ace-git 0.26.0 → 0.28.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: 0b306e7eeb68db0b211dead9498aabdeba48ca866b13a3710a886193232d310a
4
- data.tar.gz: db3448da7471947520a0aa68f70c56283f3539412456b221809ab7cd0349f976
3
+ metadata.gz: f2aefd55e04cf20182ce9302454f857fb836a9948f8c5d3287d574fda05e73d0
4
+ data.tar.gz: 630a48152d3aaa43df4d6ec23dac65f6f2496fb1ea4fa7b4dbe968a677843751
5
5
  SHA512:
6
- metadata.gz: 0af17d14185c802e2a72f1f93c99054ea31d77ff86e3e2fd62adc2f30a99d3384dbc1f1f64de41ede47175f19ba334843ed553e775770839c41b3fc6a8232372
7
- data.tar.gz: 6a5229b535fe9a4c586d1fd2e332daf90b57b0f91a899727cc677e3750aff38d55eb58f010ba1363e3956227b5f0f51879128f1bf18effac22f47236afbb53f1
6
+ metadata.gz: 38abefdfea8dc06b4b30804daaa0b9e9327a3d9042c6671c6bae876896bfc8473c5f364444142726008d49d91c50d6ad7bb4cdb8e92243f1f0c515c050030a77
7
+ data.tar.gz: 760780054d64d4cd58134ab8c6e3db5a47a8daf45dbc7080195e716a5a55b17535b986f0dbc7d383bedc2108a87e27aa3c2b8fd2bdde7ff1834654240839f2b2
data/CHANGELOG.md CHANGED
@@ -7,13 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.28.0] - 2026-10-04
11
+
12
+ ### Added
13
+ - PR receipts include merge-commit proof and `pr` JSON failures carry the classified error category on stdout (parseable non-success; text and nonzero exit unchanged).
14
+
15
+ ## [0.27.0] - 2026-10-03
16
+
17
+ ### Added
18
+ - Forge-neutral issue tracking contract with named-server selection, normalized issue evidence, and classified provider failures.
19
+
20
+ ### Fixed
21
+ - 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.
22
+
10
23
  ## [0.26.0] - 2026-10-02
11
24
 
12
25
  ### Added
13
26
  - Normalized PR comment and review evidence, comment mutation receipts, and guarded review snapshots with exact repository and head identity.
14
27
  - Recheck PR heads around collection and before review comments or thread mutations; classify unsupported capabilities and uncertain post outcomes.
15
28
 
16
-
17
29
  ## [0.25.0] - 2026-09-28
18
30
 
19
31
  ### Added
data/docs/usage.md CHANGED
@@ -199,6 +199,18 @@ ace-git pr merge 25 --expected-head SHA --method squash
199
199
  - A create request whose outcome is unknown (transport failure after send)
200
200
  reports an unknown outcome with the exact identity to reconcile; it never
201
201
  retries automatically.
202
+ - With `--format json`, a failed operation prints the classified error as
203
+ JSON on stdout (`{"error": {"category": ..., "message": ...}}` — e.g.
204
+ `expected_head_conflict`, `unsupported_capability`, `unknown_outcome`)
205
+ before the nonzero exit; text mode explains the same failure on stderr.
206
+
207
+ Provider notes (Forgejo): the delivery lifecycle rides the repository-bound
208
+ Forgejo API v1 and requires Forgejo >= 8.0 (probed before mutations; the
209
+ provider refuses below the documented capability floor). Drafts use the
210
+ forge's WIP-title convention (`draft: true` creates a `WIP:`-prefixed
211
+ title; `pr ready` strips it and proves the ready state by read-back).
212
+ Same-server fork sources are supported via the declared `--head-repo`
213
+ URL; cross-host sources refuse before any mutation.
202
214
 
203
215
  ### `ace-git version`
204
216
 
@@ -282,11 +294,15 @@ the provider contract (`Ace::Git::Providers::Base`), and normalized evidence typ
282
294
  provider packages own all forge CLI behavior:
283
295
 
284
296
  - **ace-git-github** - GitHub behavior via the `gh` CLI (PR lookups, diffs, issue
285
- synchronization). Registers under the `github` provider type.
297
+ tracking operations). Registers under the `github` provider type.
286
298
  - **ace-git-forgejo** - Forgejo behavior via the `fj` CLI. Registers under the
287
299
  `forgejo` provider type.
288
300
 
289
- Configure servers in `.ace/git.yml` (or project config) under `git.servers`, then
301
+ Both providers implement the same issue-tracking contract (issue lookup, comment
302
+ create/update/delete, label add/remove, state transitions) so task issue
303
+ synchronization stays forge-neutral; `ace-task` owns the sync orchestration.
304
+
305
+ Configure servers in `.ace/git/config.yml` under `git.servers`, then
290
306
  resolve them by name, by default, or from a git remote URL. Every failure path is
