ace-git 0.25.0 → 0.27.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: '089e377ec56099fb872544bfc754f6f95f00ecaf157715ecad93e726e9c8960f'
4
- data.tar.gz: 35bb8b556655c0ef1804fb2fe26039dd182115e558127254c49e17da9ce3f1ba
3
+ metadata.gz: 8f05486971c36b968a3eeeb42f4bfdeda24b0a3d672732dd5ff532ab4a7db034
4
+ data.tar.gz: 21fe708e731585aba811f719acc8705b906b2c2e11cf249f9d5279223890ea69
5
5
  SHA512:
6
- metadata.gz: c156b7e8b123f15f9d305d27933d68de72d640e16a803a9d7b5ce2cce12c1c7cf537bb7e6ae31e7c8ca044999869160ccf835281fdcc51e3bee76589cd43363f
7
- data.tar.gz: 4eaeac69d71391c491718c68e68e1599a0a3e1991eae4a25d3e4e2a922959ef13e940c7d863eae01478a78ae9f81940a016fcac46da27ee7bbdf427f64527a03
6
+ metadata.gz: a4439500ea545b8c557af9e6b436852b14bebec56f4e44b4f36c72c005851d72d29256cef3a70fcff6de50900bc2e101d5f55ef93a7b48c850b69c200157ddca
7
+ data.tar.gz: 705d2d95b952af1c2bd44a8da79b1173210b00313744c3dbd554b9d22d76983534b5db3552655a8fd797584accc4c774b205bb5710cb8e8b0fd61ddddf0fc80e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.0] - 2026-10-03
11
+
12
+ ### Added
13
+ - Forge-neutral issue tracking contract with named-server selection, normalized issue evidence, and classified provider failures.
14
+
15
+ ### Fixed
16
+ - Scheme-less server URLs with numeric ports (forge.example:8443/owner/repo) keep the port in the derived web endpoint instead of parsing it as an scp-style repo path.
17
+
18
+ ## [0.26.0] - 2026-10-02
19
+
20
+ ### Added
21
+ - Normalized PR comment and review evidence, comment mutation receipts, and guarded review snapshots with exact repository and head identity.
22
+ - Recheck PR heads around collection and before review comments or thread mutations; classify unsupported capabilities and uncertain post outcomes.
23
+
10
24
  ## [0.25.0] - 2026-09-28
11
25
 
12
26
  ### Added
data/docs/usage.md CHANGED
@@ -282,10 +282,14 @@ the provider contract (`Ace::Git::Providers::Base`), and normalized evidence typ
282
282
  provider packages own all forge CLI behavior:
283
283
 
284
284
  - **ace-git-github** - GitHub behavior via the `gh` CLI (PR lookups, diffs, issue
285
- synchronization). Registers under the `github` provider type.
285
+ tracking operations). Registers under the `github` provider type.
286
286
  - **ace-git-forgejo** - Forgejo behavior via the `fj` CLI. Registers under the
287
287
  `forgejo` provider type.
288
288
 
289
+ Both providers implement the same issue-tracking contract (issue lookup, comment
290
+ create/update/delete, label add/remove, state transitions) so task issue
291
+ synchronization stays forge-neutral; `ace-task` owns the sync orchestration.
292
+
289
293
  Configure servers in `.ace/git.yml` (or project config) under `git.servers`, then
290
294
  resolve them by name, by default, or from a git remote URL. Every failure path is
