ace-git 0.23.1 → 0.25.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.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/pr_reference"
4
+
5
+ module Ace
6
+ module Git
7
+ module Organisms
8
+ # Forge-neutral pull request lifecycle orchestration.
9
+ #
10
+ # Owns the shared policy for every `ace-git pr` operation: server
11
+ # selection (exactly once per operation), identifier-to-server identity
12
+ # validation, body-file loading, and normalized provider invocation.
13
+ # Provider-specific command construction and parsing stay in the
14
+ # provider packages; this class only sees normalized evidence.
15
+ #
16
+ # Selection rules (see the qk1 parent contract):
17
+ # - `--server NAME` and `--default-server` are mutually exclusive.
18
+ # - With neither, the configured repository remote is resolved through
19
+ # ServerRegistry; a failed remote resolution never falls back to a
20
+ # default.
21
+ # - A PR URL or `owner/repo#number` is accepted only when it matches a
22
+ # configured server exactly; an explicit selection that contradicts
23
+ # the identifier fails before any mutation.
24
+ class PullRequestLifecycle
25
+ MERGE_METHODS = %i[squash merge rebase].freeze
26
+
27
+ # @param server_name [String, Symbol, nil] explicit server selection
28
+ # @param use_default [Boolean] resolve the configured default server
29
+ # @param remote_name [String, nil] git remote used when nothing selected
30
+ # @param timeout [Integer, nil] provider operation timeout
31
+ # @param runner [Proc, nil] injectable provider command runner (tests)
32
+ def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
33
+ @selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
34
+ @timeout = timeout
35
+ @runner = runner
36
+ end
37
+
38
+ # Fetch normalized evidence for one pull request.
39
+ #
40
+ # @param identifier [String, Integer] number, `owner/repo#n`, or URL
41
+ # @return [ProviderPullRequest]
42
+ def show(identifier)
43
+ reference = parse_identifier(identifier)
44
+ server = resolve_server_for(reference)
45
+ provider_for(server).pull_request(number: reference.number)
46
+ end
47
+
48
+ # Create (or reconcile to) a pull request for an exact base/head
49
+ # identity. There is no identifier: the selected server's repository
50
+ # is the base, `head_repository_url` names the source (defaults to
51
+ # the base repository; no fork inference).
52
+ #
53
+ # @return [ProviderMutationReceipt] idempotency :created/:existing
54
+ def create(head_ref:, base_ref:, expected_head:, title:, head_repository_url: nil,
55
+ body: nil, body_file: nil, draft: true)
56
+ body_text = body_file ? load_body(body_file) : body
57
+ server = resolve_selected_server
58
+ provider_for(server).create_pull_request(
59
+ head_ref: head_ref,
60
+ head_repository_url: head_repository_url || server.url,
61
+ base_ref: base_ref,
62
+ expected_head: expected_head,
63
+ title: title,
64
+ body: body_text,
65
+ draft: draft
66
+ )
67
+ end
68
+
69
+ # Update title/body after exact-head verification.
70
+ #
71
+ # @return [ProviderMutationReceipt]
72
+ def update(identifier, expected_head:, title: nil, body: nil, body_file: nil)
73
+ body_text = body_file ? load_body(body_file) : body
74
+ reference = parse_identifier(identifier)
75
+ server = resolve_server_for(reference)
76
+ provider_for(server).update_pull_request(
77
+ number: reference.number, expected_head: expected_head,
78
+ title: title, body: body_text
79
+ )
80
+ end
81
+
82
+ # Mark a draft pull request ready after exact-head verification.
83
+ #
84
+ # @return [ProviderMutationReceipt]
85
+ def ready(identifier, expected_head:)
86
+ reference = parse_identifier(identifier)
87
+ server = resolve_server_for(reference)
88
+ provider_for(server).ready_pull_request(number: reference.number, expected_head: expected_head)
89
+ end
90
+
91
+ # Merge with provider-side expected-head enforcement; no implicit
92
+ # merge method exists.
93
+ #
94
+ # @param method [Symbol, String] :squash, :merge, or :rebase
95
+ # @return [ProviderMutationReceipt]
96
+ def merge(identifier, expected_head:, method:)
97
+ normalized = method.to_s.strip.downcase.to_sym
98
+ unless MERGE_METHODS.include?(normalized)
99
+ raise ArgumentError, "Invalid merge method '#{method}'; use one of: #{MERGE_METHODS.join(", ")}"
100
+ end
101
+
102
+ reference = parse_identifier(identifier)
103
+ server = resolve_server_for(reference)
104
+ provider_for(server).merge_pull_request(
105
+ number: reference.number, expected_head: expected_head, method: normalized
106
+ )
107
+ end
108
+
109
+ private
110
+
111
+ def parse_identifier(identifier)
112
+ reference = Atoms::PrReference.parse(identifier)
113
+ raise ArgumentError, "Invalid PR identifier: #{identifier}" unless reference
114
+
115
+ reference
116
+ end
117
+
118
+ def resolve_selected_server
119
+ ServerRegistry.resolve_for(**@selection)
120
+ end
121
+
122
+ # Resolve exactly one server for a parsed reference, validating any
123
+ # explicit selection against the identifier's repository identity.
124
+ def resolve_server_for(reference)
125
+ return resolve_selected_server unless reference.repository_explicit?
126
+
127
+ candidates = if reference.repository_url
128
+ ServerRegistry.matching_servers(reference.repository_url)
129
+ else
130
+ ServerRegistry.servers_for_owner_repo(reference.owner_repo)
131
+ end
132
+
133
+ if @selection[:server_name] || @selection[:use_default]
134
+ selected = ServerRegistry.resolve_for(**@selection)
135
+ unless candidates.any? { |candidate| candidate.name == selected.name }
136
+ identity = reference.repository_url || reference.owner_repo
137
+ raise ProviderIdentityMismatchError,
138
+ "PR identifier (#{identity}) does not match the selected server '#{selected.name}'"
139
+ end
140
+
141
+ selected
142
+ elsif candidates.size == 1
143
+ candidates.first
144
+ elsif candidates.empty?
145
+ identity = reference.repository_url || reference.owner_repo
146
+ raise AmbiguousRemoteError,
147
+ "PR repository '#{identity}' matches no configured server (configured: " \
148
+ "#{ServerRegistry.servers.map(&:name).join(", ")})"
149
+ else
150
+ identity = reference.repository_url || reference.owner_repo
151
+ raise AmbiguousRemoteError,
152
+ "PR repository '#{identity}' matches multiple configured servers " \
153
+ "(#{candidates.map(&:name).join(", ")}); select one explicitly"
154
+ end
155
+ end
156
+
157
+ def provider_for(server)
158
+ Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
159
+ end
160
+
161
+ def load_body(path)
162
+ raise ArgumentError, "--body-file is required" unless path
163
+ raise ArgumentError, "Body file not found: #{path}" unless File.file?(path)
164
+
165
+ File.read(path)
166
+ rescue SystemCallError => e
167
+ raise ArgumentError, "Cannot read body file #{path}: #{e.message}"
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -4,12 +4,17 @@ module Ace
4
4
  module Git
