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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 746038569079877012656b703209b794e8a055718955f23f8d4b613ec8fb6628
4
- data.tar.gz: 841b2ac7dc6eed9348e36a46f6abef8e18c8f726e9f26a705d96a3548209ee27
3
+ metadata.gz: '089e377ec56099fb872544bfc754f6f95f00ecaf157715ecad93e726e9c8960f'
4
+ data.tar.gz: 35bb8b556655c0ef1804fb2fe26039dd182115e558127254c49e17da9ce3f1ba
5
5
  SHA512:
6
- metadata.gz: b10072d2af5d314c0c00fefc7883ab6d33de5bad5388b549a50db5cc1134b92196d1462c4c598c9aeaa3a79114eb44be2d29693aa3a95db74646bd4786dd6131
7
- data.tar.gz: 510071cb8c141cde868bcc0fb79d55e62edbf749c5b82dfbb987488cfa20fcd236fc7c2c5c963db3891655bcbef8bcf9223634298a5abeb0236c4518f9ecf6be
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 four commands:
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",
@@ -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
@@ -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 and require the provider package that owns #{key.inspect}"
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 { @registry.clear }
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.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = "0.24.0"
5
+ VERSION = "0.25.0"
6
6
  end
7
7
  end
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.24.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-23 00:00:00.000000000 Z
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