ace-git 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,110 +7,185 @@ module Ace
7
7
  module Git
8
8
  module CLI
9
9
  module Commands
10
- # ace-support-cli command for showing PR information
11
- class Pr < Ace::Support::Cli::Command
12
- include Ace::Support::Cli::Base
13
-
14
- desc "Show PR information"
15
-
16
- argument :number, required: false, desc: "PR number (auto-detected if not provided)"
17
-
18
- option :format, type: :string, aliases: ["f"], default: "markdown",
19
- desc: "Output format: markdown, json"
20
- option :with_diff, type: :boolean, default: false,
21
- desc: "Include PR diff in output"
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
22
36
 
23
- # Standard options
24
- option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
25
- option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
26
- option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
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?
27
44
 
28
- def call(number: nil, **options)
29
- # Check if gh is installed
30
- unless Molecules::PrMetadataFetcher.gh_installed?
31
- raise Ace::Support::Cli::Error.new("GitHub CLI (gh) not installed. Install with: brew install gh")
45
+ puts "#{label.to_s.tr("_", "-")}: #{format_value(value)}"
46
+ end
47
+ end
32
48
  end
33
49
 
34
- # Determine PR identifier
35
- identifier = if number
36
- number.to_s
37
- else
38
- # Try to find PR for current branch
39
- found = Molecules::PrMetadataFetcher.find_pr_for_branch
40
- unless found
41
- raise Ace::Support::Cli::Error.new("No PR found for current branch. Specify a PR number.")
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
42
56
  end
43
- found
44
57
  end
45
58
 
46
- # Fetch PR data
47
- result = if options[:with_diff]
48
- Molecules::PrMetadataFetcher.fetch_pr(identifier)
49
- else
50
- Molecules::PrMetadataFetcher.fetch_metadata(identifier)
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
+ )
51
78
  end
79
+ end
52
80
 
53
- unless result[:success]
54
- raise Ace::Support::Cli::Error.new(result[:error])
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)
55
107
  end
108
+ end
56
109
 
57
- # Output based on format
58
- case options[:format]
59
- when "json"
60
- output_data = {metadata: result[:metadata]}
61
- output_data[:diff] = result[:diff] if options[:with_diff]
62
- puts JSON.pretty_generate(output_data)
63
- else
64
- output_markdown(result[:metadata], result[:diff], options)
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)
65
135
  end
66
- rescue Ace::Git::Error => e
67
- raise Ace::Support::Cli::Error.new(e.message)
68
- rescue ArgumentError => e
69
- raise Ace::Support::Cli::Error.new(e.message)
70
136
  end
71
137
 
72
- private
73
-
74
- def output_markdown(metadata, diff, options)
75
- lines = []
76
-
77
- # Header line: # PR #82: Title... [OPEN]
78
- header = "# PR ##{metadata["number"]}"
79
- header += ": #{metadata["title"]}" if metadata["title"]
80
- header += " [#{metadata["state"]}]" if metadata["state"]
81
- lines << header
82
-
83
- # Branch line: Branch: source → target | Draft: No
84
- branch_parts = []
85
- if metadata["headRefName"] && metadata["baseRefName"]
86
- branch_parts << "Branch: #{metadata["headRefName"]} → #{metadata["baseRefName"]}"
87
- elsif metadata["headRefName"]
88
- branch_parts << "Branch: #{metadata["headRefName"]}"
89
- elsif metadata["baseRefName"]
90
- branch_parts << "Target: #{metadata["baseRefName"]}"
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)
91
157
  end
92
- branch_parts << "Draft: #{metadata["isDraft"] ? "Yes" : "No"}" if metadata.key?("isDraft")
93
- lines << branch_parts.join(" | ") unless branch_parts.empty?
158
+ end
94
159
 
95
- # Author line
96
- if metadata["author"]
97
- author = metadata["author"].is_a?(Hash) ? metadata["author"]["login"] : metadata["author"]
98
- lines << "Author: #{author}"
99
- end
160
+ # `ace-git pr ready ID --expected-head SHA`
161
+ class Ready < BaseCommand
162
+ desc "Mark a draft pull request ready for review"
100
163
 
101
- # URL line
102
- lines << "URL: #{metadata["url"]}" if metadata["url"]
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"
103
166
 
