ace-git-worktree 0.23.0 → 0.24.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.
@@ -2,117 +2,173 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
- require "ace/git/github"
6
- require "ace/git/github/pr_fetcher"
5
+ require "ace/git"
7
6
 
8
7
  module Ace
9
8
  module Git
10
9
  module Worktree
11
10
  module Molecules
12
- # Resolves PR evidence for candidate branches to prove safe squash-merge cleanup.
11
+ # Resolves PR evidence for candidate branches through the registered
12
+ # forge provider to prove safe merge cleanup.
13
+ #
14
+ # Only confirmed remote merge evidence (:merged) can prove removal;
15
+ # object states (:no_pr, :open, :closed_unmerged) and transport
16
+ # failures (:offline, :authentication_error, :malformed) always
17
+ # retain the candidate. Local ancestry proof is handled by the
18
+ # reporter before this resolver is consulted.
13
19
  class CleanupPrResolver
14
- def initialize(target:, target_sha:, offline: false)
20
+ # @param target [String] base ref the PR must have targeted
21
+ # @param target_sha [String] commit SHA of the target
22
+ # @param offline [Boolean] skip provider evidence entirely
23
+ # @param server_name [String, nil] explicit server selection
24
+ # @param use_default [Boolean] resolve the configured default server
25
+ # @param remote_name [String, nil] remote used for fallback resolution
26
+ # @param timeout [Integer, nil] provider timeout
27
+ # @param runner [Proc, nil] injectable provider runner (tests)
28
+ def initialize(target:, target_sha:, offline: false, server_name: nil, use_default: false,
29
+ remote_name: nil, timeout: nil, runner: nil)
15
30
  @target = target
16
31
  @target_sha = target_sha
17
32
  @offline = offline
18
-
19
- # Cache gh availability to avoid repeated checks
20
- @gh_available = Ace::Git::Github::PrFetcher.installed? &&
21
- Ace::Git::Github::PrFetcher.authenticated?
33
+ @selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
34
+ @timeout = timeout
35
+ @runner = runner
22
36
  end
23
37
 
24
- # Classify a candidate branch with GitHub PR evidence.
38
+ # @return [Ace::Git::ResolvedServer, nil] the server resolved for
39
+ # provider evidence; nil when unresolvable (local proof only)
40
+ def resolved_server
41
+ resolve_provider
42
+ @resolved_server
43
+ end
44
+
45
+ # Classify a candidate branch with provider PR evidence.
25
46
  #
26
- # @param branch [String] The branch name (e.g. "feature")
27
- # @param candidate_sha [String] The commit SHA of the candidate
28
- # @return [Hash] Proof classification result
47
+ # @param branch [String] the branch name (e.g. "feature")
48
+ # @param candidate_sha [String] the commit SHA of the candidate
49
+ # @return [Hash] proof classification; :status is one of
50
+ # Ace::Git::CLEANUP_PROOF_STATUSES
29
51
  def classify(branch, candidate_sha)
30
- return offline_result unless @gh_available && !@offline
31
-
32
- prs = fetch_merged_prs_for_branch(branch)
33
- return unproven_result if prs.nil? || prs.empty?
34
-
35
- # Find a PR that targets the configured base and is actually reachable
36
- valid_pr = prs.find do |pr|
37
- pr["baseRefName"] == @target &&
38
- pr["state"] == "MERGED" &&
39
- pr["mergeCommit"] &&
40
- pr["mergeCommit"]["oid"] &&
41
- ancestor?(pr["mergeCommit"]["oid"], @target_sha)
42
- end
52
+ return unproven_result(:no_pr, "provider_disabled") if @offline
43
53
 
44
- return ambiguous_result(prs) if prs.length > 1 && !valid_pr
45
- return unproven_result unless valid_pr
54
+ evidence = fetch_merged_evidence(branch)
55
+ return failure_result(evidence) if evidence.is_a?(Hash) # transport failure marker
46
56
 
