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.
- checksums.yaml +4 -4
- data/.ace-defaults/git/config.yml +21 -1
- data/CHANGELOG.md +67 -0
- data/docs/getting-started.md +4 -4
- data/docs/usage.md +53 -38
- data/lib/ace/git/atoms/command_executor.rb +1 -1
- data/lib/ace/git/atoms/pr_identifier.rb +66 -0
- data/lib/ace/git/atoms/pr_reference.rb +76 -0
- data/lib/ace/git/atoms/server_url.rb +56 -0
- data/lib/ace/git/atoms/status_formatter.rb +22 -27
- data/lib/ace/git/cli/commands/pr.rb +159 -84
- data/lib/ace/git/cli/commands/status.rb +16 -6
- data/lib/ace/git/cli.rb +8 -3
- data/lib/ace/git/errors.rb +82 -0
- data/lib/ace/git/models/diff_config.rb +2 -2
- data/lib/ace/git/models/repo_status.rb +1 -1
- data/lib/ace/git/molecules/branch_reader.rb +13 -7
- data/lib/ace/git/molecules/config_loader.rb +1 -1
- data/lib/ace/git/organisms/pull_request_lifecycle.rb +172 -0
- data/lib/ace/git/organisms/repo_status_loader.rb +72 -136
- data/lib/ace/git/providers/base.rb +221 -0
- data/lib/ace/git/providers/evidence.rb +96 -0
- data/lib/ace/git/providers.rb +121 -0
- data/lib/ace/git/resolved_server.rb +20 -0
- data/lib/ace/git/server_registry.rb +202 -0
- data/lib/ace/git/version.rb +1 -1
- data/lib/ace/git.rb +15 -14
- metadata +19 -13
- data/lib/ace/git/atoms/pr_identifier_parser.rb +0 -88
- data/lib/ace/git/molecules/gh_cli_executor.rb +0 -77
- data/lib/ace/git/molecules/github_issue_sync.rb +0 -248
- data/lib/ace/git/molecules/pr_metadata_fetcher.rb +0 -288
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
104
|
-
|
|
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
|