104
- if options[:with_diff] && diff
105
- lines << ""
106
- lines << "## Diff"
107
- lines << ""
108
- lines << "```diff"
109
- lines << diff
110
- lines << "```"
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)
111
172
  end
173
+ end
112
174
 
113
- puts lines.join("\n")
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
114
189
  end
115
190
  end
116
191
  end
@@ -59,24 +59,34 @@ module Ace
59
59
  # Include diff if requested
60
60
  if options[:with_diff] && status.has_pr?
61
61
  begin
62
- diff_result = Molecules::PrMetadataFetcher.fetch_diff(
63
- status.pr_metadata["number"].to_s
64
- )
65
- if diff_result[:success]
62
+ provider = active_provider
63
+ diff = provider&.pull_request_diff(number: status.pr_metadata.number.to_s)
64
+ if diff
66
65
  puts ""
67
66
  puts "## PR Diff"
68
67
  puts ""
69
68
  puts "```diff"
70
- puts diff_result[:diff]
69
+ puts diff
71
70
  puts "```"
72
71
  end
73
72
  rescue Ace::Git::Error
74
- # Silently skip diff if it fails
73
+ # Classified provider failure: skip the diff section
75
74
  end
76
75
  end
77
76
  rescue Ace::Git::Error => e
78
77
  raise Ace::Support::Cli::Error.new(e.message)
79
78
  end
79
+
80
+ private
81
+
82
+ # Resolve the default server's registered provider for optional
83
+ # enrichment; nil when no server or provider is configured.
84
+ def active_provider
85
+ server = Ace::Git::ServerRegistry.resolve_default
86
+ Ace::Git::Providers.for(server)
87
+ rescue Ace::Git::Error
88
+ nil
89
+ end
80
90
  end
81
91
  end
82
92
  end
data/lib/ace/git/cli.rb CHANGED
@@ -52,19 +52,24 @@ module Ace
52
52
  ["diff", "Show filtered git diff output"],
53
53
  ["status", "Show repository status and PR context"],
54
54
  ["branch", "Show current branch information"],
55
- ["pr", "Show pull request information"]
55
+ ["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"]
56
56
  ].freeze
57
57
 
58
58
  HELP_EXAMPLES = [
59
59
  "ace-git diff --since 7d # Changes from last week",
60
60
  "ace-git diff -p 'lib/**' -f summary # Filtered summary",
61
- "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"
62
63
  ].freeze
63
64
 
64
65
  register "diff", Commands::Diff.new
65
66
  register "status", Commands::Status.new
66
67
  register "branch", Commands::Branch.new
67
- register "pr", Commands::Pr.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
68
73
 
