ace-git 0.24.0 → 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 +5 -0
- data/CHANGELOG.md +50 -0
- data/docs/usage.md +47 -1
- data/lib/ace/git/atoms/pr_reference.rb +76 -0
- data/lib/ace/git/cli/commands/pr.rb +194 -0
- data/lib/ace/git/cli.rb +10 -2
- data/lib/ace/git/errors.rb +26 -0
- data/lib/ace/git/organisms/pull_request_lifecycle.rb +172 -0
- data/lib/ace/git/providers/base.rb +106 -0
- data/lib/ace/git/providers/evidence.rb +71 -1
- data/lib/ace/git/providers.rb +31 -2
- data/lib/ace/git/server_registry.rb +46 -0
- data/lib/ace/git/version.rb +1 -1
- data/lib/ace/git.rb +3 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '089e377ec56099fb872544bfc754f6f95f00ecaf157715ecad93e726e9c8960f'
|
|
4
|
+
data.tar.gz: 35bb8b556655c0ef1804fb2fe26039dd182115e558127254c49e17da9ce3f1ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c156b7e8b123f15f9d305d27933d68de72d640e16a803a9d7b5ce2cce12c1c7cf537bb7e6ae31e7c8ca044999869160ccf835281fdcc51e3bee76589cd43363f
|
|
7
|
+
data.tar.gz: 4eaeac69d71391c491718c68e68e1599a0a3e1991eae4a25d3e4e2a922959ef13e940c7d863eae01478a78ae9f81940a016fcac46da27ee7bbdf427f64527a03
|
|
@@ -27,6 +27,11 @@ git:
|
|
|
27
27
|
# provider: github
|
|
28
28
|
# url: https://github.example.com/owner/repo
|
|
29
29
|
|
|
30
|
+
# Provider packages required on first use (library names under ace/git/*).
|
|
31
|
+
# Both shipped provider gems are loaded by default; remove entries for
|
|
32
|
+
# providers you do not use.
|
|
33
|
+
providers: [github, forgejo]
|
|
34
|
+
|
|
30
35
|
# Status command settings
|
|
31
36
|
status:
|
|
32
37
|
commits_limit: 3 # Number of recent commits to show
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.25.0] - 2026-09-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Forge-neutral `ace-git pr` lifecycle CLI: `show`, `create`, `update`, `ready`, `merge`
|
|
14
|
+
with `--server` / `--default-server` selection and `--format json`.
|
|
15
|
+
- Provider contract for PR lifecycle mutations (exact base/head identity,
|
|
16
|
+
idempotent create reconciliation, expected-head enforcement, unknown-outcome
|
|
17
|
+
classification) on `Ace::Git::Providers::Base`.
|
|
18
|
+
- Normalized evidence types: PR provenance fields (`head_repository_url`,
|
|
19
|
+
`base_repository_url`, `merge_commit_sha`), `ProviderPullRequestIdentity`,
|
|
20
|
+
`ProviderMutationReceipt`, `ProviderCleanupProof` with the cleanup proof
|
|
21
|
+
status vocabulary.
|
|
22
|
+
- Classified lifecycle failures: identity mismatch, conflicting exact matches,
|
|
23
|
+
expected-head conflict, unsupported capability, unknown mutation outcome.
|
|
24
|
+
- Server selection helpers `ServerRegistry.resolve_for`, `matching_servers`,
|
|
25
|
+
and `servers_for_owner_repo`; `PrReference` atom for `NUMBER`,
|
|
26
|
+
`owner/repo#NUMBER`, and URL identifiers.
|
|
27
|
+
- Config-driven provider loading via `git.providers` (default: both shipped
|
|
28
|
+
provider gems).
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- PR identifiers now validate repository identity against the resolved server;
|
|
32
|
+
mismatches fail before any mutation. `owner/repo#NUMBER` requires exactly one
|
|
33
|
+
configured matching server and never assumes github.com.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- Forge-neutral `ace-git pr` lifecycle CLI: `show`, `create`, `update`, `ready`, `merge`
|
|
38
|
+
with `--server` / `--default-server` selection and `--format json`.
|
|
39
|
+
- Provider contract for PR lifecycle mutations (exact base/head identity,
|
|
40
|
+
idempotent create reconciliation, expected-head enforcement, unknown-outcome
|
|
41
|
+
classification) on `Ace::Git::Providers::Base`.
|
|
42
|
+
- Normalized evidence types: PR provenance fields (`head_repository_url`,
|
|
43
|
+
`base_repository_url`, `merge_commit_sha`), `ProviderPullRequestIdentity`,
|
|
44
|
+
`ProviderMutationReceipt`, `ProviderCleanupProof` with the cleanup proof
|
|
45
|
+
status vocabulary.
|
|
46
|
+
- Classified lifecycle failures: identity mismatch, conflicting exact matches,
|
|
47
|
+
expected-head conflict, unsupported capability, unknown mutation outcome.
|
|
48
|
+
- Server selection helpers `ServerRegistry.resolve_for`, `matching_servers`,
|
|
49
|
+
and `servers_for_owner_repo`; `PrReference` atom for `NUMBER`,
|
|
50
|
+
`owner/repo#NUMBER`, and URL identifiers.
|
|
51
|
+
- Config-driven provider loading via `git.providers` (default: both shipped
|
|
52
|
+
provider gems).
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
- PR identifiers now validate repository identity against the resolved server;
|
|
56
|
+
mismatches fail before any mutation. `owner/repo#NUMBER` requires exactly one
|
|
57
|
+
configured matching server and never assumes github.com.
|
|
58
|
+
|
|
59
|
+
|
|
10
60
|
## [0.24.0] - 2026-09-21
|
|
11
61
|
|
|
12
62
|
### Added
|
data/docs/usage.md
CHANGED
|
@@ -23,11 +23,12 @@ gem install ace-git
|
|
|
23
23
|
|
|
24
24
|
## Command Overview
|
|
25
25
|
|
|
26
|
-
`ace-git` ships
|
|
26
|
+
`ace-git` ships the following commands:
|
|
27
27
|
|
|
28
28
|
- `diff` for filtered or formatted git diffs
|
|
29
29
|
- `status` for repository context and PR activity
|
|
30
30
|
- `branch` for current branch and tracking state
|
|
31
|
+
- `pr` for the forge-neutral pull request lifecycle (`show`, `create`, `update`, `ready`, `merge`)
|
|
31
32
|
- `version` for the installed package version
|
|
32
33
|
|
|
33
34
|
`ace-git` with no arguments shows help. Git range shorthand such as `HEAD~5..HEAD` routes to `diff`.
|
|
@@ -154,6 +155,51 @@ ace-git branch --format json
|
|
|
154
155
|
**Options:**
|
|
155
156
|
- `--format, -f` - Output format: `text` (default), `json`
|
|
156
157
|
|
|
158
|
+
### `ace-git pr`
|
|
159
|
+
|
|
160
|
+
Forge-neutral pull request lifecycle over the configured forge servers
|
|
161
|
+
(`git.servers`). Every subcommand accepts `--server NAME` / `--default-server`
|
|
162
|
+
(mutually exclusive; with neither, the configured repository remote is
|
|
163
|
+
resolved) and `--format json`. Server, provider, and the exact head SHA are
|
|
164
|
+
part of every result; failures are classified and exit nonzero.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# Show normalized PR evidence (number, owner/repo#number, or URL)
|
|
168
|
+
ace-git pr show 25
|
|
169
|
+
ace-git pr show cs3b/ace#25 --format json
|
|
170
|
+
ace-git pr show https://forge.example.com/cs3b/ace/pull/25 --server forgejo-lab
|
|
171
|
+
|
|
172
|
+
# Create (or reconcile to) a draft PR for a pushed branch.
|
|
173
|
+
# The pushed head SHA is proven against the PR; draft is the default.
|
|
174
|
+
ace-git pr create --head feature --base main \
|
|
175
|
+
--expected-head "$(git rev-parse HEAD)" \
|
|
176
|
+
--title "Add feature" --body-file /tmp/body.md --format json
|
|
177
|
+
|
|
178
|
+
# Fork PRs name the source repository explicitly; the base repository is
|
|
179
|
+
# always the selected configured server. No fork inference happens.
|
|
180
|
+
ace-git pr create --head feature --head-repo https://forge.example.com/fork/ace \
|
|
181
|
+
--base main --expected-head SHA --title "Fork PR" --draft
|
|
182
|
+
|
|
183
|
+
# Update title/body after head verification
|
|
184
|
+
ace-git pr update 25 --expected-head SHA --title "New title" --body-file /tmp/body.md
|
|
185
|
+
|
|
186
|
+
# Mark a draft ready (provider capability; unsupported providers fail classified)
|
|
187
|
+
ace-git pr ready 25 --expected-head SHA
|
|
188
|
+
|
|
189
|
+
# Merge with provider-side expected-head enforcement; no default method
|
|
190
|
+
ace-git pr merge 25 --expected-head SHA --method squash
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Behavior guarantees:**
|
|
194
|
+
- Create is idempotent: one exact open base/head match returns the existing
|
|
195
|
+
PR (`idempotency: existing`); multiple matches are a conflict.
|
|
196
|
+
- `merge` requires the provider to enforce `--expected-head` atomically;
|
|
197
|
+
providers without that capability return an unsupported-capability failure
|
|
198
|
+
instead of racing.
|
|
199
|
+
- A create request whose outcome is unknown (transport failure after send)
|
|
200
|
+
reports an unknown outcome with the exact identity to reconcile; it never
|
|
201
|
+
retries automatically.
|
|
202
|
+
|
|
157
203
|
### `ace-git version`
|
|
158
204
|
|
|
159
205
|
Print the installed `ace-git` version.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "server_url"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Atoms
|
|
8
|
+
# Parse a user-supplied pull request identifier into its exact components.
|
|
9
|
+
#
|
|
10
|
+
# Three accepted shapes:
|
|
11
|
+
# - bare number: "25"
|
|
12
|
+
# - owner/repo#number: "cs3b/ace#25"
|
|
13
|
+
# - full URL: "https://forge.example.com/cs3b/ace/pull/25"
|
|
14
|
+
# "https://forge.example.com/cs3b/ace/pulls/25"
|
|
15
|
+
#
|
|
16
|
+
# A URL's repository part is canonicalized through {ServerUrl.normalize}
|
|
17
|
+
# so identity matching never assumes a hostname. Parsing is purely
|
|
18
|
+
# textual; it never contacts a forge and never infers github.com.
|
|
19
|
+
module PrReference
|
|
20
|
+
# Parsed reference: `number` always present; exactly one of
|
|
21
|
+
# `repository_url` (canonical URL form) or `owner_repo` is set for
|
|
22
|
+
# explicit-repository shapes, both nil for a bare number.
|
|
23
|
+
Reference = Data.define(:number, :repository_url, :owner_repo) do
|
|
24
|
+
# @return [Boolean] true when the reference names a repository
|
|
25
|
+
def repository_explicit?
|
|
26
|
+
!repository_url.nil? || !owner_repo.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Hash] plain representation
|
|
30
|
+
def to_h
|
|
31
|
+
{number: number, repository_url: repository_url, owner_repo: owner_repo}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
BARE_NUMBER_PATTERN = /\A\d+\z/
|
|
36
|
+
OWNER_REPO_PATTERN = /\A(?<owner>[\w.\-]+)\/(?<repo>[\w.\-]+)#(?<number>\d+)\z/
|
|
37
|
+
URL_PATTERN = %r{
|
|
38
|
+
\Ahttps?://[^/\s]+/(?<path>.+?)/pulls?/(?<number>\d+)(?:[/?#].*)?\z
|
|
39
|
+
}ix
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Parse a pull request identifier.
|
|
43
|
+
#
|
|
44
|
+
# @param input [String, Integer] identifier in any accepted shape
|
|
45
|
+
# @return [Reference, nil] parsed reference, or nil when unrecognized
|
|
46
|
+
def parse(input)
|
|
47
|
+
text = input.to_s.strip
|
|
48
|
+
return nil if text.empty?
|
|
49
|
+
|
|
50
|
+
if (match = text.match(BARE_NUMBER_PATTERN))
|
|
51
|
+
return Reference.new(number: match[0].to_i, repository_url: nil, owner_repo: nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if (match = text.match(OWNER_REPO_PATTERN))
|
|
55
|
+
return Reference.new(
|
|
56
|
+
number: match[:number].to_i,
|
|
57
|
+
repository_url: nil,
|
|
58
|
+
owner_repo: "#{match[:owner]}/#{match[:repo]}"
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if (match = text.match(URL_PATTERN))
|
|
63
|
+
return Reference.new(
|
|
64
|
+
number: match[:number].to_i,
|
|
65
|
+
repository_url: ServerUrl.normalize("#{text.split("/")[0..2].join("/")}/#{match[:path]}"),
|
|
66
|
+
owner_repo: nil
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ace/support/cli"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Git
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Forge-neutral pull request lifecycle commands.
|
|
11
|
+
#
|
|
12
|
+
# Every subcommand accepts `--server NAME` / `--default-server`
|
|
13
|
+
# (mutually exclusive; remote resolution when neither is given) and
|
|
14
|
+
# `--format json`. Success output carries the resolved server,
|
|
15
|
+
# provider, and exact PR identity; failures exit nonzero with the
|
|
16
|
+
# classified provider error and never claim success.
|
|
17
|
+
module Pr
|
|
18
|
+
# Shared plumbing for all pr subcommands.
|
|
19
|
+
class BaseCommand < Ace::Support::Cli::Command
|
|
20
|
+
include Ace::Support::Cli::Base
|
|
21
|
+
|
|
22
|
+
option :server, type: :string, desc: "Explicit configured forge server name"
|
|
23
|
+
option :default_server, type: :boolean, desc: "Use the configured default forge server"
|
|
24
|
+
option :remote, type: :string, desc: "Git remote used for server resolution when nothing is selected"
|
|
25
|
+
option :format, type: :string, default: "text", desc: "Output format: text, json"
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def lifecycle(options)
|
|
30
|
+
Organisms::PullRequestLifecycle.new(
|
|
31
|
+
server_name: options[:server],
|
|
32
|
+
use_default: options[:default_server] == true,
|
|
33
|
+
remote_name: options[:remote]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render_evidence(options, header: nil, **fields)
|
|
38
|
+
if options[:format] == "json"
|
|
39
|
+
puts JSON.pretty_generate(fields)
|
|
40
|
+
else
|
|
41
|
+
puts header if header
|
|
42
|
+
fields.each do |label, value|
|
|
43
|
+
next if value.nil?
|
|
44
|
+
|
|
45
|
+
puts "#{label.to_s.tr("_", "-")}: #{format_value(value)}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_value(value)
|
|
51
|
+
case value
|
|
52
|
+
when Hash
|
|
53
|
+
value.compact.map { |key, nested| "#{key}=#{nested}" }.join(" ")
|
|
54
|
+
else
|
|
55
|
+
value.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def emit_receipt(options, operation, receipt)
|
|
60
|
+
pr = receipt.pull_request
|
|
61
|
+
render_evidence(
|
|
62
|
+
options,
|
|
63
|
+
header: "#{operation} PR ##{pr.number} on #{receipt.server_name}",
|
|
64
|
+
server: receipt.server_name,
|
|
65
|
+
operation: receipt.operation,
|
|
66
|
+
idempotency: receipt.idempotency,
|
|
67
|
+
number: pr.number,
|
|
68
|
+
url: pr.url,
|
|
69
|
+
title: pr.title,
|
|
70
|
+
state: pr.state,
|
|
71
|
+
draft: pr.draft,
|
|
72
|
+
head: pr.head_sha,
|
|
73
|
+
head_ref: pr.head_ref,
|
|
74
|
+
head_repository: pr.head_repository_url,
|
|
75
|
+
base_ref: pr.base_ref,
|
|
76
|
+
base_repository: pr.base_repository_url
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `ace-git pr show ID`
|
|
82
|
+
class Show < BaseCommand
|
|
83
|
+
desc "Show normalized pull request evidence"
|
|
84
|
+
|
|
85
|
+
argument :identifier, required: true, desc: "PR number, owner/repo#number, or URL"
|
|
86
|
+
|
|
87
|
+
def call(identifier:, **options)
|
|
88
|
+
pr = lifecycle(options).show(identifier)
|
|
89
|
+
render_evidence(
|
|
90
|
+
options,
|
|
91
|
+
header: "PR ##{pr.number} on #{pr.server_name}",
|
|
92
|
+
server: pr.server_name,
|
|
93
|
+
number: pr.number,
|
|
94
|
+
url: pr.url,
|
|
95
|
+
title: pr.title,
|
|
96
|
+
state: pr.state,
|
|
97
|
+
draft: pr.draft,
|
|
98
|
+
head: pr.head_sha,
|
|
99
|
+
head_ref: pr.head_ref,
|
|
100
|
+
head_repository: pr.head_repository_url,
|
|
101
|
+
base_ref: pr.base_ref,
|
|
102
|
+
base_repository: pr.base_repository_url,
|
|
103
|
+
merge_commit: pr.merge_commit_sha
|
|
104
|
+
)
|
|
105
|
+
rescue Ace::Git::Error, ArgumentError => e
|
|
106
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# `ace-git pr create --head REF --head-repo URL --base REF --expected-head SHA --title TEXT [--body-file PATH] [--draft]`
|
|
111
|
+
class Create < BaseCommand
|
|
112
|
+
desc "Create (or reconcile to) a pull request; drafts are the default"
|
|
113
|
+
|
|
114
|
+
option :head, type: :string, required: true, desc: "Source branch/ref"
|
|
115
|
+
option :head_repo, type: :string, desc: "Source repository URL (fork); defaults to the base repository"
|
|
116
|
+
option :base, type: :string, required: true, desc: "Base branch/ref"
|
|
117
|
+
option :expected_head, type: :string, required: true, desc: "Exact SHA the pushed source ref must resolve to"
|
|
118
|
+
option :title, type: :string, required: true, desc: "Pull request title"
|
|
119
|
+
option :body_file, type: :string, desc: "Path to the pull request body text"
|
|
120
|
+
option :draft, type: :boolean, default: true, desc: "Create as draft when the provider supports it"
|
|
121
|
+
|
|
122
|
+
def call(head:, base:, expected_head:, title:, **options)
|
|
123
|
+
receipt = lifecycle(options).create(
|
|
124
|
+
head_ref: head,
|
|
125
|
+
head_repository_url: options[:head_repo],
|
|
126
|
+
base_ref: base,
|
|
127
|
+
expected_head: expected_head,
|
|
128
|
+
title: title,
|
|
129
|
+
body_file: options[:body_file],
|
|
130
|
+
draft: options[:draft] != false
|
|
131
|
+
)
|
|
132
|
+
emit_receipt(options, "created", receipt)
|
|
133
|
+
rescue Ace::Git::Error, ArgumentError => e
|
|
134
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# `ace-git pr update ID --expected-head SHA [--title TEXT] [--body-file PATH]`
|
|
139
|
+
class Update < BaseCommand
|
|
140
|
+
desc "Update pull request title/body after head verification"
|
|
141
|
+
|
|
142
|
+
argument :identifier, required: true, desc: "PR number, owner/repo#number, or URL"
|
|
143
|
+
option :expected_head, type: :string, required: true, desc: "Exact SHA the PR head must still be"
|
|
144
|
+
option :title, type: :string, desc: "New title"
|
|
145
|
+
option :body_file, type: :string, desc: "Path to the new body text"
|
|
146
|
+
|
|
147
|
+
def call(identifier:, expected_head:, **options)
|
|
148
|
+
receipt = lifecycle(options).update(
|
|
149
|
+
identifier,
|
|
150
|
+
expected_head: expected_head,
|
|
151
|
+
title: options[:title],
|
|
152
|
+
body_file: options[:body_file]
|
|
153
|
+
)
|
|
154
|
+
emit_receipt(options, "updated", receipt)
|
|
155
|
+
rescue Ace::Git::Error, ArgumentError => e
|
|
156
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# `ace-git pr ready ID --expected-head SHA`
|
|
161
|
+
class Ready < BaseCommand
|
|
162
|
+
desc "Mark a draft pull request ready for review"
|
|
163
|
+
|
|
164
|
+
argument :identifier, required: true, desc: "PR number, owner/repo#number, or URL"
|
|
165
|
+
option :expected_head, type: :string, required: true, desc: "Exact SHA the PR head must still be"
|
|
166
|
+
|
|
167
|
+
def call(identifier:, expected_head:, **options)
|
|
168
|
+
receipt = lifecycle(options).ready(identifier, expected_head: expected_head)
|
|
169
|
+
emit_receipt(options, "readied", receipt)
|
|
170
|
+
rescue Ace::Git::Error, ArgumentError => e
|
|
171
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# `ace-git pr merge ID --expected-head SHA --method squash|merge|rebase`
|
|
176
|
+
class Merge < BaseCommand
|
|
177
|
+
desc "Merge with provider-side expected-head enforcement; no default method"
|
|
178
|
+
|
|
179
|
+
argument :identifier, required: true, desc: "PR number, owner/repo#number, or URL"
|
|
180
|
+
option :expected_head, type: :string, required: true, desc: "Exact SHA enforced by the provider"
|
|
181
|
+
option :method, type: :string, required: true, desc: "Merge method: squash, merge, rebase"
|
|
182
|
+
|
|
183
|
+
def call(identifier:, expected_head:, method:, **options)
|
|
184
|
+
receipt = lifecycle(options).merge(identifier, expected_head: expected_head, method: method)
|
|
185
|
+
emit_receipt(options, "merged", receipt)
|
|
186
|
+
rescue Ace::Git::Error, ArgumentError => e
|
|
187
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
data/lib/ace/git/cli.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "../git"
|
|
|
6
6
|
require_relative "cli/commands/diff"
|
|
7
7
|
require_relative "cli/commands/status"
|
|
8
8
|
require_relative "cli/commands/branch"
|
|
9
|
+
require_relative "cli/commands/pr"
|
|
9
10
|
|
|
10
11
|
module Ace
|
|
11
12
|
module Git
|
|
@@ -50,18 +51,25 @@ module Ace
|
|
|
50
51
|
REGISTERED_COMMANDS = [
|
|
51
52
|
["diff", "Show filtered git diff output"],
|
|
52
53
|
["status", "Show repository status and PR context"],
|
|
53
|
-
["branch", "Show current branch information"]
|
|
54
|
+
["branch", "Show current branch information"],
|
|
55
|
+
["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"]
|
|
54
56
|
].freeze
|
|
55
57
|
|
|
56
58
|
HELP_EXAMPLES = [
|
|
57
59
|
"ace-git diff --since 7d # Changes from last week",
|
|
58
60
|
"ace-git diff -p 'lib/**' -f summary # Filtered summary",
|
|
59
|
-
"ace-git status --no-pr # Quick status, skip network"
|
|
61
|
+
"ace-git status --no-pr # Quick status, skip network",
|
|
62
|
+
"ace-git pr show 25 --format json # Normalized PR evidence"
|
|
60
63
|
].freeze
|
|
61
64
|
|
|
62
65
|
register "diff", Commands::Diff.new
|
|
63
66
|
register "status", Commands::Status.new
|
|
64
67
|
register "branch", Commands::Branch.new
|
|
68
|
+
register "pr show", Commands::Pr::Show.new
|
|
69
|
+
register "pr create", Commands::Pr::Create.new
|
|
70
|
+
register "pr update", Commands::Pr::Update.new
|
|
71
|
+
register "pr ready", Commands::Pr::Ready.new
|
|
72
|
+
register "pr merge", Commands::Pr::Merge.new
|
|
65
73
|
|
|
66
74
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
67
75
|
gem_name: "ace-git",
|
data/lib/ace/git/errors.rb
CHANGED
|
@@ -52,5 +52,31 @@ module Ace
|
|
|
52
52
|
|
|
53
53
|
# The requested pull request, issue, branch, or commit does not exist.
|
|
54
54
|
class ProviderObjectNotFoundError < Error; end
|
|
55
|
+
|
|
56
|
+
# ---- PR lifecycle mutation failures ----
|
|
57
|
+
|
|
58
|
+
# A supplied PR URL or repository identity does not match the resolved
|
|
59
|
+
# server (or the explicit selection contradicts the identifier). Detected
|
|
60
|
+
# before any mutation.
|
|
61
|
+
class ProviderIdentityMismatchError < Error; end
|
|
62
|
+
|
|
63
|
+
# More than one open pull request exactly matches the requested
|
|
64
|
+
# base/head identity, so no single object can be selected.
|
|
65
|
+
class ProviderConflictingMatchesError < Error; end
|
|
66
|
+
|
|
67
|
+
# The pull request head changed relative to the expected head SHA the
|
|
68
|
+
# caller supplied. The operation did not proceed (or the provider refused
|
|
69
|
+
# it atomically).
|
|
70
|
+
class ProviderExpectedHeadConflictError < Error; end
|
|
71
|
+
|
|
72
|
+
# The provider CLI cannot enforce a required precondition atomically
|
|
73
|
+
# (e.g. expected-head merge) or does not offer the requested operation.
|
|
74
|
+
# Never worked around with a check-then-act fallback.
|
|
75
|
+
class ProviderUnsupportedCapabilityError < Error; end
|
|
76
|
+
|
|
77
|
+
# A mutation request was sent but its outcome is unknown (e.g. transport
|
|
78
|
+
# failure after the send). Contains the exact base/head identity needed to
|
|
79
|
+
# reconcile by lookup; never retried automatically.
|
|
80
|
+
class ProviderUnknownOutcomeError < Error; end
|
|
55
81
|
end
|
|
56
82
|
end
|
|
@@ -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
|
|
@@ -104,11 +104,117 @@ module Ace
|
|
|
104
104
|
raise NotImplementedError, "Providers must implement #{self.class}#repository"
|
|
105
105
|
end
|
|
106
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
|
+
|
|
107
199
|
private
|
|
108
200
|
|
|
109
201
|
# Command runner: injected fake or nil (implementations use their own
|
|
110
202
|
# executor when nil).
|
|
111
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
|
|
112
218
|
end
|
|
113
219
|
end
|
|
114
220
|
end
|
|
@@ -12,7 +12,8 @@ module Ace
|
|
|
12
12
|
# :open, :closed.
|
|
13
13
|
ProviderPullRequest = Data.define(
|
|
14
14
|
:server_name, :number, :title, :state, :head_ref, :base_ref,
|
|
15
|
-
:head_sha, :author, :url, :draft, :merged_at
|
|
15
|
+
:head_sha, :author, :url, :draft, :merged_at,
|
|
16
|
+
:head_repository_url, :base_repository_url, :merge_commit_sha
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
ProviderIssue = Data.define(:server_name, :number, :title, :state, :author, :url, :labels)
|
|
@@ -22,5 +23,74 @@ module Ace
|
|
|
22
23
|
|
|
23
24
|
# Normalized repository evidence.
|
|
24
25
|
ProviderRepository = Data.define(:server_name, :full_name, :default_branch, :url)
|
|
26
|
+
|
|
27
|
+
# Exact pull request source/base identity used for idempotent create
|
|
28
|
+
# reconciliation, expected-head proof, and cleanup consent binding.
|
|
29
|
+
#
|
|
30
|
+
# `head_sha` is the exact head commit the identity was captured at; every
|
|
31
|
+
# mutation decision that depends on head state must compare against it.
|
|
32
|
+
ProviderPullRequestIdentity = Data.define(
|
|
33
|
+
:head_repository_url, :head_ref, :base_repository_url, :base_ref, :head_sha
|
|
34
|
+
) do
|
|
35
|
+
# @return [Hash] plain, JSON-serializable representation
|
|
36
|
+
def to_h
|
|
37
|
+
{
|
|
38
|
+
head_repository_url: head_repository_url,
|
|
39
|
+
head_ref: head_ref,
|
|
40
|
+
base_repository_url: base_repository_url,
|
|
41
|
+
base_ref: base_ref,
|
|
42
|
+
head_sha: head_sha
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Normalized result of one provider lifecycle mutation (create, update,
|
|
48
|
+
# ready, merge). Receipts are immutable evidence: the operation performed,
|
|
49
|
+
# the pull request state observed afterwards, and — for creates only —
|
|
50
|
+
# whether the request created a new PR or reconciled to an existing one
|
|
51
|
+
# (`:created` / `:existing`; nil for non-create operations).
|
|
52
|
+
ProviderMutationReceipt = Data.define(:server_name, :operation, :pull_request, :idempotency) do
|
|
53
|
+
# @return [Hash] plain, JSON-serializable representation
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
server_name: server_name,
|
|
57
|
+
operation: operation,
|
|
58
|
+
idempotency: idempotency,
|
|
59
|
+
pull_request: pull_request&.to_h
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Cleanup proof status vocabulary. Only :merged is confirmed remote merge
|
|
65
|
+
# proof; every other status retains the candidate. Provider transport
|
|
66
|
+
# failures (:offline, :authentication_error, :malformed) are distinct from
|
|
67
|
+
# object states and never relabel a failed lookup as merged.
|
|
68
|
+
CLEANUP_PROOF_STATUSES = %i[
|
|
69
|
+
no_pr open closed_unmerged merged offline authentication_error malformed
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
# Normalized provider proof about the pull request behind a cleanup
|
|
73
|
+
# candidate (worktree, local ref, or remote ref). `status` is one of
|
|
74
|
+
# {CLEANUP_PROOF_STATUSES}; pr/head fields are nil unless a PR was found.
|
|
75
|
+
ProviderCleanupProof = Data.define(
|
|
76
|
+
:server_name, :status, :pr_number, :pr_url, :head_sha, :merge_commit_sha
|
|
77
|
+
) do
|
|
78
|
+
# @return [Boolean] true only for confirmed remote merge proof
|
|
79
|
+
def merged?
|
|
80
|
+
status == :merged
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Hash] plain, JSON-serializable representation
|
|
84
|
+
def to_h
|
|
85
|
+
{
|
|
86
|
+
server_name: server_name,
|
|
87
|
+
status: status,
|
|
88
|
+
pr_number: pr_number,
|
|
89
|
+
pr_url: pr_url,
|
|
90
|
+
head_sha: head_sha,
|
|
91
|
+
merge_commit_sha: merge_commit_sha
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
25
95
|
end
|
|
26
96
|
end
|
data/lib/ace/git/providers.rb
CHANGED
|
@@ -42,6 +42,12 @@ module Ace
|
|
|
42
42
|
|
|
43
43
|
# Build the provider implementation for a resolved server.
|
|
44
44
|
#
|
|
45
|
+
# Provider packages register themselves when required. On a miss, the
|
|
46
|
+
# provider libraries listed in the `git.providers` configuration are
|
|
47
|
+
# required once (mono-repo default: both shipped provider gems) and
|
|
48
|
+
# the registry is re-checked; a still-missing provider is a classified
|
|
49
|
+
# failure, never a fallback.
|
|
50
|
+
#
|
|
45
51
|
# @param server [ResolvedServer] exactly-resolved server identity
|
|
46
52
|
# @param timeout [Integer, nil] provider operation timeout in seconds
|
|
47
53
|
# @param runner [Proc, nil] optional command runner injection for tests;
|
|
@@ -52,10 +58,14 @@ module Ace
|
|
|
52
58
|
def for(server, timeout: nil, runner: nil)
|
|
53
59
|
key = normalize_type(server.provider)
|
|
54
60
|
provider_class = @mutex.synchronize { @registry[key] }
|
|
61
|
+
unless provider_class
|
|
62
|
+
require_configured_providers
|
|
63
|
+
provider_class = @mutex.synchronize { @registry[key] }
|
|
64
|
+
end
|
|
55
65
|
unless provider_class
|
|
56
66
|
raise UnknownProviderError,
|
|
57
67
|
"No provider registered for type #{key.inspect} (server '#{server.name}'); " \
|
|
58
|
-
"install
|
|
68
|
+
"install the provider package that owns #{key.inspect} and list it in git.providers"
|
|
59
69
|
end
|
|
60
70
|
|
|
61
71
|
provider_class.new(server: server, timeout: timeout, runner: runner)
|
|
@@ -75,7 +85,10 @@ module Ace
|
|
|
75
85
|
|
|
76
86
|
# Clear all registrations (test seam).
|
|
77
87
|
def reset!
|
|
78
|
-
@mutex.synchronize
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
@registry.clear
|
|
90
|
+
@required_configured = {}
|
|
91
|
+
end
|
|
79
92
|
end
|
|
80
93
|
|
|
81
94
|
private
|
|
@@ -86,6 +99,22 @@ module Ace
|
|
|
86
99
|
|
|
87
100
|
normalized.to_sym
|
|
88
101
|
end
|
|
102
|
+
|
|
103
|
+
# Require the provider libraries configured under `git.providers`
|
|
104
|
+
# (e.g. "github" -> ace/git/github). Load failures are ignored here;
|
|
105
|
+
# a missing provider surfaces as UnknownProviderError at resolution.
|
|
106
|
+
def require_configured_providers
|
|
107
|
+
@required_configured ||= {}
|
|
108
|
+
Array(Ace::Git.config["providers"]).each do |name|
|
|
109
|
+
library = name.to_s.strip.downcase
|
|
110
|
+
next if library.empty? || @required_configured[library]
|
|
111
|
+
|
|
112
|
+
require "ace/git/#{library}"
|
|
113
|
+
@required_configured[library] = true
|
|
114
|
+
rescue LoadError
|
|
115
|
+
@required_configured[library] = true
|
|
116
|
+
end
|
|
117
|
+
end
|
|
89
118
|
end
|
|
90
119
|
end
|
|
91
120
|
end
|
|
@@ -99,6 +99,52 @@ module Ace
|
|
|
99
99
|
matches.first
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# Resolve exactly one server for an operation from CLI-style selection.
|
|
103
|
+
#
|
|
104
|
+
# @param server_name [String, Symbol, nil] explicit configured server name
|
|
105
|
+
# @param use_default [Boolean] resolve the configured default server
|
|
106
|
+
# @param remote_name [String, nil] remote used when nothing is selected
|
|
107
|
+
# @return [ResolvedServer]
|
|
108
|
+
# @raise [ConfigError] when explicit selection and default conflict
|
|
109
|
+
# @raise [UnknownServerNameError, NoDefaultServerConfiguredError,
|
|
110
|
+
# MultipleDefaultServersError, AmbiguousRemoteError] per resolution
|
|
111
|
+
def resolve_for(server_name: nil, use_default: false, remote_name: nil)
|
|
112
|
+
if server_name && use_default
|
|
113
|
+
raise ConfigError,
|
|
114
|
+
"Server selection is ambiguous: an explicit server ('#{server_name}') and " \
|
|
115
|
+
"the default-server request are mutually exclusive"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if server_name
|
|
119
|
+
resolve(server_name)
|
|
120
|
+
elsif use_default
|
|
121
|
+
resolve_default
|
|
122
|
+
else
|
|
123
|
+
resolve_remote(remote_name)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Configured servers whose URL matches the repository URL exactly.
|
|
128
|
+
#
|
|
129
|
+
# @param repository_url [String] repository URL in any supported shape
|
|
130
|
+
# @return [Array<ResolvedServer>] matching servers (may be empty)
|
|
131
|
+
def matching_servers(repository_url)
|
|
132
|
+
entries.map(&:server).select { |server| Atoms::ServerUrl.match?(server.url, repository_url) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Configured servers whose repository path equals `owner/repo`
|
|
136
|
+
# (any host). Never assumes a forge hostname.
|
|
137
|
+
#
|
|
138
|
+
# @param owner_repo [String] "owner/repo"
|
|
139
|
+
# @return [Array<ResolvedServer>] matching servers (may be empty)
|
|
140
|
+
def servers_for_owner_repo(owner_repo)
|
|
141
|
+
wanted = owner_repo.to_s.downcase
|
|
142
|
+
entries.map(&:server).select do |server|
|
|
143
|
+
path = Atoms::ServerUrl.normalize(server.url).to_s.split("/", 2)[1]
|
|
144
|
+
path == wanted
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
102
148
|
private
|
|
103
149
|
|
|
104
150
|
# Validated configured entries, in configuration order.
|
data/lib/ace/git/version.rb
CHANGED
data/lib/ace/git.rb
CHANGED
|
@@ -120,7 +120,7 @@ module Ace
|
|
|
120
120
|
normalized = normalize_keys(git_section)
|
|
121
121
|
|
|
122
122
|
# Copy top-level settings
|
|
123
|
-
%w[default_branch remote verbose timeout network_timeout servers].each do |key|
|
|
123
|
+
%w[default_branch remote verbose timeout network_timeout servers providers].each do |key|
|
|
124
124
|
config[key] = normalized[key] if normalized.key?(key)
|
|
125
125
|
end
|
|
126
126
|
|
|
@@ -195,6 +195,7 @@ end
|
|
|
195
195
|
# Require ATOM architecture components
|
|
196
196
|
require_relative "git/atoms/command_executor"
|
|
197
197
|
require_relative "git/atoms/server_url"
|
|
198
|
+
require_relative "git/atoms/pr_reference"
|
|
198
199
|
require_relative "git/atoms/pr_identifier"
|
|
199
200
|
require_relative "git/atoms/pattern_filter"
|
|
200
201
|
require_relative "git/atoms/diff_parser"
|
|
@@ -220,6 +221,7 @@ require_relative "git/molecules/git_status_fetcher"
|
|
|
220
221
|
|
|
221
222
|
require_relative "git/organisms/diff_orchestrator"
|
|
222
223
|
require_relative "git/organisms/repo_status_loader"
|
|
224
|
+
require_relative "git/organisms/pull_request_lifecycle"
|
|
223
225
|
require_relative "git/resolved_server"
|
|
224
226
|
require_relative "git/server_registry"
|
|
225
227
|
require_relative "git/providers/base"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.25.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-config
|
|
@@ -150,6 +150,7 @@ files:
|
|
|
150
150
|
- lib/ace/git/atoms/lock_error_detector.rb
|
|
151
151
|
- lib/ace/git/atoms/pattern_filter.rb
|
|
152
152
|
- lib/ace/git/atoms/pr_identifier.rb
|
|
153
|
+
- lib/ace/git/atoms/pr_reference.rb
|
|
153
154
|
- lib/ace/git/atoms/repository_checker.rb
|
|
154
155
|
- lib/ace/git/atoms/repository_state_detector.rb
|
|
155
156
|
- lib/ace/git/atoms/server_url.rb
|
|
@@ -160,6 +161,7 @@ files:
|
|
|
160
161
|
- lib/ace/git/cli.rb
|
|
161
162
|
- lib/ace/git/cli/commands/branch.rb
|
|
162
163
|
- lib/ace/git/cli/commands/diff.rb
|
|
164
|
+
- lib/ace/git/cli/commands/pr.rb
|
|
163
165
|
- lib/ace/git/cli/commands/status.rb
|
|
164
166
|
- lib/ace/git/errors.rb
|
|
165
167
|
- lib/ace/git/models/diff_config.rb
|
|
@@ -172,6 +174,7 @@ files:
|
|
|
172
174
|
- lib/ace/git/molecules/git_status_fetcher.rb
|
|
173
175
|
- lib/ace/git/molecules/recent_commits_fetcher.rb
|
|
174
176
|
- lib/ace/git/organisms/diff_orchestrator.rb
|
|
177
|
+
- lib/ace/git/organisms/pull_request_lifecycle.rb
|
|
175
178
|
- lib/ace/git/organisms/repo_status_loader.rb
|
|
176
179
|
- lib/ace/git/providers.rb
|
|
177
180
|
- lib/ace/git/providers/base.rb
|