ace-git-github 0.1.1 → 0.2.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: 582b5aa0bbe2430f79b22897711fdebfa1e6b1b9f95bc18bed86bf65c1e9827f
4
- data.tar.gz: a494f8d740771123e5d82ea5ab2b10872c38f0434f6e7311a892715025904402
3
+ metadata.gz: 998ca300a7c6610ff632dac2a6051fe84daa68a4e8daac13181e10fd86223798
4
+ data.tar.gz: 8cc1cf649582eed6600fc53bb0d70ac8bdc73226c02072400f8cf6c5de450d4a
5
5
  SHA512:
6
- metadata.gz: 554e3438466bb91e22fe03addcdaed22a3678aeab24c00a874a75c36b00fe6814ad06e6b7613075c38864145d4dd29466692ac739f88b1e58998f3feacd19d17
7
- data.tar.gz: 98b94104aac8d09ca7a319b2c3ef201fb4a44bac5b90e02facbfae5eb477bd74bdb33cfcb64b2cdc6c6d86dfad00fd5783b4567a159bf2952be22ebffe0f5ac0
6
+ metadata.gz: 5f8d10b98f72f43767031476e9a980658462834374ec26af019a7d7b753c5bb3d5e78257b2a6ee7261856069c72db234993541223b4f14c52ac84e83ba9a28f0
7
+ data.tar.gz: d1399752f36d349a36fa0bbb21406d62c820806ad78ff62eefe3ec0423b0bfc28f75964ac389f45e99b7ed599c148b8c638152ecf0bef8e51d77b596ea5a0ec8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-28
11
+
12
+ ### Added
13
+ - PR lifecycle mutations with atomic provider-side expected-head enforcement:
14
+ exact-match lookup (`find_open_pull_requests`), idempotent draft-create
15
+ reconciliation, expected-head merge via the gh match-head-commit flag,
16
+ head-verified update/ready.
17
+ - Cross-repository PR provenance: head repository URL resolved from gh
18
+ evidence and returned in normalized PR evidence.
19
+ - Classified lifecycle failures (conflicting matches, expected-head conflict,
20
+ unknown create outcome with reconciliation identity).
21
+
22
+ ### Changed
23
+ - `gh pr view`/`list` JSON field sets extended with head-repository fields.
24
+
25
+
26
+ ### Added
27
+ - PR lifecycle mutations with atomic provider-side expected-head enforcement:
28
+ exact-match lookup (`find_open_pull_requests`), idempotent draft-create
29
+ reconciliation, expected-head merge via the gh match-head-commit flag,
30
+ head-verified update/ready.
31
+ - Cross-repository PR provenance: head repository URL resolved from gh
32
+ evidence and returned in normalized PR evidence.
33
+ - Classified lifecycle failures (conflicting matches, expected-head conflict,
34
+ unknown create outcome with reconciliation identity).
35
+
36
+ ### Changed
37
+ - `gh pr view`/`list` JSON field sets extended with head-repository fields.
38
+
39
+
40
+ ## [0.1.2] - 2026-09-26
41
+
42
+ ### Added
43
+ - `IssueSync.available?` probe: true when the `gh` CLI is installed and authenticated, letting callers treat GitHub sync as best-effort.
44
+
10
45
  ## [0.1.1] - 2026-09-24
11
46
 
12
47
  ### Fixed
@@ -14,6 +14,12 @@ module Ace
14
14
  TRACKED_LABEL = "ace:tracked"
15
15
  TERMINAL_STATUSES = %w[done completed shipped closed cancelled skipped archived].freeze
16
16
 
17
+ # True when GitHub sync can run: the gh CLI is installed and authenticated.
18
+ # GitHub sync is best-effort; callers skip it silently when this is false.
19
+ def self.available?
20
+ CliExecutor.authenticated?
21
+ end
22
+
17
23
  def self.sync_task(task_id:, task_title:, task_status:, task_path:, issue_ids:, reason:, previous: nil,
18
24
  current_issue_ids: nil)
19
25
  current_ids = if current_issue_ids.nil?
@@ -53,13 +53,15 @@ module Ace
53
53
  # @param timeout [Integer] Timeout in seconds
54
54
  # @param runner [Proc, nil] injectable command runner for tests
55
55
  # @return [Hash] Result with :success, :diff, :identifier, :source
56
- def fetch_diff(identifier, timeout: Ace::Git.network_timeout, runner: nil)
56
+ def fetch_diff(identifier, timeout: Ace::Git.network_timeout, runner: nil, repo: nil)
57
57
  parsed = PrIdentifier.parse(identifier)
