ace-git 0.26.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 +8 -1
- 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/providers/base.rb +31 -0
- 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,13 +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
|
+
|
|
10
18
|
## [0.26.0] - 2026-10-02
|
|
11
19
|
|
|
12
20
|
### Added
|
|
13
21
|
- Normalized PR comment and review evidence, comment mutation receipts, and guarded review snapshots with exact repository and head identity.
|
|
14
22
|
- Recheck PR heads around collection and before review comments or thread mutations; classify unsupported capabilities and uncertain post outcomes.
|
|
15
23
|
|
|
16
|
-
|
|
17
24
|
## [0.25.0] - 2026-09-28
|
|
18
25
|
|
|
19
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
|
|
@@ -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
|
|
@@ -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-
|
|
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
|