69
74
  version_cmd = Ace::Support::Cli::VersionCommand.build(
70
75
  gem_name: "ace-git",
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ # Error handling pattern:
6
+ # - Atoms: Pure functions, raise exceptions for invalid inputs
7
+ # - Molecules: May return error hashes for "expected" errors (e.g., not in git repo)
8
+ # or raise exceptions for unexpected failures
9
+ # - Organisms: Orchestrate molecules, propagate or wrap exceptions
10
+ # - Commands: Catch exceptions and return exit codes (0=success, 1=error)
11
+ #
12
+ # All custom exceptions inherit from Ace::Git::Error for consistent catching.
13
+ class Error < StandardError; end
14
+ class GitError < Error; end
15
+ class ConfigError < Error; end
16
+ class TimeoutError < Error; end
17
+
18
+ # ---- Forge server resolution failures ----
19
+
20
+ # Default requested but no default server is configured.
21
+ class NoDefaultServerConfiguredError < Error; end
22
+
23
+ # More than one server is marked as default in configuration.
24
+ class MultipleDefaultServersError < Error; end
25
+
26
+ # Multiple servers are configured with the same identifier.
27
+ class DuplicateServerNameError < Error; end
28
+
29
+ # A server references a provider type that no provider package registered.
30
+ class UnknownProviderError < Error; end
31
+
32
+ # The requested server name is not present in configuration.
33
+ class UnknownServerNameError < Error; end
34
+
35
+ # A remote URL matches multiple configured servers, or none, so the server
36
+ # identity cannot be determined without caller input.
37
+ class AmbiguousRemoteError < Error; end
38
+
39
+ # ---- Provider interaction failures ----
40
+
41
+ # The provider CLI binary is not installed in PATH.
42
+ class ProviderCliMissingError < Error; end
43
+
44
+ # The provider CLI is unauthenticated or its credential lease expired.
45
+ class ProviderAuthenticationError < Error; end
46
+
47
+ # The remote forge endpoint is offline or unreachable.
48
+ class ProviderUnreachableError < Error; end
49
+
50
+ # The provider CLI returned invalid JSON or an unexpected schema.
51
+ class ProviderMalformedOutputError < Error; end
52
+
53
+ # The requested pull request, issue, branch, or commit does not exist.
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
81
+ end
82
+ end
@@ -91,7 +91,7 @@ module Ace
91
91
  elsif exclude_renames?
92
92
  flags << "--diff-filter=ACDMTUXB"
93
93
  elsif exclude_moves?
94
- # Moves appear as rename with 100% similarity, disable high-similarity renames
94
+ # Moves appear as renames with total similarity; disable such exact renames
95
95
  flags << "-M0" # Disable rename detection (moves are renames at 100% similarity)
96
96
  end
97
97
  flags
@@ -164,7 +164,7 @@ module Ace
164
164
  hash.each_key do |key|
165
165
  key_str = key.to_s
166
166
  next if KNOWN_KEYS.include?(key_str)
167
- # Skip nested sections that may be passed through
167
+ # Skip nested sections that may be propagated
168
168
  next if %w[diff rebase pr squash default_branch remote verbose].include?(key_str)
169
169
 
170
170
  warn "[ace-git] Unknown config key '#{key_str}' in DiffConfig - did you mean one of: #{KNOWN_KEYS.join(", ")}?"
@@ -68,7 +68,7 @@ module Ace
68
68
  # Check if has associated PR
69
69
  # @return [Boolean] True if PR metadata present
70
70
  def has_pr?
71
- !pr_metadata.nil? && !pr_metadata.empty?
71
+ !pr_metadata.nil?
72
72
  end
73
73
 
74
74
  # Check if has detected task pattern
@@ -35,17 +35,14 @@ module Ace
35
35
  # @param executor [Module] Command executor
36
36
  # @return [Hash] { ahead: Integer, behind: Integer }
37
37
  def tracking_status(executor: Atoms::CommandExecutor)
38
- result = executor.execute("git", "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
38
+ ahead = count_commits("@{upstream}..HEAD", executor: executor)
39
+ behind = count_commits("HEAD..@{upstream}", executor: executor)
39
40
 
40
- unless result[:success]
41
+ if ahead.nil? || behind.nil?
41
42
  return {ahead: 0, behind: 0, error: "No tracking branch or not in git repo"}
42
43
  end
43
44
 
44
- parts = result[:output].strip.split(/\s+/)
45
- {
46
- ahead: parts[1].to_i,
47
- behind: parts[0].to_i
48
- }
45
+ {ahead: ahead, behind: behind}
49
46
  end
50
47
 
51
48
  # Get full branch information
@@ -85,6 +82,15 @@ module Ace
85
82
  "#{behind} behind"
86
83
  end
87
84
  end
85
+
86
+ # Count commits in a rev-list range; nil when the range is invalid
87
+ # (e.g. no upstream configured)
88
+ def count_commits(range, executor: Atoms::CommandExecutor)
89
+ result = executor.execute("git", "rev-list", "--count", range)
90
+ return nil unless result[:success]
91
+
92
+ result[:output].strip.to_i
93
+ end
88
94
  end
89
95
  end
90
96
  end
@@ -13,7 +13,7 @@ module Ace
13
13
  class << self
14
14
  # Load configuration using ace-config cascade with deep merge
15
15
  # Priority: instance_config merged over global config (which is already defaults + user)
16
- # @param instance_config [Hash] Instance-level configuration (highest priority)
16
+ # @param instance_config [Hash] Instance-level configuration (top priority)
17
17
  # @return [Models::DiffConfig] Merged configuration
18
18
  def load(instance_config = {})
19
19
  # Get global config from ace-git (already merged defaults + user per ADR-022)