47
- merge_commit_sha = valid_pr["mergeCommit"]["oid"]
48
- pr_head_sha = valid_pr["headRefOid"]
57
+ return unproven_result(:no_pr) unless evidence
58
+ return unproven_result(:open) if evidence.state == :open
59
+ return unproven_result(:closed_unmerged) if evidence.state == :closed
60
+ return unproven_result(:no_pr, "state_unavailable") unless evidence.state == :merged
61
+ return unproven_result(:merged, "merge_commit_unavailable") unless evidence.merge_commit_sha
62
+ return unproven_result(:merged, "merge_commit_unreachable") unless ancestor?(evidence.merge_commit_sha, @target_sha)
63
+
64
+ merge_commit_sha = evidence.merge_commit_sha
65
+ pr_head_sha = evidence.head_sha
49
66
 
50
67
  if candidate_sha == pr_head_sha
51
- {
52
- proof: "exact_merged_pr_head",
53
- pr: valid_pr["number"],
54
- candidate_head: candidate_sha,
55
- merged_head: pr_head_sha,
56
- merge_commit: merge_commit_sha,
57
- target_reachable: true,
58
- path_type_mode_match: true,
59
- provider_status: "available",
60
- action: "remove"
61
- }
68
+ proof_result(evidence, "exact_merged_pr_head", candidate_sha, pr_head_sha, merge_commit_sha)
62
69
  elsif patch_equivalent?(candidate_sha, merge_commit_sha)
63
- {
64
- proof: "stable_patch_equivalence",
65
- pr: valid_pr["number"],
66
- candidate_head: candidate_sha,
67
- merged_head: pr_head_sha,
68
- merge_commit: merge_commit_sha,
69
- target_reachable: true,
70
- path_type_mode_match: true,
71
- provider_status: "available",
72
- action: "remove"
73
- }
70
+ proof_result(evidence, "stable_patch_equivalence", candidate_sha, pr_head_sha, merge_commit_sha)
74
71
  else
75
72
  {
76
- proof: "none",
77
- pr: valid_pr["number"],
73
+ status: :merged,
74
+ proof: nil,
75
+ pr: evidence.number,
76
+ pr_url: evidence.url,
78
77
  candidate_head: candidate_sha,
79
78
  merged_head: pr_head_sha,
80
79
  merge_commit: merge_commit_sha,
81
80
  target_reachable: true,
82
- path_type_mode_match: false,
83
81
  provider_status: "available",
84
82
  action: "retain",
85
83
  retention_reason: "patch_mismatch"
86
84
  }
87
85
  end
88
- rescue Ace::Git::ProviderCliMissingError, Ace::Git::ProviderAuthenticationError, Ace::Git::TimeoutError
89
- # Provider failure degradation
90
- offline_result("unavailable")
91
86
  end
92
87
 
93
88
  private
94
89
 