58
58
  raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
59
59
 
60
60
  validate_identifier_characters(parsed.gh_format)
61
61
 
62
- result = CliExecutor.execute("pr", ["diff", *parsed.cli_target_args], timeout: timeout, runner: runner)
62
+ args = ["diff", *parsed.cli_target_args]
63
+ args += ["--repo", repo] if repo
64
+ result = CliExecutor.execute("pr", args, timeout: timeout, runner: runner)
63
65
 
64
66
  if result[:success]
65
67
  {
@@ -182,9 +184,11 @@ module Ace
182
184
  # @param timeout [Integer] Timeout in seconds
183
185
  # @param runner [Proc, nil] injectable command runner for tests
184
186
  # @return [Hash] Result with :success, :prs array, or :error
185
- def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout, runner: nil)
187
+ def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout, runner: nil, repo: nil)
188
+ args = ["list", "--state", "all", "--limit", limit.to_s, "--json", LIST_FIELDS]
189
+ args += ["--repo", repo] if repo
186
190
  result = CliExecutor.execute(
187
- "pr", ["list", "--state", "all", "--limit", limit.to_s, "--json", LIST_FIELDS],
191
+ "pr", args,
188
192
  timeout: timeout, runner: runner
189
193
  )
190
194
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
4
5
 
5
6
  module Ace
6
7
  module Git
@@ -25,11 +26,14 @@ module Ace
25
26
  "skipping" => :skipped
26
27
  }.freeze
27
28
 
28
- PR_FIELDS = "number,state,isDraft,title,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt"
29
+ PR_FIELDS = "number,state,isDraft,title,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt,headRepositoryOwner,headRepository"
29
30
  LIST_FIELDS = PrFetcher::LIST_FIELDS
30
31
  REPO_FIELDS = "nameWithOwner,defaultBranchRef,url"
31
32
  ISSUE_FIELDS = "number,title,state,author,url"
32
33
 
34
+ # Fields needed to match pull requests by exact base/head identity.
35
+ LIFECYCLE_LIST_FIELDS = "number,title,state,isDraft,author,headRefName,baseRefName,url,headRefOid,headRepositoryOwner,headRepository"
36
+
33
37
  class << self
34
38
  # Human-facing provider type name used in failure messages.
35
39
  def display_name
@@ -66,6 +70,13 @@ module Ace
66
70
  normalize_pr(data, server.name)
67
71
  end
68
72
 
73
+ # @return [String] HOST/OWNER/REPO for the resolved server; appended
74
+ # to every data/mutation command so a `--server` selection can never
75
+ # be silently retargeted to the checkout's repository.
76
+ def repo_target
77
+ @repo_target ||= Ace::Git::Atoms::ServerUrl.normalize(server.url)
78
+ end
79
+
69
80
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
70
81
  def pull_request_for_branch(branch:)
71
82
  candidates = recent_pull_requests(limit: 30)
@@ -77,13 +88,13 @@ module Ace
77
88
 
78
89
  # @return [String] unified diff text for the pull request
79
90
  def pull_request_diff(number:)
80
- result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner)
91
+ result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner, repo: repo_target)
81
92
  result[:diff]
82
93
  end
83
94
 
84
95
  # @return [Array<ProviderPullRequest>] recent PRs, newest first
85
96
  def recent_pull_requests(limit:)
86
- result = PrFetcher.fetch_all_prs(limit: limit, timeout: timeout, runner: runner)
97
+ result = PrFetcher.fetch_all_prs(limit: limit, timeout: timeout, runner: runner, repo: repo_target)
87
98
  result[:prs]
88
99
  .map { |pr| normalize_pr(pr, server.name) }
89
100
  .sort_by { |pr| -pr.number.to_i }
@@ -112,7 +123,8 @@ module Ace
112
123
 
113
124
  # @return [ProviderRepository] normalized repository evidence
114
125
  def repository
115
- output = gh_json(["repo", "view", "--json", REPO_FIELDS])
126
+ # `gh repo view` rejects `--repo`; the repository is positional.
127
+ output = gh_json(["repo", "view", repo_target, "--json", REPO_FIELDS], bind_repo: false)
116
128
  Ace::Git::ProviderRepository.new(
117
129
  server_name: server.name,
118
130
  full_name: output["nameWithOwner"],
@@ -121,13 +133,156 @@ module Ace
121
133
  )
122
134
  end
123
135
 
