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 +4 -4
- data/CHANGELOG.md +13 -1
- data/docs/usage.md +18 -2
- data/lib/ace/git/atoms/server_url.rb +40 -0
- data/lib/ace/git/cli/commands/pr.rb +35 -2
- 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: f2aefd55e04cf20182ce9302454f857fb836a9948f8c5d3287d574fda05e73d0
|
|
4
|
+
data.tar.gz: 630a48152d3aaa43df4d6ec23dac65f6f2496fb1ea4fa7b4dbe968a677843751
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.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-
|
|
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
|