95
- def fetch_merged_prs_for_branch(branch)
96
- # Use `gh pr list --head <branch> --state merged` to find the exact PR
97
- cmd = [
98
- "gh", "pr", "list", "--state", "merged", "--head", branch,
99
- "--json", "number,state,headRefOid,mergeCommit,baseRefName"
100
- ]
101
-
102
- # Using Open3 directly to avoid circular dependencies if we don't want to augment ace-git too heavily for just this
103
- # but we can rely on LC_ALL=C for safety.
104
- env = {"LC_ALL" => "C"}
105
- out, err, status = Open3.capture3(env, *cmd)
106
-
107
- return nil unless status.success?
108
-
109
- JSON.parse(out)
110
- rescue JSON::ParserError, Errno::ENOENT
111
- nil
90
+ # Fetch the merged PR evidence for a branch, or a failure marker
91
+ # hash {:failure_status => ...} on classified transport failures.
92
+ def fetch_merged_evidence(branch)
93
+ provider = resolve_provider
94
+ return {failure_status: :offline} unless provider
95
+
96
+ provider.pull_request_for_branch(branch: branch)
97
+ rescue Ace::Git::ProviderAuthenticationError
98
+ {failure_status: :authentication_error}
99
+ rescue Ace::Git::ProviderMalformedOutputError
100
+ {failure_status: :malformed}
101
+ rescue Ace::Git::Error
102
+ {failure_status: :offline}
103
+ end
104
+
105
+ # Resolve the provider lazily, once per resolver. Unresolvable
106
+ # server selection (no config, ambiguous remote, missing provider)
107
+ # means no provider evidence is available.
108
+ def resolve_provider
109
+ return @provider if defined?(@provider)
110
+
111
+ @provider = begin
112
+ server = Ace::Git::ServerRegistry.resolve_for(**@selection)
113
+ @resolved_server = server
114
+ Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
115
+ rescue Ace::Git::Error
116
+ @resolved_server = nil
117
+ nil
118
+ end
119
+ end
120
+
121
+ def proof_result(evidence, proof, candidate_sha, pr_head_sha, merge_commit_sha)
122
+ {
123
+ status: :merged,
124
+ proof: proof,
125
+ pr: evidence.number,
126
+ pr_url: evidence.url,
127
+ candidate_head: candidate_sha,
128
+ merged_head: pr_head_sha,
129
+ merge_commit: merge_commit_sha,
130
+ target_reachable: true,
131
+ provider_status: "available",
132
+ action: "remove",
133
+ retention_reason: nil
134
+ }
135
+ end
136
+
137
+ def unproven_result(status, reason = "ancestry_unproven")
138
+ {
139
+ status: status,
140
+ proof: nil,
141
+ pr: nil,
142
+ pr_url: nil,
143
+ candidate_head: nil,
144
+ merged_head: nil,
145
+ merge_commit: nil,
146
+ target_reachable: "unknown",
147
+ provider_status: "available",
148
+ action: "retain",
149
+ retention_reason: reason
150
+ }
151
+ end
152
+
153
+ def failure_result(marker)
154
+ status = marker[:failure_status]
155
+ {
156
+ status: status,
157
+ proof: nil,
158
+ pr: nil,
159
+ pr_url: nil,
160
+ candidate_head: nil,
161
+ merged_head: nil,
162
+ merge_commit: nil,
163
+ target_reachable: "unknown",
164
+ provider_status: status.to_s,
165
+ action: "retain",
166
+ retention_reason: "provider_evidence_unavailable"
167
+ }
112
168
  end
113
169
 
114
170
  def ancestor?(candidate, target)
115
- out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
171
+ _out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
116
172
  status.success?
117
173
  end
118
174
 
@@ -122,7 +178,7 @@ module Ace
122
178
  base, status = Open3.capture2("git", "merge-base", candidate_sha, @target_sha)
123
179
  return false unless status.success?
124
180
  base = base.strip
125
-
181
+
126
182
  # Candidate inventory: path, type, mode changes
127
183
  cand_inv = tree_diff_inventory(base, candidate_sha)
128
184
  return false unless cand_inv
@@ -136,9 +192,8 @@ module Ace
136
192
  end
137
193
 
138
194
  def tree_diff_inventory(tree_a, tree_b)
139
- # git diff-tree -r --name-status or raw mode
140
- # using raw mode to get exact mode and type
141
- # :100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M file
195
+ # git diff-tree -r raw mode carries exact mode and type information
196
+ # :100644 100644 e69de29... 000000... M file
142
197
  out, status = Open3.capture2("git", "diff-tree", "-r", "--no-commit-id", tree_a, tree_b)
143
198
  return nil unless status.success?
144
199
 
@@ -146,66 +201,19 @@ module Ace
146
201
  out.each_line do |line|
147
202
  # Format: :src_mode dst_mode src_sha dst_sha status\tpath
148
203
  parts = line.strip.split("\t", 2)
204
+ next if parts.length < 2
205
+
149
206
  meta = parts[0].split(" ")