136
+ # ---- PR lifecycle mutations ----
137
+
138
+ # @return [Array<ProviderPullRequest>] open PRs matching the exact
139
+ # base/head identity (head repository URL/ref plus base URL/ref)
140
+ def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
141
+ require_base_on_server!(base_repository_url)
142
+ data = gh_json(["pr", "list", "--state", "open", "--limit", "200", "--json", LIFECYCLE_LIST_FIELDS])
143
+ data.filter_map { |entry| normalize_pr(entry, server.name) }
144
+ .select do |pr|
145
+ pr.head_ref == head_ref &&
146
+ pr.base_ref == base_ref &&
147
+ Ace::Git::Atoms::ServerUrl.match?(pr.head_repository_url, head_repository_url)
148
+ end
149
+ end
150
+
151
+ # Create a PR, reconciling to an exact open match first; proves the
152
+ # created/reconciled head against `expected_head`.
153
+ #
154
+ # @return [ProviderMutationReceipt] idempotency :created/:existing
155
+ def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
156
+ matches = find_open_pull_requests(
157
+ head_repository_url: head_repository_url, head_ref: head_ref,
158
+ base_repository_url: server.url, base_ref: base_ref
159
+ )
160
+ if matches.size > 1
161
+ raise Ace::Git::ProviderConflictingMatchesError,
162
+ "Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
163
+ "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
164
+ end
165
+ if matches.size == 1
166
+ existing = matches.first
167
+ return receipt(:create, verify_head!(existing, expected_head), :existing)
168
+ end
169
+
170
+ args = ["pr", "create", "--head", gh_head_arg(head_ref, head_repository_url), "--base", base_ref, "--title", title]
171
+ args += ["--body", body] if body
172
+ args << "--draft" if draft
173
+
174
+ result = send_mutation(args, ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
175
+ number = result[:stdout].to_s.strip[%r{/pull/(\d+)\z}, 1]
176
+ unless number
177
+ raise Ace::Git::ProviderMalformedOutputError,
178
+ "gh pr create did not return a pull request URL: #{result[:stdout].to_s.strip}"
179
+ end
180
+
181
+ created = verify_head!(pull_request(number: number), expected_head)
182
+ receipt(:create, created, :created)
183
+ end
184
+
185
+ # @return [ProviderMutationReceipt] operation :update
186
+ def update_pull_request(number:, expected_head:, title: nil, body: nil)
187
+ verify_head!(pull_request(number: number), expected_head)
188
+ args = ["pr", "edit", number.to_s]
189
+ args += ["--title", title] if title
190
+ args += ["--body", body] if body
191
+ send_mutation(args, ambiguous: false)
192
+ receipt(:update, pull_request(number: number), nil)
193
+ end
194
+
195
+ # @return [ProviderMutationReceipt] operation :ready
196
+ def ready_pull_request(number:, expected_head:)
197
+ verify_head!(pull_request(number: number), expected_head)
198
+ send_mutation(["pr", "ready", number.to_s], ambiguous: false)
199
+ receipt(:ready, pull_request(number: number), nil)
200
+ end
201
+
202
+ # Merge with atomic provider-side expected-head enforcement via
203
+ # `gh pr merge --match-head-commit`.
204
+ #
205
+ # @return [ProviderMutationReceipt] operation :merge
206
+ def merge_pull_request(number:, expected_head:, method:)
207
+ unless %i[squash merge rebase].include?(method)
208
+ raise ArgumentError, "Invalid merge method #{method.inspect}"
209
+ end
210
+
211
+ send_mutation(
212
+ ["pr", "merge", number.to_s, "--#{method}", "--match-head-commit", expected_head],
213
+ ambiguous: false
214
+ )
215
+ receipt(:merge, pull_request(number: number), nil)
216
+ end
217
+
124
218
  private
125
219
 
126
220
  STATE_ORDER = {open: 0, merged: 1, closed: 2}.freeze
127
221
 
222
+ # Send one mutating `gh` command. When `ambiguous` is true (create),
223
+ # any transport-level failure is reported as an unknown outcome: the
224
+ # request may have mutated the forge, so callers must reconcile by
225
+ # exact identity instead of retrying blindly.
226
+ def send_mutation(args, ambiguous:, identity: nil)
227
+ result = CliExecutor.execute(args.first, (args[1..] || []) + ["--repo", repo_target], timeout: timeout, runner: runner)
228
+ return result if result[:success]
229
+
230
+ classify_failure(result[:stderr], context: args.join(" "))
231
+ result
232
+ rescue Ace::Git::ProviderUnreachableError => e
233
+ raise e unless ambiguous
234
+
235
+ raise Ace::Git::ProviderUnknownOutcomeError,
236
+ "PR create outcome unknown after transport failure (#{e.message}); " \
237
+ "reconcile by exact identity before repeating: #{identity}"
238
+ end
239
+
240
+ # Exact base/head identity text for reconciliation contexts.
241
+ def identity_text(head_repository_url, head_ref, base_ref)
242
+ "#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}"
243
+ end
244
+
245
+ # Refuse when the live head differs from the caller's expected head
246
+ # (shared guard from Providers::Base).
247
+ def verify_head!(pr, expected_head)
248
+ verify_expected_head!(pr, expected_head)
249
+ end
250
+
251
+ # `gh` head selector: "user:branch" for fork sources, plain ref for
252
+ # the base repository. The owner comes from the declared URL only.
253
+ def gh_head_arg(head_ref, head_repository_url)
254
+ return head_ref if head_repository_url.nil? ||
255
+ Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
256
+
257
+ path = Ace::Git::Atoms::ServerUrl.normalize(head_repository_url).split("/", 2)[1]
258
+ owner = path.to_s.split("/").first
259
+ raise ArgumentError, "Cannot derive fork owner from #{head_repository_url}" if owner.nil? || owner.empty?
260
+
261
+ "#{owner}:#{head_ref}"
262
+ end
263
+
264
+ def require_base_on_server!(base_repository_url)
265
+ return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
266
+
267
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
268
+ "GitHub provider operates on the resolved server repository (#{server.url}); " \
269
+ "refusing base repository #{base_repository_url}"
270
+ end
271
+
272
+ def receipt(operation, pull_request, idempotency)
273
+ Ace::Git::ProviderMutationReceipt.new(
274
+ server_name: server.name, operation: operation,
275
+ pull_request: pull_request, idempotency: idempotency
276
+ )
277
+ end
278
+
128
279
  # Run a `gh` command expecting JSON output; classify all failures.
129
- def gh_json(args)
130
- result = CliExecutor.execute(args.first, args[1..] || [], timeout: timeout, runner: runner)
280
+ # Every command is bound to the resolved server repository via
281
+ # `--repo`, except commands that take the repository positionally.
282
+ def gh_json(args, bind_repo: true)
283
+ argv = args[1..] || []
284
+ argv += ["--repo", repo_target] if bind_repo
285
+ result = CliExecutor.execute(args.first, argv, timeout: timeout, runner: runner)
131
286
  unless result[:success]
132
287
  classify_failure(result[:stderr], context: args.join(" "))
133
288
  end
@@ -152,10 +307,31 @@ module Ace
152
307
  author: normalize_author(data["author"]),
153
308
  url: data["url"],
154
309
  draft: data["isDraft"],
155
- merged_at: merged_at_of(data)
310
+ merged_at: merged_at_of(data),
311
+ head_repository_url: head_repository_url_of(data),
312
+ base_repository_url: server.url,
313
+ merge_commit_sha: data.dig("mergeCommit", "oid")
156
314
  )