291
307
  classified (missing CLI, unauthenticated, unreachable, malformed output, object
292
308
  not found) with no silent fallbacks.
@@ -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
@@ -13,7 +13,9 @@ module Ace
13
13
  # (mutually exclusive; remote resolution when neither is given) and
14
14
  # `--format json`. Success output carries the resolved server,
15
15
  # provider, and exact PR identity; failures exit nonzero with the
16
- # classified provider error and never claim success.
16
+ # classified provider error and never claim success. JSON failures
17
+ # put the error category and message on stdout so machine callers
18
+ # can parse the classification; text goes to stderr via the runner.
17
19
  module Pr
18
20
  # Shared plumbing for all pr subcommands.
19
21
  class BaseCommand < Ace::Support::Cli::Command
@@ -24,6 +26,19 @@ module Ace
24
26
  option :remote, type: :string, desc: "Git remote used for server resolution when nothing is selected"
25
27
  option :format, type: :string, default: "text", desc: "Output format: text, json"
26
28
 
29
+ ERROR_CATEGORIES = {
30
+ "ProviderAuthenticationError" => "authentication",
31
+ "ProviderUnreachableError" => "unreachable",
32
+ "ProviderMalformedOutputError" => "malformed",
33
+ "ProviderObjectNotFoundError" => "not_found",
34
+ "ProviderCliMissingError" => "cli_missing",
35
+ "ProviderIdentityMismatchError" => "identity_mismatch",
36
+ "ProviderConflictingMatchesError" => "conflicting_matches",
37
+ "ProviderExpectedHeadConflictError" => "expected_head_conflict",
38
+ "ProviderUnsupportedCapabilityError" => "unsupported_capability",
39
+ "ProviderUnknownOutcomeError" => "unknown_outcome"
40
+ }.freeze
41
+
27
42
  private
28
43
 
29
44
  def lifecycle(options)
@@ -47,6 +62,18 @@ module Ace
47
62
  end
48
63
  end
49
64
 
65
+ # JSON mode keeps the classified failure parseable on stdout;
66
+ # the human-readable line still reaches stderr through the
67
+ # raised Cli::Error with a nonzero exit.
68
+ def render_failure(options, error)
69
+ return unless options[:format] == "json"
70
+
71
+ puts JSON.pretty_generate(
72
+ error: {category: ERROR_CATEGORIES.fetch(error.class.name.split("::").last, "error"),
73
+ message: error.message}
74
+ )
75
+ end
76
+
50
77
  def format_value(value)
51
78
  case value
52
79
  when Hash
@@ -73,7 +100,8 @@ module Ace
73
100
  head_ref: pr.head_ref,
74
101
  head_repository: pr.head_repository_url,
75
102
  base_ref: pr.base_ref,
76
- base_repository: pr.base_repository_url
103
+ base_repository: pr.base_repository_url,
104
+ merge_commit: pr.merge_commit_sha
77
105
  )
78
106
  end
79
107
  end
@@ -103,6 +131,7 @@ module Ace
103
131
  merge_commit: pr.merge_commit_sha
104
132
  )
105
133
  rescue Ace::Git::Error, ArgumentError => e
134
+ render_failure(options, e)
106
135
  raise Ace::Support::Cli::Error.new(e.message)
107
136
  end
108
137
  end
@@ -131,6 +160,7 @@ module Ace
131
160
  )
132
161
  emit_receipt(options, "created", receipt)
133
162
  rescue Ace::Git::Error, ArgumentError => e
163
+ render_failure(options, e)
134
164
  raise Ace::Support::Cli::Error.new(e.message)
135
165
  end
136
166
  end
@@ -153,6 +183,7 @@ module Ace
153
183
  )
154
184
  emit_receipt(options, "updated", receipt)
155
185
  rescue Ace::Git::Error, ArgumentError => e
186
+ render_failure(options, e)
156
187
  raise Ace::Support::Cli::Error.new(e.message)
157
188
  end
158
189
  end
@@ -168,6 +199,7 @@ module Ace
168
199
  receipt = lifecycle(options).ready(identifier, expected_head: expected_head)
169
200
  emit_receipt(options, "readied", receipt)
170
201
  rescue Ace::Git::Error, ArgumentError => e
202
+ render_failure(options, e)
171
203
  raise Ace::Support::Cli::Error.new(e.message)
172
204
  end
173
205
  end
@@ -184,6 +216,7 @@ module Ace
184
216
  receipt = lifecycle(options).merge(identifier, expected_head: expected_head, method: method)
185
217
  emit_receipt(options, "merged", receipt)
186
218
  rescue Ace::Git::Error, ArgumentError => e
219
+ render_failure(options, e)
187
220
  raise Ace::Support::Cli::Error.new(e.message)
188
221
  end
189
222
  end
@@ -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
- 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.26.0"
5
+ VERSION = "0.28.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.26.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-02 00:00:00.000000000 Z
10
+ date: 2026-10-04 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