150
207
  path = parts[1]
151
-
152
- dst_mode = meta[1]
153
- dst_sha = meta[3]
154
- status_char = meta[4]
155
-
208
+
156
209
  inventory[path] = {
157
- mode: dst_mode,
158
- sha: dst_sha,
159
- status: status_char[0] # handle R100 etc by taking first char
210
+ mode: meta[1],
211
+ sha: meta[3],
212
+ status: meta[4][0] # handle R100 etc by taking first char
160
213
  }
161
214
  end
162
-
163
- inventory
164
- end
165
-
166
- def offline_result(status = "offline")
167
- {
168
- proof: "none",
169
- pr: nil,
170
- candidate_head: nil,
171
- merged_head: nil,
172
- merge_commit: nil,
173
- target_reachable: "unknown",
174
- path_type_mode_match: "unknown",
175
- provider_status: status,
176
- action: "retain",
177
- retention_reason: "ancestry_unproven"
178
- }
179
- end
180
215
 
181
- def unproven_result
182
- {
183
- proof: "none",
184
- pr: nil,
185
- candidate_head: nil,
186
- merged_head: nil,
187
- merge_commit: nil,
188
- target_reachable: "unknown",
189
- path_type_mode_match: "unknown",
190
- provider_status: "available",
191
- action: "retain",
192
- retention_reason: "ancestry_unproven"
193
- }
194
- end
195
-
196
- def ambiguous_result(prs)
197
- {
198
- proof: "none",
199
- pr: nil,
200
- candidate_head: nil,
201
- merged_head: nil,
202
- merge_commit: nil,
203
- target_reachable: "unknown",
204
- path_type_mode_match: "unknown",
205
- provider_status: "ambiguous",
206
- action: "retain",
207
- retention_reason: "ambiguous_prs"
208
- }
216
+ inventory
209
217
  end
210
218
  end
211
219
  end
@@ -19,12 +19,22 @@ module Ace
19
19
  SCHEMA_VERSION = "1.0"
20
20
 
21
21
  # @param target [String] Target ref (e.g. "main", "origin/main")
22
- # @param remote [String] Remote name (e.g. "origin")
22
+ # @param remote [String] Remote name (e.g. "origin"); identifies the
23
+ # local Git remote, never the forge
23
24
  # @param offline [Boolean] Skip remote refresh
24
- def initialize(target:, remote: "origin", offline: false)
25
+ # @param server_name [String, nil] explicit forge server selection
26
+ # @param use_default [Boolean] resolve the configured default server
27
+ # @param remote_name [String, nil] remote used for fallback resolution
28
+ # @param timeout [Integer, nil] provider timeout
29
+ # @param runner [Proc, nil] injectable provider runner (tests)
30
+ def initialize(target:, remote: "origin", offline: false, server_name: nil, use_default: false,
31
+ remote_name: nil, timeout: nil, runner: nil)
25
32
  @target = target
26
33
  @remote = remote
27
34
  @offline = offline
35
+ @selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
36
+ @timeout = timeout
37
+ @runner = runner
28
38
  end
29
39
 
30
40
  # Build complete cleanup report.
@@ -46,7 +56,11 @@ module Ace
46
56
  local_refs = inventory_local_refs
47
57
  remote_refs = inventory_remote_refs
48
58
 
49
- pr_resolver = CleanupPrResolver.new(target: @target, target_sha: target_sha, offline: @offline)
59
+ pr_resolver = CleanupPrResolver.new(
60
+ target: @target, target_sha: target_sha, offline: @offline,
61
+ server_name: @selection[:server_name], use_default: @selection[:use_default],
62
+ remote_name: @selection[:remote_name], timeout: @timeout, runner: @runner
63
+ )
50
64
 
51
65
  # Classify each item
52
66
  classify_worktrees(worktrees, target_sha, pr_resolver)
@@ -56,8 +70,12 @@ module Ace
56
70
  # Build ordered action plan
