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 +4 -4
- data/CHANGELOG.md +14 -0
- data/docs/usage.md +5 -1
- data/lib/ace/git/atoms/server_url.rb +40 -0
- data/lib/ace/git/errors.rb +5 -0
- data/lib/ace/git/organisms/issue_tracking.rb +270 -0
- data/lib/ace/git/organisms/pull_request_lifecycle.rb +143 -0
- data/lib/ace/git/providers/base.rb +75 -0
- data/lib/ace/git/providers/evidence.rb +28 -1
- data/lib/ace/git/server_registry.rb +14 -1
- data/lib/ace/git/version.rb +1 -1
- data/lib/ace/git.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f05486971c36b968a3eeeb42f4bfdeda24b0a3d672732dd5ff532ab4a7db034
|
|
4
|
+
data.tar.gz: 21fe708e731585aba811f719acc8705b906b2c2e11cf249f9d5279223890ea69
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/lib/ace/git/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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`
|
data/lib/ace/git/version.rb
CHANGED
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.
|
|
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-
|
|
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
|