157
315
  end
158
316
 
317
+ # Source repository URL for the PR head: the fork's owner/name against
318
+ # the server host for cross-repository PRs, the configured repository
319
+ # otherwise. Never inferred beyond what `gh` reported.
320
+ def head_repository_url_of(data)
321
+ owner = data.dig("headRepositoryOwner", "login")
322
+ name = data.dig("headRepository", "name")
323
+ return server.url if owner.nil? || name.nil?
324
+
325
+ "#{server_host_root}/#{owner}/#{name}"
326
+ end
327
+
328
+ def server_host_root
329
+ @server_host_root ||= begin
330
+ uri = URI.parse(server.url.to_s)
331
+ "#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
332
+ end
333
+ end
334
+
159
335
  def merged_at_of(data)
160
336
  data["mergedAt"]
161
337
  end
@@ -184,6 +360,9 @@ module Ace
184
360
  raise Ace::Git::ProviderObjectNotFoundError, "Object not found: #{context}: #{message}"
185
361
  elsif message.match?(PrFetcher::AUTH_ERROR_PATTERN)
186
362
  raise Ace::Git::ProviderAuthenticationError, "Not authenticated with GitHub: #{message}"
363
+ elsif message.match?(/match[- ]head[- ]commit|head commit/i)
364
+ raise Ace::Git::ProviderExpectedHeadConflictError,
365
+ "GitHub refused the merge: pull request head does not match --match-head-commit (#{context}): #{message}"
187
366
  else
188
367
  raise Ace::Git::ProviderUnreachableError, "GitHub request failed (#{context}): #{message}"
189
368
  end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Github
6
- VERSION = "0.1.1"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git-github
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-24 00:00:00.000000000 Z
10
+ date: 2026-09-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-git