ace-git-worktree 0.22.1 → 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,116 +2,173 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
- require "ace/git/molecules/pr_metadata_fetcher"
5
+ require "ace/git"
6
6
 
7
7
  module Ace
8
8
  module Git
9
9
  module Worktree
10
10
  module Molecules
11
- # 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.
12
19
  class CleanupPrResolver
13
- 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)
14
30
  @target = target
15
31
  @target_sha = target_sha
16
32
  @offline = offline
17
-
18
- # Cache gh availability to avoid repeated checks
19
- @gh_available = Ace::Git::Molecules::PrMetadataFetcher.gh_installed? &&
20
- Ace::Git::Molecules::PrMetadataFetcher.gh_authenticated?
33
+ @selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
34
+ @timeout = timeout
35
+ @runner = runner
21
36
  end
22
37
 
23
- # 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.
24
46
  #
25
- # @param branch [String] The branch name (e.g. "feature")
26
- # @param candidate_sha [String] The commit SHA of the candidate
27
- # @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
28
51
  def classify(branch, candidate_sha)
29
- return offline_result unless @gh_available && !@offline
30
-
31
- prs = fetch_merged_prs_for_branch(branch)
32
- return unproven_result if prs.nil? || prs.empty?
33
-
34
- # Find a PR that targets the configured base and is actually reachable
35
- valid_pr = prs.find do |pr|
36
- pr["baseRefName"] == @target &&
37
- pr["state"] == "MERGED" &&
38
- pr["mergeCommit"] &&
39
- pr["mergeCommit"]["oid"] &&
40
- ancestor?(pr["mergeCommit"]["oid"], @target_sha)
41
- end
52
+ return unproven_result(:no_pr, "provider_disabled") if @offline
42
53
 
43
- return ambiguous_result(prs) if prs.length > 1 && !valid_pr
44
- 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
45
56
 
46
- merge_commit_sha = valid_pr["mergeCommit"]["oid"]
47
- 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
48
66
 
49
67
  if candidate_sha == pr_head_sha
50
- {
51
- proof: "exact_merged_pr_head",
52
- pr: valid_pr["number"],
53
- candidate_head: candidate_sha,
54
- merged_head: pr_head_sha,
55
- merge_commit: merge_commit_sha,
56
- target_reachable: true,
57
- path_type_mode_match: true,
58
- provider_status: "available",
59
- action: "remove"
60
- }
68
+ proof_result(evidence, "exact_merged_pr_head", candidate_sha, pr_head_sha, merge_commit_sha)
61
69
  elsif patch_equivalent?(candidate_sha, merge_commit_sha)
62
- {
63
- proof: "stable_patch_equivalence",
64
- pr: valid_pr["number"],
65
- candidate_head: candidate_sha,
66
- merged_head: pr_head_sha,
67
- merge_commit: merge_commit_sha,
68
- target_reachable: true,
69
- path_type_mode_match: true,
70
- provider_status: "available",
71
- action: "remove"
72
- }
70
+ proof_result(evidence, "stable_patch_equivalence", candidate_sha, pr_head_sha, merge_commit_sha)
73
71
  else
74
72
  {
75
- proof: "none",
76
- pr: valid_pr["number"],
73
+ status: :merged,
74
+ proof: nil,
75
+ pr: evidence.number,
76
+ pr_url: evidence.url,
77
77
  candidate_head: candidate_sha,
78
78
  merged_head: pr_head_sha,
79
79
  merge_commit: merge_commit_sha,
80
80
  target_reachable: true,
81
- path_type_mode_match: false,
82
81
  provider_status: "available",
83
82
  action: "retain",
84
83
  retention_reason: "patch_mismatch"
85
84
  }
86
85
  end
87
- rescue Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError, Ace::Git::TimeoutError
88
- # Provider failure degradation
89
- offline_result("unavailable")
90
86
  end
91
87
 
92
88
  private
93
89
 
94
- def fetch_merged_prs_for_branch(branch)
95
- # Use `gh pr list --head <branch> --state merged` to find the exact PR
96
- cmd = [
97
- "gh", "pr", "list", "--state", "merged", "--head", branch,
98
- "--json", "number,state,headRefOid,mergeCommit,baseRefName"
99
- ]
100
-
101
- # Using Open3 directly to avoid circular dependencies if we don't want to augment ace-git too heavily for just this
102
- # but we can rely on LC_ALL=C for safety.
103
- env = {"LC_ALL" => "C"}
104
- out, err, status = Open3.capture3(env, *cmd)
105
-
106
- return nil unless status.success?
107
-
108
- JSON.parse(out)
109
- rescue JSON::ParserError, Errno::ENOENT
110
- 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
+ }
111
168
  end
112
169
 
113
170
  def ancestor?(candidate, target)
114
- out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
171
+ _out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
115
172
  status.success?
116
173
  end
117
174
 
@@ -121,7 +178,7 @@ module Ace
121
178
  base, status = Open3.capture2("git", "merge-base", candidate_sha, @target_sha)
122
179
  return false unless status.success?
123
180
  base = base.strip
124
-
181
+
125
182
  # Candidate inventory: path, type, mode changes
126
183
  cand_inv = tree_diff_inventory(base, candidate_sha)
127
184
  return false unless cand_inv
@@ -135,9 +192,8 @@ module Ace
135
192
  end
136
193
 
137
194
  def tree_diff_inventory(tree_a, tree_b)
138
- # git diff-tree -r --name-status or raw mode
139
- # using raw mode to get exact mode and type
140
- # :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
141
197
  out, status = Open3.capture2("git", "diff-tree", "-r", "--no-commit-id", tree_a, tree_b)
142
198
  return nil unless status.success?
143
199
 
@@ -145,66 +201,19 @@ module Ace
145
201
  out.each_line do |line|
146
202
  # Format: :src_mode dst_mode src_sha dst_sha status\tpath
147
203
  parts = line.strip.split("\t", 2)
204
+ next if parts.length < 2
205
+
148
206
  meta = parts[0].split(" ")
149
207
  path = parts[1]
150
-
151
- dst_mode = meta[1]
152
- dst_sha = meta[3]
153
- status_char = meta[4]
154
-
208
+
155
209
  inventory[path] = {
156
- mode: dst_mode,
157
- sha: dst_sha,
158
- 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
159
213
  }
160
214
  end
161
-
162
- inventory
163
- end
164
-
165
- def offline_result(status = "offline")
166
- {
167
- proof: "none",
168
- pr: nil,
169
- candidate_head: nil,
170
- merged_head: nil,
171
- merge_commit: nil,
172
- target_reachable: "unknown",
173
- path_type_mode_match: "unknown",
174
- provider_status: status,
175
- action: "retain",
176
- retention_reason: "ancestry_unproven"
177
- }
178
- end
179
215
 
180
- def unproven_result
181
- {
182
- proof: "none",
183
- pr: nil,
184
- candidate_head: nil,
185
- merged_head: nil,
186
- merge_commit: nil,
187
- target_reachable: "unknown",
188
- path_type_mode_match: "unknown",
189
- provider_status: "available",
190
- action: "retain",
191
- retention_reason: "ancestry_unproven"
192
- }
193
- end
194
-
195
- def ambiguous_result(prs)
196
- {
197
- proof: "none",
198
- pr: nil,
199
- candidate_head: nil,
200
- merged_head: nil,
201
- merge_commit: nil,
202
- target_reachable: "unknown",
203
- path_type_mode_match: "unknown",
204
- provider_status: "ambiguous",
205
- action: "retain",
206
- retention_reason: "ambiguous_prs"
207
- }
216
+ inventory
208
217
  end
209
218
  end
210
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