5
5
  module Organisms
6
6
  # Orchestrates loading complete repository status
7
- # Combines branch info, task pattern detection, and PR metadata
7
+ # Combines branch info, task pattern detection, and PR evidence
8
+ #
9
+ # PR enrichment is provider-optional: it resolves the default forge
10
+ # server and its registered provider via the core contract. With no
11
+ # server configured, no provider registered, or a classified provider
12
+ # failure, status stays purely local and the PR sections are skipped.
8
13
  class RepoStatusLoader
9
14
  class << self
10
15
  # Load complete repository status
11
16
  # @param options [Hash] Options for status loading
12
- # @option options [Boolean] :include_pr Whether to fetch PR metadata (default: true)
17
+ # @option options [Boolean] :include_pr Whether to fetch PR evidence (default: true)
13
18
  # @option options [Boolean] :include_pr_activity Whether to fetch PR activity (default: true)
14
19
  # @option options [Boolean] :include_commits Whether to fetch recent commits (default: true)
15
20
  # @option options [Integer] :commits_limit Number of recent commits to fetch (default: 3)
@@ -53,28 +58,18 @@ module Ace
53
58
  recent_commits = fetch_recent_commits(limit: commits_limit)
54
59
  end
55
60
 
56
- # Fetch PR data (metadata and activity) - parallelized for performance
61
+ # Fetch PR evidence (metadata and activity) via the provider contract
57
62
  pr_metadata = nil