57
71
  actions = build_action_plan(worktrees, local_refs, remote_refs)
58
72
 
59
- # Compute canonical digest
60
- plan_digest = compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha)
73
+ # The resolved server identity binds provider proof to the plan;
74
+ # changed server configuration invalidates approval.
75
+ server = server_identity_hash(pr_resolver)
76
+
77
+ # Compute canonical digest (server/PR/head evidence included)
78
+ plan_digest = compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha, server)
61
79
 
62
80
  {
63
81
  success: true,
@@ -65,6 +83,7 @@ module Ace
65
83
  repository: common_dir,
66
84
  target: {ref: @target, sha: target_sha},
67
85
  remote: {name: @remote, sha: remote_sha},
86
+ server: server,
68
87
  refresh: @offline ? {status: "offline"} : (refresh_result || {status: "skipped"}),
69
88
  worktrees: worktrees,
70
89
  local_refs: local_refs,
@@ -286,7 +305,7 @@ module Ace
286
305
  wt[:ancestry] = "ancestor"
287
306
  wt[:action] = "remove"
288
307
  else
289
- # Try GitHub PR evidence
308
+ # Try provider PR evidence
290
309
  proof_result = pr_resolver.classify(wt[:branch], wt[:sha])
291
310
  if proof_result[:action] == "remove"
292
311
  wt[:ancestry] = proof_result[:proof]
@@ -311,7 +330,7 @@ module Ace
311
330
  ref[:ancestry] = "ancestor"
312
331
  ref[:action] = "remove"
313
332
  else
314
- # Try GitHub PR evidence
333
+ # Try provider PR evidence
315
334
  # Only local refs and remote-tracking refs have meaningful branch names for PR lookup
316
335
  branch_name = kind == "remote" ? ref[:short_name] : ref[:name]
317
336
  proof_result = pr_resolver.classify(branch_name, ref[:sha])
@@ -378,17 +397,36 @@ module Ace
378
397
 
379
398
  # --- Canonical digest ---
380
399
 
381
- def compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha)
400
+ # Resolved server identity for the report/digest; nil when no
401
+ # provider was resolvable (local ancestry proof only).
402
+ def server_identity_hash(pr_resolver)
403
+ server = pr_resolver.resolved_server
404
+ return nil unless server
405
+
406
+ {name: server.name, provider: server.provider.to_s, url: server.url}
407
+ end
408
+
409
+ def compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha, server)
382
410
  canonical = {
411
+ schema_version: SCHEMA_VERSION,
383
412
  target_sha: target_sha,
384
- worktrees: worktrees.sort_by { |wt| wt[:path] }.map { |wt| [wt[:path], wt[:sha], wt[:action]] },
385
- local_refs: local_refs.sort_by { |r| r[:name] }.map { |r| [r[:name], r[:sha], r[:action]] },
386
- remote_refs: remote_refs.sort_by { |r| r[:name] }.map { |r| [r[:name], r[:sha], r[:action]] },
413
+ server: server,
414
+ worktrees: worktrees.sort_by { |wt| wt[:path] }.map { |wt| proof_tuple(wt[:path], wt) },
415
+ local_refs: local_refs.sort_by { |r| r[:name] }.map { |r| proof_tuple(r[:name], r) },
416
+ remote_refs: remote_refs.sort_by { |r| r[:name] }.map { |r| proof_tuple(r[:name], r) },
387
417
  actions: actions.map { |a| [a[:type], a[:target], a[:sha]] }
388
418
  }
389
419
 
390
420
  Digest::SHA256.hexdigest(JSON.generate(canonical))
391
421
  end
422
+
423
+ # One canonical entry per item: identity, decision, and the full
424
+ # provider proof that justified it.
425
+ def proof_tuple(identity, item)
426
+ proof = item[:pr_proof]
427
+ [identity, item[:sha], item[:action], item[:ancestry],
428
+ proof ? [proof[:status], proof[:pr], proof[:merged_head], proof[:merge_commit]] : nil]
429
+ end
392
430
  end