291
295
  classified (missing CLI, unauthenticated, unreachable, malformed output, object
@@ -38,6 +38,46 @@ module Ace
38
38
  text.chomp("/").downcase
39
39
  end
40
40
 
41
+ # Derive the HTTP(S) web endpoint for a configured server URL.
42
+ # HTTPS/HTTP URLs keep their scheme and authority; SSH-style URLs
43
+ # (scp-style "git@host:path", "ssh://") map to https web endpoints.
44
+ #
45
+ # @param url [String] configured server URL
46
+ # @return [String] web base without trailing slash or .git suffix
47
+ def self.web_base(url)
48
+ text = url.to_s.strip
49
+ scheme = text[/\A(https?):\/\//i, 1]&.downcase
50
+ rest = text.sub(/\A[a-z][a-z0-9+.\-]*:\/\//i, "").sub(/\A[^\/\s]+@/, "")
51
+ if scheme
52
+ authority, _, path = rest.partition("/")
53
+ elsif text.match?(/\Assh:\/\//i)
54
+ # ssh:// URLs separate the authority (with optional port) by slash.
55
+ rest = text.sub(/\Assh:\/\//i, "").sub(/\A[^\/\s]+@/, "")
56
+ authority, _, path = rest.partition("/")
57
+ authority = authority.split(":").first.to_s
58
+ scheme = "https"
59
+ else
60
+ host_part, sep, remainder = rest.partition(":")
61
+ if sep == ":" && remainder.match?(%r{\A\d+/})
62
+ # A numeric segment before the slash is an SSH-style port, not
63
+ # an scp repo path: keep it in the web authority.
64
+ authority = "#{host_part}:#{remainder.split("/", 2).first}"
65
+ path = remainder.split("/", 2).last
66
+ elsif sep == ":" && !remainder.start_with?("/")
67
+ # scp-style "git@host:path"; ssh ports ride the colon and are
68
+ # dropped from the web endpoint.
69
+ authority = host_part.split(":").first.to_s
70
+ path = remainder.sub(%r{\A\d+/}, "")
71
+ else
72
+ # Scheme-less "host/owner/repo" splits on the first slash.
73
+ authority, _, path = rest.partition("/")
74
+ end
75
+ scheme = "https"
76
+ end
77
+ path = path.sub(%r{/+\z}, "").sub(/\.git\z/i, "")
78
+ "#{scheme}://#{authority}/#{path}"
79
+ end
80
+
41
81
  # Compare a configured server URL against a remote URL for identity.
42
82
  #
43
83
  # @param server_url [String] configured server base URL
@@ -78,5 +78,10 @@ module Ace
78
78
  # failure after the send). Contains the exact base/head identity needed to
79
79
  # reconcile by lookup; never retried automatically.
80
80
  class ProviderUnknownOutcomeError < Error; end
81
+
82
+ # Reconciliation reads all completed and the tracked marker never
83
+ # appeared: authoritative absence of a prior create. The only outcome
84
+ # that may authorize a fresh create POST.
85
+ class ProviderReconcileAbsenceError < ProviderUnknownOutcomeError; end
81
86
  end
82
87
  end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Organisms
6
+ # One ACE task owns at most one exact remote issue. The provider handles
7
+ # transport; this service owns only the marker, label, and task lifecycle.
8
+ class IssueTracking
9
+ STICKY_MARKER = "<!-- ace-task:tracked -->"
10
+ RECONCILE_READ_ATTEMPTS = 3
11
+ RECONCILE_READ_INTERVAL_SECONDS = 1
12
+ CREATE_RECONCILE_READ_ATTEMPTS = 5
13
+ CREATE_RECONCILE_READ_INTERVAL_SECONDS = 2
14
+ TRACKED_LABEL = "ace:tracked"
15
+ TERMINAL_STATUSES = %w[done cancelled skipped].freeze
16
+
17
+ def initialize(provider:)
18
+ @provider = provider
19
+ end
20
+
21
+ def validate_link!(number:, task_id: nil, previous_task_id: nil)
22
+ snapshot = fetch(number)
23
+ owners = owned_task_ids(snapshot)
24
+ if sticky_comment(snapshot) && owners.empty?
25
+ raise ProviderMalformedOutputError, "Issue ##{number} has an ACE marker without a task owner"
26
+ end
27
+ accepted = [task_id, previous_task_id].compact.map(&:to_s).uniq
28
+ return snapshot if owners.empty? || (!accepted.empty? && owners.all? { |owner| accepted.include?(owner) })
29
+
30
+ raise ProviderIdentityMismatchError,
31
+ "Issue ##{number} is already owned by ACE task #{owners.join(', ')}"
32
+ end
33
+
34
+ def sync(number:, task_id:, task_link:, task_status:, previous_task_id: nil,
35
+ create_pending: false, before_create: nil, comment_only: false)
36
+ # Reparenting/promotion changes the local ID; the remote marker may
37
+ # still name the previous ID, or may already carry the new one when
38
+ # a prior sync failed partway. Both count as this task's ownership.
39
+ snapshot = validate_link!(number: number, task_id: task_id, previous_task_id: previous_task_id)
40
+ sticky = sticky_comment(snapshot)
41
+ desired_line = "Tracked in ace-task: [#{task_id}](#{task_link})"
42
+ desired_body = compose_tracking_body(sticky&.dig(:body), desired_line)
43
+ if sticky.nil?
44
+ if create_pending
45
+ # A prior create ended with an unknown outcome: this replay must
46
+ # reconcile (bounded extended reads), never issue a second POST.
47
+ resolved = CREATE_RECONCILE_READ_ATTEMPTS.times.any? do |attempt|
48
+ snapshot = fetch(number)
49
+ found = sticky_comment(snapshot)
50
+ break true if found&.[](:body) == desired_body
51
+
52
+ if found
53
+ # The marker that just appeared may belong to another task
54
+ # or be malformed; validate ownership before overwriting
55
+ # anything (same guard as validate_link!).
56
+ owners = owned_task_ids(snapshot)
57
+ accepted = [task_id, previous_task_id].compact.map(&:to_s).uniq
58
+ if (found && owners.empty?) || !owners.all? { |owner| accepted.include?(owner) }
59
+ raise ProviderIdentityMismatchError,
60
+ "Issue ##{number} is already owned by ACE task #{owners.join(', ')}"
61
+ end
62
+ desired_line = "Tracked in ace-task: [#{task_id}](#{task_link})"
63
+ reconciled_body = compose_tracking_body(found[:body], desired_line)
64
+ desired_body = reconciled_body
65
+ mutate_and_reconcile(number, desired_body: reconciled_body) do
66
+ @provider.update_issue_comment(number: number, comment_id: found[:id], body: reconciled_body)
67
+ end
68
+ break true
69
+ end
70
+ sleep(create_reconcile_interval(attempt))
71
+ false
72
+ end
73
+ raise ProviderReconcileAbsenceError,
74
+ "Prior tracking comment create for issue ##{number} is still unresolved" unless resolved
75
+ else
76
+ mutate_and_reconcile(number, desired_body: desired_body) do
77
+ # Persist the uncertain-create guard before the only
78
+ # non-idempotent call, so a stop after the POST leaves a
79
+ # durable reconcile record instead of a duplicate on replay.
80
+ before_create&.call
81
+ @provider.create_issue_comment(number: number, body: desired_body)
82
+ end
83
+ end
84
+ elsif sticky[:body] != desired_body
85
+ mutate_and_reconcile(number, desired_body: desired_body) do
86
+ @provider.update_issue_comment(number: number, comment_id: sticky[:id], body: desired_body)
87
+ end
88
+ end
89
+ verified = nil
90
+ unless comment_only
91
+ begin
92
+ snapshot = fetch(number)
93
+ unless snapshot[:labels].include?(TRACKED_LABEL)
94
+ mutate_and_reconcile(number, label: TRACKED_LABEL) do
95
+ @provider.add_issue_label(number: number, label: TRACKED_LABEL)
96
+ end
97
+ end
98
+ desired_state = TERMINAL_STATUSES.include?(task_status.to_s) ? :closed : :open
99
+ snapshot = fetch(number)
100
+ unless snapshot[:issue].state == desired_state
101
+ mutate_and_reconcile(number, state: desired_state) do
102
+ @provider.set_issue_state(number: number, state: desired_state)
103
+ end
104
+ end
105
+ rescue ProviderAuthenticationError, ProviderObjectNotFoundError => e
106
+ # The non-idempotent comment POST already committed, so a later
107
+ # definitive-looking failure cannot prove the create failed.
108
+ # Replay must reconcile the committed marker, never re-POST.
109
+ raise ProviderUnknownOutcomeError,
110
+ "post-create step failed after the tracking comment committed: #{e.class}: #{e.message}"
111
+ end
112
+ verified = fetch(number)
113
+ unless sticky_comment(verified)&.[](:body) == desired_body &&
114
+ verified[:labels].include?(TRACKED_LABEL) && verified[:issue].state == desired_state
115
+ raise ProviderMalformedOutputError, "Issue ##{number} did not reflect ACE tracking updates"
116
+ end
117
+ else
118
+ verified = fetch(number)
119
+ unless sticky_comment(verified)&.[](:body) == desired_body
120
+ raise ProviderMalformedOutputError, "Issue ##{number} did not reflect ACE tracking updates"
121
+ end
122
+ end
123
+ verified
124
+ end
125
+
126
+ # Clear only the task's marker/comment and ACE label. Issue state is
127
+ # intentionally untouched, even when it was changed by prior sync.
128
+ def clear(number:, task_id:, previous_task_id: nil)
129
+ snapshot = fetch(number)
130
+ owners = owned_task_ids(snapshot)
131
+ sticky = sticky_comment(snapshot)
132
+ if sticky && owners.empty?
133
+ raise ProviderMalformedOutputError, "Issue ##{number} has an ACE marker without a task owner"
134
+ end
135
+ accepted = [task_id, previous_task_id].compact.map(&:to_s).uniq
136
+ unless owners.empty? || (!accepted.empty? && owners.all? { |owner| accepted.include?(owner) })
137
+ raise ProviderIdentityMismatchError, "Issue ##{number} is owned by another ACE task"
138
+ end
139
+ if sticky
140
+ # Strip inline markers concatenated onto note lines (historical
141
+ # layout) so the rebuilt body verifies marker-free.
142
+ preserved = sticky[:body].to_s.lines
143
+ .reject { |line| line.chomp == STICKY_MARKER || line.start_with?("Tracked in ace-task: ") }
144
+ .map { |line| line.gsub(STICKY_MARKER, "") }
145
+ mutate_and_reconcile(number, absent_comment: true) do
146
+ if preserved.empty?
147
+ @provider.delete_issue_comment(number: number, comment_id: sticky[:id])
148
+ else
149
+ @provider.update_issue_comment(number: number, comment_id: sticky[:id], body: preserved.join)
150
+ end
151
+ end
152
+ end
153
+ snapshot = fetch(number)
154
+ if snapshot[:labels].include?(TRACKED_LABEL)
155
+ mutate_and_reconcile(number, absent_label: TRACKED_LABEL) do
156
+ @provider.remove_issue_label(number: number, label: TRACKED_LABEL)
157
+ end
158
+ end
159
+ verified = fetch(number)
160
+ if sticky_comment(verified) || verified[:labels].include?(TRACKED_LABEL)
161
+ raise ProviderMalformedOutputError, "Issue ##{number} still contains ACE tracking artifacts"
162
+ end
163
+ verified
164
+ end
165
+
166
+ private
167
+
168
+ # Unrelated lines are preserved verbatim (including trailing
169
+ # whitespace); only ACE-owned lines are ever added or removed. A
170
+ # marker concatenated onto a note line (historical layout) is
171
+ # stripped in place so clear can rebuild the body cleanly.
172
+ def compose_tracking_body(existing_body, desired_line)
173
+ preserved = existing_body.to_s.lines
174
+ .reject { |line| line.chomp == STICKY_MARKER || line.start_with?("Tracked in ace-task: ") }
175
+ .map { |line| line.gsub(STICKY_MARKER, "") }
176
+ prefix = preserved.join
177
+ prefix += "\n" unless prefix.empty? || prefix.end_with?("\n")
178
+ "#{prefix}#{STICKY_MARKER}\n#{desired_line}"
179
+ end
180
+
181
+ def create_reconcile_interval(attempt)
182
+ CREATE_RECONCILE_READ_INTERVAL_SECONDS * (attempt + 1)
183
+ end
184
+
185
+ def fetch(number)
186
+ snapshot = @provider.issue_tracking(number: number)
187
+ unless snapshot.is_a?(Hash) && snapshot[:issue]&.number.to_i == number.to_i &&
188
+ snapshot[:comments].is_a?(Array) && snapshot[:labels].is_a?(Array)
189
+ raise ProviderMalformedOutputError, "Incomplete issue tracking evidence for ##{number}"
190
+ end
191
+ # Pagination across capped pages can repeat comments; dedupe by id
192
+ # so duplicate pages never read as multiple ACE tracking comments.
193
+ seen_ids = []
194
+ snapshot = snapshot.merge(
195
+ comments: snapshot[:comments].reject do |comment|
196
+ if seen_ids.include?(comment[:id])
197
+ true
198
+ else
199
+ seen_ids << comment[:id]
200
+ false
201
+ end
202
+ end
203
+ )
204
+ snapshot
205
+ end
206
+
207
+ def sticky_comment(snapshot)
208
+ matches = snapshot[:comments].select { |comment| comment[:body].to_s.include?(STICKY_MARKER) }
209
+ if matches.length > 1
210
+ raise ProviderIdentityMismatchError, "Multiple ACE tracking comments on issue ##{snapshot[:issue].number}"
211
+ end
212
+ matches.first
213
+ end
214
+
215
+ def owned_task_ids(snapshot)
216
+ sticky = sticky_comment(snapshot)
217
+ return [] unless sticky
218
+
219
+ sticky[:body].to_s.lines.filter_map do |line|
220
+ line[/\ATracked in ace-task: \[([^\]]+)\]\(/, 1]
221
+ end.uniq
222
+ end
223
+
224
+ def mutate_and_reconcile(number, desired_body: nil, label: nil, state: nil,
225
+ absent_comment: false, absent_label: nil)
226
+ yield
227
+ rescue ProviderUnknownOutcomeError
228
+ begin
229
+ @reconcile_resolved = reconcile_outcome(number, desired_body: desired_body, label: label, state: state,
230
+ absent_comment: absent_comment, absent_label: absent_label)
231
+ rescue ProviderUnknownOutcomeError
232
+ raise
233
+ rescue StandardError => e
234
+ # The mutation outcome is still unknown when reconciliation reads
235
+ # fail (a 401/404 on a read says nothing about the write); keep
236
+ # the uncertainty so replay reconciles instead of duplicating.
237
+ raise ProviderUnknownOutcomeError,
238
+ "Mutation outcome unresolved; reconciliation read failed: #{e.class}"
239
+ end
240
+ raise unless @reconcile_resolved
241
+ end
242
+
243
+ def reconcile_outcome(number, desired_body: nil, label: nil, state: nil,
244
+ absent_comment: false, absent_label: nil)
245
+ resolved = RECONCILE_READ_ATTEMPTS.times.any? do |attempt|
246
+ snapshot = fetch(number)
247
+ outcome = if desired_body
248
+ sticky_comment(snapshot)&.[](:body) == desired_body
249
+ elsif label
250
+ snapshot[:labels].include?(label)
251
+ elsif state
252
+ snapshot[:issue].state == state
253
+ elsif absent_comment
254
+ sticky_comment(snapshot).nil?
255
+ elsif absent_label
256
+ !snapshot[:labels].include?(absent_label)
257
+ end
258
+ break outcome if outcome
259
+
260
+ # The forge may still be committing the write; an immediate read
261
+ # can miss it and a replay would then duplicate the mutation.
262
+ sleep(RECONCILE_READ_INTERVAL_SECONDS * (attempt + 1)) if desired_body
263
+ outcome
264
+ end
265
+ resolved
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -45,6 +45,134 @@ module Ace
45
45
  provider_for(server).pull_request(number: reference.number)
46
46
  end
47
47
 
48
+ # Exact-head PR metadata without the diff/comment/check inventory.
49
+ # Delta resolution needs identity and base provenance only; pulling
50
+ # the full diff there would transfer it just to discard it.
51
+ def review_metadata_snapshot(identifier)
52
+ reference = parse_identifier(identifier)
53
+ server = resolve_server_for(reference)
54
+ provider = provider_for(server)
55
+ before = provider.pull_request(number: reference.number)
56
+ head = required_head!(before)
57
+ details = provider.pull_request_review_details(number: reference.number)
58
+ body_before = provider.pull_request_body(number: reference.number)
59
+ # Hydration is a remote read: build the metadata from the read that
60
+ # follows it, and re-read the body after — the task spec text lives
61
+ # in the body, so an edit between reads invalidates the collection.
62
+ after = provider.pull_request(number: reference.number)
63
+ body_after = provider.pull_request_body(number: reference.number)
64
+ if body_before != body_after
65
+ raise ProviderExpectedHeadConflictError,
66
+ "PR body changed while collecting review metadata; retry on the current head"
67
+ end
68
+ after = after.with(body: body_after) unless body_after.nil?
69
+ # Body hydration and identity must agree with the base provenance
70
+ # read before collection; a moved base invalidates the metadata.
71
+ latest_details = provider.pull_request_review_details(number: reference.number)
72
+ if after.head_sha != head || after.head_ref != before.head_ref ||
73
+ after.base_ref != before.base_ref || latest_details.base_sha != details.base_sha
74
+ raise ProviderExpectedHeadConflictError,
75
+ "PR head/base changed while collecting review metadata; retry on the current head"
76
+ end
77
+ ProviderReviewSnapshot.new(
78
+ provider: server.provider, pull_request: after,
79
+ base_sha: details.base_sha, files: details.files,
80
+ diff: nil, review_evidence: ProviderReviewEvidence.new(
81
+ server_name: server.name, repository_url: server.url,
82
+ pr_number: before.number, head_sha: head, comments: [], reviews: []
83
+ ),
84
+ checks: []
85
+ )
86
+ end
87
+
88
+ # Collect one complete review packet under an exact head/base guard.
89
+ # Provider failure is propagated; empty diff/comments remain valid
90
+ # evidence only after the full inventory and second read agree.
91
+ def review_snapshot(identifier, include_comments: true)
92
+ reference = parse_identifier(identifier)
93
+ server = resolve_server_for(reference)
94
+ provider = provider_for(server)
95
+ before = provider.pull_request(number: reference.number)
96
+ head = required_head!(before)
97
+ details = provider.pull_request_review_details(number: reference.number)
98
+ diff = provider.pull_request_diff(number: reference.number)
99
+ unless diff.is_a?(String)
100
+ raise ProviderMalformedOutputError, "PR diff is not text"
101
+ end
102
+ comments = if include_comments
103
+ provider.pull_request_review_evidence(number: reference.number, expected_head: head)
104
+ else
105
+ ProviderReviewEvidence.new(
106
+ server_name: server.name, repository_url: server.url,
107
+ pr_number: before.number, head_sha: head, comments: [], reviews: []
108
+ )
109
+ end
110
+ checks = provider.pull_request_checks(number: reference.number, head_sha: head)
111
+ # The CLI-parsed PR may omit the description; hydrate it from the
112
+ # provider's body capability when available (nil stays absence).
113
+ # Hydration is itself a remote read: the final identity comes from
114
+ # the read that follows it, so body and identity can never come
115
+ # from different points in time.
116
+ body_before = provider.pull_request_body(number: reference.number)
117
+ after = provider.pull_request(number: reference.number)
118
+ body_after = provider.pull_request_body(number: reference.number)
119
+ if body_before != body_after
120
+ raise ProviderExpectedHeadConflictError,
121
+ "PR body changed while collecting review evidence; retry on the current head"
122
+ end
123
+ after = after.with(body: body_after) unless body_after.nil?
124
+ latest_details = provider.pull_request_review_details(number: reference.number)
125
+ if after.head_sha != head || latest_details.base_sha != details.base_sha ||
126
+ after.head_ref != before.head_ref || after.base_ref != before.base_ref ||
127
+ after.head_repository_url != before.head_repository_url ||
128
+ after.base_repository_url != before.base_repository_url
129
+ raise ProviderExpectedHeadConflictError,
130
+ "PR head/base changed while collecting review evidence; retry on the current head"
131
+ end
132
+ unless details.files.is_a?(Array) && checks.is_a?(Array) &&
133
+ comments.head_sha == head && comments.pr_number == before.number
134
+ raise ProviderMalformedOutputError, "PR review evidence is incomplete or mismatched"
135
+ end
136
+ ProviderReviewSnapshot.new(
137
+ provider: server.provider, pull_request: after,
138
+ base_sha: details.base_sha, files: details.files,
139
+ diff: diff, review_evidence: comments, checks: checks
140
+ )
141
+ end
142
+
143
+ def post_review_comment(identifier, expected_head:, body:, correlation:)
144
+ reference = parse_identifier(identifier)
145
+ provider = provider_for(resolve_server_for(reference))
146
+ provider.create_pull_request_comment(
147
+ number: reference.number, expected_head: expected_head,
148
+ body: body, correlation: correlation
149
+ )
150
+ end
151
+
152
+ def update_review_comment(identifier, expected_head:, comment_id:, body:)
153
+ reference = parse_identifier(identifier)
154
+ provider = provider_for(resolve_server_for(reference))
155
+ provider.update_pull_request_comment(
156
+ number: reference.number, expected_head: expected_head,
157
+ comment_id: comment_id, body: body
158
+ )
159
+ end
160
+
161
+ def resolve_review_thread(identifier, expected_head:, thread_id:)
162
+ reference = parse_identifier(identifier)
163
+ provider = provider_for(resolve_server_for(reference))
164
+ provider.resolve_pull_request_thread(
165
+ number: reference.number, expected_head: expected_head,
166
+ thread_id: thread_id
167
+ )
168
+ end
169
+
170
+ def file_at_ref(identifier, path:, ref:)
171
+ reference = parse_identifier(identifier)
172
+ provider = provider_for(resolve_server_for(reference))
173
+ provider.repository_file(path: path, ref: ref)
174
+ end
175
+
48
176
  # Create (or reconcile to) a pull request for an exact base/head
49
177
  # identity. There is no identifier: the selected server's repository
50
178
  # is the base, `head_repository_url` names the source (defaults to
@@ -106,8 +234,19 @@ module Ace
106
234
  )
107
235
  end
108
236
 
237
+ def resolved_server_url(identifier)
238
+ reference = parse_identifier(identifier)
239
+ resolve_server_for(reference).url
240
+ end
109
241
  private
110
242
 
243
+ def required_head!(pull_request)
244
+ head = pull_request.head_sha
245
+ return head if head.to_s.match?(/\A[0-9a-f]{40}\z/)
246
+
247
+ raise ProviderMalformedOutputError, "PR evidence is missing an exact head SHA"
248
+ end
249
+
111
250
  def parse_identifier(identifier)
112
251
  reference = Atoms::PrReference.parse(identifier)
113
252
  raise ArgumentError, "Invalid PR identifier: #{identifier}" unless reference
@@ -154,7 +293,11 @@ module Ace
154
293
  end
155
294
  end
156
295
 
296
+ # The forge server URL for a PR identifier, resolved on demand
297
+ # through the shared registry (pure resolution, no mutation).
298
+
157
299
  def provider_for(server)
300
+ @resolved_server_url = server.url
158
301
  Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
159
302
  end
160
303
 
@@ -89,6 +89,37 @@ module Ace
89
89
  raise NotImplementedError, "Providers must implement #{self.class}#issue"
90
90
  end
91
91
 
92
+ # Read the exact issue and its comments/labels for task tracking.
93
+ # Returns {issue: ProviderIssue, comments: [{id:, body:}], labels: [String]}.
94
+ # Every mutation below is scoped to this provider's resolved server.
95
+ def issue_tracking(number:)
96
+ raise NotImplementedError, "Providers must implement #{self.class}#issue_tracking"
97
+ end
98
+
99
+ def create_issue_comment(number:, body:)
100
+ raise NotImplementedError, "Providers must implement #{self.class}#create_issue_comment"
101
+ end
102
+
103
+ def update_issue_comment(number:, comment_id:, body:)
104
+ raise NotImplementedError, "Providers must implement #{self.class}#update_issue_comment"
105
+ end
106
+
107
+ def delete_issue_comment(number:, comment_id:)
108
+ raise NotImplementedError, "Providers must implement #{self.class}#delete_issue_comment"
109
+ end
110
+
111
+ def add_issue_label(number:, label:)
112
+ raise NotImplementedError, "Providers must implement #{self.class}#add_issue_label"
113
+ end
114
+
115
+ def remove_issue_label(number:, label:)
116
+ raise NotImplementedError, "Providers must implement #{self.class}#remove_issue_label"
117
+ end
118
+
119
+ def set_issue_state(number:, state:)
120
+ raise NotImplementedError, "Providers must implement #{self.class}#set_issue_state"
121
+ end
122
+
92
123
  # Fetch check/CI status evidence for a reference.
93
124
  #
94
125
  # @param ref [String] commit sha or branch name
@@ -104,6 +135,50 @@ module Ace
104
135
  raise NotImplementedError, "Providers must implement #{self.class}#repository"
105
136
  end
106
137
 
138
+ # Collect normalized conversation comments, code comments and reviews
139
+ # for one exact PR head. A provider must reject malformed collections;
140
+ # an empty successful collection is represented by empty arrays.
141
+ def pull_request_review_evidence(number:, expected_head:)
142
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request_review_evidence"
143
+ end
144
+
145
+ # The PR description text when the provider can supply it; nil stays
146
+ # an honest absence for providers whose CLI surface omits the body.
147
+ def pull_request_body(number:)
148
+ nil
149
+ end
150
+
151
+ # The exact base SHA and complete file inventory used to prove that a
152
+ # review diff has not been silently truncated.
153
+ def pull_request_review_details(number:)
154
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request_review_details"
155
+ end
156
+
157
+ def pull_request_checks(number:, head_sha:)
158
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request_checks"
159
+ end
160
+
161
+ def repository_file(path:, ref:)
162
+ raise NotImplementedError, "Providers must implement #{self.class}#repository_file"
163
+ end
164
+
165
+ # Create a top-level PR comment. `correlation` is a stable session key
166
+ # embedded in the body so a retry can find exactly one prior send.
167
+ # A possible post-send transport failure raises ProviderUnknownOutcomeError.
168
+ def create_pull_request_comment(number:, expected_head:, body:, correlation:)
169
+ raise NotImplementedError, "Providers must implement #{self.class}#create_pull_request_comment"
170
+ end
171
+
172
+ def update_pull_request_comment(number:, expected_head:, comment_id:, body:)
173
+ raise NotImplementedError, "Providers must implement #{self.class}#update_pull_request_comment"
174
+ end
175
+
176
+ # Thread resolution is a separate capability. Unsupported providers
177
+ # must raise ProviderUnsupportedCapabilityError, never simulate success.
178
+ def resolve_pull_request_thread(number:, expected_head:, thread_id:)
179
+ raise NotImplementedError, "Providers must implement #{self.class}#resolve_pull_request_thread"
180
+ end
181
+
107
182
  # ---- PR lifecycle mutations ----
108
183
  #
109
184
  # Contract requirements (enforced by every provider implementation):
@@ -11,7 +11,7 @@ module Ace
11
11
  # Pull request state values: :open, :merged, :closed. Issue state values:
12
12
  # :open, :closed.
13
13
  ProviderPullRequest = Data.define(
14
- :server_name, :number, :title, :state, :head_ref, :base_ref,
14
+ :server_name, :number, :title, :body, :state, :head_ref, :base_ref,
15
15
  :head_sha, :author, :url, :draft, :merged_at,
16
16
  :head_repository_url, :base_repository_url, :merge_commit_sha
17
17
  )
@@ -21,6 +21,33 @@ module Ace
21
21
  # Normalized check/CI evidence for one check run.
22
22
  ProviderCheck = Data.define(:server_name, :name, :state, :conclusion, :url)
23
23
 
24
+ # Comments keep the exact repository and PR identity supplied by the
25
+ # selected provider. A nil path/line denotes a conversation comment.
26
+ ProviderReviewComment = Data.define(
27
+ :server_name, :repository_url, :pr_number, :id, :author, :body,
28
+ :url, :path, :line, :head_sha, :resolved, :thread_id
29
+ )
30
+
31
+ ProviderReview = Data.define(
32
+ :server_name, :repository_url, :pr_number, :id, :author, :body,
33
+ :state, :url, :head_sha
34
+ )
35
+
36
+ ProviderReviewEvidence = Data.define(
37
+ :server_name, :repository_url, :pr_number, :head_sha, :comments, :reviews
38
+ )
39
+
40
+ ProviderReviewDetails = Data.define(:server_name, :repository_url, :pr_number, :base_sha, :files)
41
+
42
+ ProviderReviewSnapshot = Data.define(
43
+ :provider, :pull_request, :base_sha, :files, :diff, :review_evidence, :checks
44
+ )
45
+
46
+ ProviderReviewMutation = Data.define(
47
+ :server_name, :repository_url, :pr_number, :head_sha, :comment,
48
+ :idempotency
49
+ )
50
+
24
51
  # Normalized repository evidence.
25
52
  ProviderRepository = Data.define(:server_name, :full_name, :default_branch, :url)
26
53
 
@@ -129,7 +129,20 @@ module Ace
129
129
  # @param repository_url [String] repository URL in any supported shape
130
130
  # @return [Array<ResolvedServer>] matching servers (may be empty)
131
131
  def matching_servers(repository_url)
132
- entries.map(&:server).select { |server| Atoms::ServerUrl.match?(server.url, repository_url) }
132
+ supplied = repository_url.to_s
133
+ supplied_scheme = supplied[/\A([a-z][a-z0-9+.\-]*):\/\//i, 1]&.downcase
134
+ entries.map(&:server).select do |server|
135
+ # Compare the derived web endpoint so SSH clone URLs (including
136
+ # custom SSH ports) match their HTTPS issue URLs.
137
+ next false unless Atoms::ServerUrl.match?(Atoms::ServerUrl.web_base(server.url), supplied)
138
+ next true if supplied_scheme.nil?
139
+
140
+ # An explicit issue-URL scheme must not downgrade to a server
141
+ # serving the same host/path over a weaker scheme. SSH-configured
142
+ # clones serve their web endpoint over HTTPS.
143
+ server_scheme = Atoms::ServerUrl.web_base(server.url)[/\A(https?):\/\//i, 1]&.downcase
144
+ server_scheme == supplied_scheme
145
+ end
133
146
  end
134
147
 
135
148
  # Configured servers whose repository path equals `owner/repo`
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = "0.25.0"
5
+ VERSION = "0.27.0"
6
6
  end
7
7
  end
data/lib/ace/git.rb CHANGED
@@ -222,6 +222,7 @@ require_relative "git/molecules/git_status_fetcher"
222
222
  require_relative "git/organisms/diff_orchestrator"
223
223
  require_relative "git/organisms/repo_status_loader"
224
224
  require_relative "git/organisms/pull_request_lifecycle"
225
+ require_relative "git/organisms/issue_tracking"
225
226
  require_relative "git/resolved_server"
226
227
  require_relative "git/server_registry"
227
228
  require_relative "git/providers/base"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-29 00:00:00.000000000 Z
10
+ date: 2026-10-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-config
@@ -174,6 +174,7 @@ files:
174
174
  - lib/ace/git/molecules/git_status_fetcher.rb
175
175
  - lib/ace/git/molecules/recent_commits_fetcher.rb
176
176
  - lib/ace/git/organisms/diff_orchestrator.rb
177
+ - lib/ace/git/organisms/issue_tracking.rb
177
178
  - lib/ace/git/organisms/pull_request_lifecycle.rb
178
179
  - lib/ace/git/organisms/repo_status_loader.rb
179
180
  - lib/ace/git/providers.rb