58
63
  pr_activity = nil
59
64
  if !branch_info[:detached]
60
- if include_pr && include_pr_activity
61
- # Both requested: use parallel fetch for ~50% speedup
62
- pr_data = fetch_pr_data_parallel(
63
- current_branch: branch_info[:name],
64
- timeout: timeout
65
- )
66
- pr_metadata = pr_data[:pr_metadata]
67
- pr_activity = pr_data[:pr_activity]
68
- elsif include_pr
69
- # Only PR metadata requested
70
- pr_metadata = fetch_pr_metadata(timeout: timeout)
71
- elsif include_pr_activity
72
- # Only activity requested
73
- pr_activity = fetch_pr_activity(
74
- current_branch: branch_info[:name],
75
- timeout: timeout
76
- )
77
- end
65
+ pr_sections = fetch_pr_sections(
66
+ current_branch: branch_info[:name],
67
+ include_metadata: include_pr,
68
+ include_activity: include_pr_activity,
69
+ timeout: timeout
70
+ )
71
+ pr_metadata = pr_sections[:pr_metadata]
72
+ pr_activity = pr_sections[:pr_activity]
78
73
  end
79
74
 
80
75
  # Build and return status
@@ -100,13 +95,8 @@ module Ace
100
95
  # Get basic status (skip PR activity since we're fetching a specific PR)
101
96
  status = load(include_pr: false, include_pr_activity: false)
102
97
 
103
- # Fetch specific PR metadata
104
- begin
105
- result = Molecules::PrMetadataFetcher.fetch_metadata(pr_identifier, timeout: timeout)
106
- pr_metadata = result[:success] ? result[:metadata] : nil
107
- rescue Ace::Git::Error
108
- pr_metadata = nil
109
- end
98
+ # Fetch specific PR evidence
99
+ pr_metadata = resolve_pr_metadata(pr_identifier, timeout: timeout)
110
100
 
111
101
  # Return status with PR data