393
431
  end
394
432
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Fetches the exact source repository/ref declared by PR evidence and
10
+ # proves the fetched commit before any worktree is created.
11
+ #
12
+ # Canonical-repository and fork source URLs are equally valid: the
13
+ # fetch goes through a matching local remote when one exists, and by
14
+ # explicit URL otherwise (no remote ref is created for URL fetches).
15
+ class PullRequestCheckoutPreparer
16
+ DEFAULT_TIMEOUT = 60
17
+
18
+ # @param timeout [Integer] git command timeout in seconds
19
+ def initialize(timeout: DEFAULT_TIMEOUT)
20
+ @timeout = timeout
21
+ end
22
+
23
+ # Fetch the declared source ref and verify its SHA against the
24
+ # evidence head.
25
+ #
26
+ # @param evidence [Ace::Git::ProviderPullRequest] normalized PR evidence
27
+ # @return [Hash] {success:, local_ref:, sha:, remote_tracking:, error:}
28
+ # `local_ref` is the exact verified commit SHA (immutable), safe
29
+ # for `git worktree add` even if later fetches move FETCH_HEAD.
30
+ def prepare(evidence)
31
+ head_url = evidence.head_repository_url
32
+ head_ref = evidence.head_ref
33
+ return error_result("PR evidence is missing the source repository URL") if head_url.nil? || head_url.empty?
34
+ return error_result("PR evidence is missing the source branch") if head_ref.nil? || head_ref.empty?
35
+ return error_result("PR evidence is missing the exact head SHA") unless evidence.head_sha
36
+
37
+ local_remote = matching_local_remote(head_url)
38
+ fetch_source = local_remote || head_url
39
+ fetch_desc = "#{head_url}@#{head_ref}"
40
+
41
+ fetch_result = Ace::Git::Worktree::Atoms::GitCommand.execute(
42
+ "fetch", fetch_source, head_ref, timeout: @timeout
43
+ )
44
+ return error_result("Failed to fetch #{fetch_desc}: #{fetch_result[:error]}") unless fetch_result[:success]
45
+
46
+ sha = resolve_fetch_head
47
+ return error_result("Cannot resolve fetched commit for #{fetch_desc}") unless sha
48
+
49
+ if sha != evidence.head_sha
50
+ return error_result(
51
+ "Fetched head #{sha} does not match PR evidence head #{evidence.head_sha} " \
52
+ "(#{head_url}@#{head_ref}); the PR head moved since the evidence was captured"
53
+ )
54
+ end
55
+
56
+ {
57
+ success: true,
58
+ # The immutable verified SHA, not FETCH_HEAD: a later fetch must
59
+ # never be able to change what the worktree is created from.
60
+ local_ref: sha,
61
+ sha: sha,
62
+ remote_tracking: local_remote ? "#{local_remote}/#{head_ref}" : nil,
63
+ error: nil
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ # Find a configured local git remote whose URL matches the evidence
70
+ # source repository; nil means fetch by explicit URL instead.
71
+ def matching_local_remote(head_url)
72
+ result = Ace::Git::Worktree::Atoms::GitCommand.execute("remote", "-v", timeout: 5)
73
+ return nil unless result[:success]
74
+
75
+ remotes = {}
76
+ result[:output].to_s.each_line do |line|
77
+ name, url, = line.strip.split(/\s+/)
78
+ remotes[name] = url if url
79
+ end
80
+
81
+ remotes.find do |_name, url|
82
+ Ace::Git::Atoms::ServerUrl.match?(url, head_url)
83
+ end&.first
84
+ end
85
+
86
+ def resolve_fetch_head
87
+ result = Ace::Git::Worktree::Atoms::GitCommand.execute(
88
+ "rev-parse", "--verify", "FETCH_HEAD^{commit}", timeout: 10
89
+ )
90
+ result[:success] ? result[:output].to_s.strip : nil
91
+ end
92
+
93
+ def error_result(message)
94
+ {success: false, local_ref: nil, sha: nil, remote_tracking: nil, error: message}
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Creates draft pull requests for task worktrees through the
10
+ # forge-neutral provider contract.
11
+ #
12
+ # The caller must supply the exact pushed head SHA (`expected_head`);
13
+ # creation or reuse is proven against that SHA by the provider, never
14
+ # inferred from branch names. Server selection resolves lazily inside
15
+ # the lifecycle organism, so local-only task runs never touch a forge.
16
+ class PullRequestCreator
17
+ # @param server_name [String, nil] explicit server selection
18
+ # @param use_default [Boolean] resolve the configured default server
19
+ # @param remote_name [String, nil] remote used for fallback resolution
20
+ # @param timeout [Integer, nil] provider timeout
21
+ # @param runner [Proc, nil] injectable provider runner (tests)
22
+ def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
23
+ @selection = {
24
+ server_name: server_name, use_default: use_default, remote_name: remote_name
25
+ }
26
+ @timeout = timeout
27
+ @runner = runner
28
+ end
29
+
30
+ # Create (or reconcile to) a draft PR for a pushed branch.
31
+ #
32
+ # @param branch [String] pushed head branch
33
+ # @param base [String] base branch/ref
34
+ # @param title [String] PR title
35
+ # @param expected_head [String] exact SHA the pushed branch resolves to
36
+ # @param head_repository_url [String, nil] source repository URL
37
+ # (defaults to the selected server's repository)
38
+ # @param body [String, nil] PR body text
39
+ # @return [Hash] {success:, pr_number:, pr_url:, existing:, head_sha:, error:}
40
+ def create_draft(branch:, base:, title:, expected_head:, head_repository_url: nil, body: nil)
41
+ lifecycle = Ace::Git::Organisms::PullRequestLifecycle.new(**@selection.merge(timeout: @timeout, runner: @runner))
42
+ receipt = lifecycle.create(
43
+ head_ref: branch,
44
+ head_repository_url: head_repository_url,
45
+ base_ref: base,
46
+ expected_head: expected_head,
47
+ title: title,
48
+ body: body,
49
+ draft: true
50
+ )
51
+ pr = receipt.pull_request
52
+ {
53
+ success: true,
54
+ pr_number: pr.number,
55
+ pr_url: pr.url,
56
+ existing: receipt.idempotency == :existing,
57
+ head_sha: pr.head_sha,
58
+ error: nil
59
+ }
60
+ rescue Ace::Git::Error => e
61
+ {success: false, pr_number: nil, pr_url: nil, existing: false, head_sha: nil, error: e.message}
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Resolves normalized pull request evidence for worktree consumers.
10
+ #
11
+ # The server is selected once (explicit `--server`, `--default-server`,
12
+ # or remote resolution) and the PR is fetched through the registered
13
+ # provider contract. Local-only callers never construct this molecule,
14
+ # so no forge resolution happens without a PR-aware request.
15
+ class PullRequestEvidenceResolver
16
+ # @param server_name [String, nil] explicit server selection
17
+ # @param use_default [Boolean] resolve the configured default server
18
+ # @param remote_name [String, nil] git remote for fallback resolution
19
+ # @param timeout [Integer, nil] provider timeout
20
+ # @param runner [Proc, nil] injectable provider runner (tests)
21
+ def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
22
+ @selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
23
+ @timeout = timeout
24
+ @runner = runner
25
+ end
26
+
27
+ # Fetch exact PR evidence with source/base provenance.
28
+ #
29
+ # @param number [Integer, String] pull request number
30
+ # @return [Hash] {server: ResolvedServer, evidence: ProviderPullRequest}
31
+ def resolve(number)
32
+ server = Ace::Git::ServerRegistry.resolve_for(**@selection)
33
+ provider = Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
34
+ {server: server, evidence: provider.pull_request(number: number)}
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end