112
102
  Models::RepoStatus.from_data(
@@ -131,6 +121,60 @@ module Ace
131
121
 
132
122
  private
133
123
 
124
+ # Resolve a specific PR via the provider contract
125
+ # @return [ProviderPullRequest, nil] PR evidence or nil
126
+ def resolve_pr_metadata(pr_identifier, timeout:)
127
+ provider = active_provider(timeout: timeout)
128
+ return nil unless provider
129
+
130
+ provider.pull_request(number: pr_identifier)
131
+ rescue Ace::Git::Error
132
+ # Classified provider failure: keep status local, skip PR evidence
133
+ nil
134
+ end
135
+
136
+ # Fetch PR evidence sections via the provider contract
137
+ # @return [Hash] {:pr_metadata => ProviderPullRequest, :pr_activity => Hash}
138
+ def fetch_pr_sections(current_branch:, include_metadata:, include_activity:, timeout:)
139
+ provider = active_provider(timeout: timeout)
140
+ return {pr_metadata: nil, pr_activity: nil} unless provider
141
+
142
+ metadata = include_metadata ? provider.pull_request_for_branch(branch: current_branch) : nil
143
+ activity = include_activity ? fetch_activity(provider, current_branch: current_branch) : nil
144
+
145
+ {pr_metadata: metadata, pr_activity: activity}
146
+ rescue Ace::Git::Error
147
+ # Classified provider failure: keep status local, skip PR evidence
148
+ {pr_metadata: nil, pr_activity: nil}
149
+ end
150
+
151
+ # Build merged/open activity lists from recent PR evidence
152
+ def fetch_activity(provider, current_branch:)
153
+ prs = provider.recent_pull_requests(limit: 30)
154
+ merged = prs
155
+ .select { |pr| pr.state == :merged }
156
+ .first(Ace::Git.merged_prs_limit)
157
+ open_prs = prs
158
+ .select { |pr| pr.state == :open && pr.head_ref != current_branch }
159
+ .first(Ace::Git.open_prs_limit)
160
+
161
+ return nil if merged.empty? && open_prs.empty?
162
+
163
+ {merged: merged, open: open_prs}
164
+ end
165
+
166
+ # Resolve the default server's registered provider, or nil.
167
+ # Resolution failures are classified; enrichment is optional, so any
168
+ # of them simply skips the PR sections.
169
+ def active_provider(timeout:)
170
+ server = ServerRegistry.resolve_default
171
+ return nil unless server
172
+
173
+ Providers.for(server, timeout: timeout)
174
+ rescue Ace::Git::Error
175
+ nil
176
+ end
177
+
134
178
  # Fetch git status in short branch format
135
179
  # @return [String, nil] Git status output or nil
136
180
  def fetch_git_status
@@ -149,114 +193,6 @@ module Ace
149
193
  rescue
150
194
  nil
151
195
  end
152
-
153
- # Fetch PR metadata for current branch
154
- # @param timeout [Integer] Timeout in seconds
155
- # @return [Hash, nil] PR metadata or nil
156
- def fetch_pr_metadata(timeout:)
157
- # First try to find PR for current branch
158
- pr_number = Molecules::PrMetadataFetcher.find_pr_for_branch(timeout: timeout)
159
- return nil unless pr_number
160
-
161
- # Then fetch full metadata
162
- result = Molecules::PrMetadataFetcher.fetch_metadata(pr_number, timeout: timeout)
163
- result[:success] ? result[:metadata] : nil
164
- rescue Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
165
- # gh not available, skip PR metadata
166
- nil
167
- rescue Ace::Git::PrNotFoundError
168
- # No PR for this branch
169
- nil
170
- rescue Ace::Git::TimeoutError
171
- # Timeout, skip PR metadata
172
- nil
173
- rescue
174
- # Any other error, skip PR metadata
175
- nil
176
- end
177
-
178
- # Fetch PR activity (recently merged and open PRs)
179
- # @param current_branch [String] Current branch name to exclude from open PRs
180
- # @param timeout [Integer] Timeout in seconds
181
- # @return [Hash, nil] PR activity with :merged and :open arrays (symbol keys), or nil
182
- # Each PR in the arrays has string keys from JSON parsing: "number", "title", etc.
183
- def fetch_pr_activity(current_branch:, timeout:)
184
- merged_result = Molecules::PrMetadataFetcher.fetch_recently_merged(
185
- limit: Ace::Git.merged_prs_limit,
186
- timeout: timeout
187
- )
188
- open_result = Molecules::PrMetadataFetcher.fetch_open_prs(
189
- exclude_branch: current_branch,
190
- timeout: timeout
191
- )
192
-
193
- # Return nil if both failed
194
- return nil unless merged_result[:success] || open_result[:success]
195
-
196
- # Use symbol keys for outer hash, string keys for PR data (from JSON)
197
- # This is documented behavior - consumers should access via pr_activity[:merged]
198
- # and individual PRs via pr["number"], pr["title"], etc.
199
- {
200
- merged: merged_result[:success] ? merged_result[:prs] : [],
201
- open: open_result[:success] ? open_result[:prs] : []
202
- }
203
- rescue
204
- # Any error, skip PR activity
205
- nil
206
- end
207
-
208
- # Fetch PR metadata and activity in a single API call for optimal performance
209
- # Uses gh pr list --state all to get all PRs, then filters locally
210
- # @param current_branch [String] Current branch name to match/exclude
211
- # @param timeout [Integer] Timeout in seconds
212
- # @return [Hash] Result with :pr_metadata and :pr_activity keys
213
- def fetch_pr_data_parallel(current_branch:, timeout:)
214
- # Single API call gets all recent PRs
215
- result = Molecules::PrMetadataFetcher.fetch_all_prs(
216
- limit: 15, # Enough for current + merged + open
217
- timeout: timeout
218
- )
219
-
220
- return {pr_metadata: nil, pr_activity: nil} unless result[:success]
221
-
222
- prs = result[:prs]
223
-
224
- # Find current PR (matching branch, prefer OPEN > MERGED > CLOSED)
225
- branch_prs = prs.select { |pr| pr["headRefName"] == current_branch }
226
- current_pr = branch_prs.min_by { |pr|
227
- case pr["state"]
228
- when "OPEN" then 0
229
- when "MERGED" then 1
230
- when "CLOSED" then 2
231
- else 3
232
- end
233
- }
234
-
235
- # Get merged PRs (sorted by mergedAt descending, limited)
236
- merged_prs = prs
237
- .select { |pr| pr["state"] == "MERGED" }
238
- .sort_by { |pr| pr["mergedAt"] || "" }
239
- .reverse
240
- .take(Ace::Git.merged_prs_limit)
241
-
242
- # Get open PRs (exclude current branch, limited)
243
- open_prs = prs
244
- .select { |pr| pr["state"] == "OPEN" && pr["headRefName"] != current_branch }
245
- .take(Ace::Git.open_prs_limit)
246
-
247
- # Build activity hash (nil if no activity to show)
248
- pr_activity = nil
249
- if merged_prs.any? || open_prs.any?
250
- pr_activity = {merged: merged_prs, open: open_prs}
251
- end
252
-
253
- {
254
- pr_metadata: current_pr,
255
- pr_activity: pr_activity
256
- }
257
- rescue
258
- {pr_metadata: nil, pr_activity: nil}
259
- end
260
196
  end
261
197
  end
262
198
  end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Providers
6
+ # Abstract provider contract implemented by provider packages.
7
+ #
8
+ # The core defines the interface and the normalized evidence types;
9
+ # implementations own all provider CLI invocation, output parsing, and
10
+ # authentication verification. The core itself never executes a provider
11
+ # CLI and never parses provider output.
12
+ #
13
+ # Subclasses receive the resolved server identity and may accept two
14
+ # optional constructor keywords (see {Providers.for}):
15
+ # - timeout: operation timeout in seconds
16
+ # - runner: injectable command runner for tests; a callable receiving
17
+ # keyword arguments (args:, timeout:, env:) and returning a Hash with
18
+ # :stdout, :stderr, :exit_code
19
+ class Base
20
+ attr_reader :server, :timeout
21
+
22
+ # @param server [ResolvedServer] resolved server identity
23
+ # @param timeout [Integer, nil] operation timeout (default: config)
24
+ # @param runner [Proc, nil] injectable command runner for tests
25
+ def initialize(server:, timeout: nil, runner: nil)
26
+ @server = server
27
+ @timeout = timeout || Ace::Git.network_timeout
28
+ @runner = runner
29
+ end
30
+
31
+ # @return [Boolean] true when the provider CLI binary is installed
32
+ def available?
33
+ raise NotImplementedError, "Providers must implement #{self.class}#available?"
34
+ end
35
+
36
+ # @raise [ProviderCliMissingError] when the provider CLI is missing
37
+ def check_available!
38
+ raise NotImplementedError, "Providers must implement #{self.class}#check_available!"
39
+ end
40
+
41
+ # @return [Boolean] true when the provider CLI is authenticated
42
+ def authenticated?
43
+ raise NotImplementedError, "Providers must implement #{self.class}#authenticated?"
44
+ end
45
+
46
+ # @raise [ProviderAuthenticationError] when unauthenticated
47
+ def check_authenticated!
48
+ raise NotImplementedError, "Providers must implement #{self.class}#check_authenticated!"
49
+ end
50
+
51
+ # Fetch one pull request.
52
+ #
53
+ # @param number [Integer, String] pull request number
54
+ # @return [ProviderPullRequest] normalized pull request evidence
55
+ def pull_request(number:)
56
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request"
57
+ end
58
+
59
+ # Find the pull request associated with a branch.
60
+ #
61
+ # @param branch [String] branch name
62
+ # @return [ProviderPullRequest, nil] evidence, or nil when no pull
63
+ # request is associated with the branch
64
+ def pull_request_for_branch(branch:)
65
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request_for_branch"
66
+ end
67
+
68
+ # Fetch the full diff of one pull request.
69
+ #
70
+ # @param number [Integer, String] pull request number
71
+ # @return [String] unified diff text
72
+ def pull_request_diff(number:)
73
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request_diff"
74
+ end
75
+
76
+ # List recent pull requests across states (newest first).
77
+ #
78
+ # @param limit [Integer] maximum number of pull requests
79
+ # @return [Array<ProviderPullRequest>] normalized pull requests
80
+ def recent_pull_requests(limit:)
81
+ raise NotImplementedError, "Providers must implement #{self.class}#recent_pull_requests"
82
+ end
83
+
84
+ # Fetch one issue.
85
+ #
86
+ # @param number [Integer, String] issue number
87
+ # @return [ProviderIssue] normalized issue evidence
88
+ def issue(number:)
89
+ raise NotImplementedError, "Providers must implement #{self.class}#issue"
90
+ end
91
+
92
+ # Fetch check/CI status evidence for a reference.
93
+ #
94
+ # @param ref [String] commit sha or branch name
95
+ # @return [Array<ProviderCheck>] normalized check evidence
96
+ def checks(ref:)
97
+ raise NotImplementedError, "Providers must implement #{self.class}#checks"
98
+ end
99
+
100
+ # Fetch repository metadata for the resolved server.
101
+ #
102
+ # @return [ProviderRepository] normalized repository evidence
103
+ def repository
104
+ raise NotImplementedError, "Providers must implement #{self.class}#repository"
105
+ end
106
+
107
+ # ---- PR lifecycle mutations ----
108
+ #
109
+ # Contract requirements (enforced by every provider implementation):
110
+ # - Exact identity: lookup and create operate on the exact
111
+ # head repository URL/ref and base repository URL/ref; no fork
112
+ # inference and no branch-name-only matching.
113
+ # - Expected head: every head-dependent operation receives the exact
114
+ # head SHA the caller saw and must fail with
115
+ # ProviderExpectedHeadConflictError when the live head differs. For
116
+ # merge the enforcement must be atomic provider-side; a provider CLI
117
+ # without such support raises ProviderUnsupportedCapabilityError
118
+ # instead of emulating a check-then-merge race.
119
+ # - Idempotent create: one exact open base/head match returns the
120
+ # existing PR (`:existing` receipt); zero matches create; more than
121
+ # one match raises ProviderConflictingMatchesError.
122
+ # - Unknown outcomes: a transport failure after a request that may
123
+ # have mutated state raises ProviderUnknownOutcomeError carrying the
124
+ # exact identity; implementations never retry automatically.
125
+ # - Receipts: mutations return a ProviderMutationReceipt whose
126
+ # evidence carries source/base repository provenance and exact head.
127
+
128
+ # Find open pull requests matching an exact base/head identity.
129
+ #
130
+ # @param head_repository_url [String] source repository URL
131
+ # @param head_ref [String] source branch/ref
132
+ # @param base_repository_url [String] base repository URL
133
+ # @param base_ref [String] base branch/ref
134
+ # @return [Array<ProviderPullRequest>] matching open pull requests
135
+ def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
136
+ raise NotImplementedError, "Providers must implement #{self.class}#find_open_pull_requests"
137
+ end
138
+
139
+ # Create a pull request, reconciling against an exact open match.
140
+ #
141
+ # @param head_ref [String] source branch/ref
142
+ # @param head_repository_url [String] source repository URL
143
+ # @param base_ref [String] base branch/ref
144
+ # @param expected_head [String] exact SHA the pushed source ref must
145
+ # resolve to; the created/reconciled PR head is proven against it
146
+ # @param title [String] pull request title
147
+ # @param body [String, nil] pull request body text
148
+ # @param draft [Boolean] request a draft pull request when the
149
+ # provider supports draft state; the returned evidence always
150
+ # reports the provider's actual draft state
151
+ # @return [ProviderMutationReceipt] with idempotency :created/:existing
152
+ # @raise [ProviderConflictingMatchesError] on multiple exact matches
153
+ # @raise [ProviderExpectedHeadConflictError] when the head differs
154
+ # @raise [ProviderUnknownOutcomeError] on unknown post-send outcome
155
+ def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
156
+ raise NotImplementedError, "Providers must implement #{self.class}#create_pull_request"
157
+ end
158
+
159
+ # Update title/body of a pull request after head verification.
160
+ #
161
+ # @param number [Integer, String] pull request number
162
+ # @param expected_head [String] exact SHA the PR head must still be
163
+ # @param title [String, nil] new title
164
+ # @param body [String, nil] new body text
165
+ # @return [ProviderMutationReceipt]
166
+ # @raise [ProviderExpectedHeadConflictError] when the head differs
167
+ def update_pull_request(number:, expected_head:, title: nil, body: nil)
168
+ raise NotImplementedError, "Providers must implement #{self.class}#update_pull_request"
169
+ end
170
+
171
+ # Mark a draft pull request ready for review after head verification.
172
+ #
173
+ # @param number [Integer, String] pull request number
174
+ # @param expected_head [String] exact SHA the PR head must still be
175
+ # @return [ProviderMutationReceipt]
176
+ # @raise [ProviderExpectedHeadConflictError] when the head differs
177
+ # @raise [ProviderUnsupportedCapabilityError] when the provider cannot
178
+ # change draft state
179
+ def ready_pull_request(number:, expected_head:)
180
+ raise NotImplementedError, "Providers must implement #{self.class}#ready_pull_request"
181
+ end
182
+
183
+ # Merge a pull request with provider-side expected-head enforcement.
184
+ #
185
+ # @param number [Integer, String] pull request number
186
+ # @param expected_head [String] exact SHA enforced atomically by the
187
+ # provider before merging
188
+ # @param method [Symbol, String] :squash, :merge, or :rebase; there
189
+ # is no implicit default
190
+ # @return [ProviderMutationReceipt]
191
+ # @raise [ProviderExpectedHeadConflictError] when the provider refuses
192
+ # the merge because the head changed
193
+ # @raise [ProviderUnsupportedCapabilityError] when the provider CLI
194
+ # cannot enforce the head precondition atomically
195
+ def merge_pull_request(number:, expected_head:, method:)
196
+ raise NotImplementedError, "Providers must implement #{self.class}#merge_pull_request"
197
+ end
198
+
199
+ private
200
+
201
+ # Command runner: injected fake or nil (implementations use their own
202
+ # executor when nil).
203
+ attr_reader :runner
204
+
205
+ # Shared lifecycle guard: refuse when the live head differs from the
206
+ # caller's expected head. Providers must use this so the classified
207
+ # failure shape stays identical across forges.
208
+ def verify_expected_head!(pull_request, expected_head)
209
+ unless pull_request.head_sha.is_a?(String) && !pull_request.head_sha.empty?
210
+ raise ProviderMalformedOutputError,
211
+ "Provider evidence for PR ##{pull_request.number} is missing the exact head SHA"
212
+ end
213
+ return pull_request if pull_request.head_sha == expected_head
214
+
215
+ raise ProviderExpectedHeadConflictError,
216
+ "PR ##{pull_request.number} head changed: expected #{expected_head}, found #{pull_request.head_sha}"
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end