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.
@@ -1,288 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Ace
6
- module Git
7
- module Molecules
8
- # Fetch PR metadata via gh CLI
9
- # Consolidated from ace-bundle GhPrExecutor and ace-review GhPrFetcher
10
- class PrMetadataFetcher
11
- # Error message patterns from gh CLI
12
- PR_NOT_FOUND_PATTERN = /not found|Could not resolve/i
13
- AUTH_ERROR_PATTERN = /authentication|Unauthorized|not logged in|auth login/i
14
-
15
- # Valid characters for PR identifiers (owner/repo#number format)
16
- # Allows: alphanumeric, hyphens, underscores, dots, forward slashes, hash, at, colon
17
- # This prevents shell metacharacters from reaching command execution
18
- VALID_IDENTIFIER_PATTERN = /\A[\w\/.\-#@:]+\z/
19
-
20
- # Fields to fetch for PR metadata
21
- # Extracted as constant for maintainability
22
- PR_FIELDS = %w[
23
- number
24
- state
25
- isDraft
26
- title
27
- author
28
- headRefName
29
- baseRefName
30
- url
31
- isCrossRepository
32
- headRepositoryOwner
33
- headRefOid
34
- mergeCommit
35
- ].freeze
36
-
37
- class << self
38
- # Check if gh CLI is installed
39
- # @return [Boolean] True if gh is installed
40
- def gh_installed?
41
- result = Atoms::CommandExecutor.execute("gh", "--version")
42
- result[:success]
43
- end
44
-
45
- # Check if gh CLI is authenticated
46
- # @return [Boolean] True if authenticated
47
- def gh_authenticated?
48
- result = Atoms::CommandExecutor.execute("gh", "auth", "status")
49
- result[:success]
50
- end
51
-
52
- # Fetch PR diff content
53
- # @param identifier [String] PR identifier (number, URL, or owner/repo#number)
54
- # @param timeout [Integer] Timeout in seconds (default from config)
55
- # @return [Hash] Result with :success, :diff, :error
56
- def fetch_diff(identifier, timeout: Ace::Git.network_timeout)
57
- parsed = Atoms::PrIdentifierParser.parse(identifier)
58
- raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
59
-
60
- # Validate identifier characters before command execution (defense in depth)
61
- validate_identifier_characters(parsed.gh_format)
62
-
63
- result = execute_gh_command(["gh", "pr", "diff", parsed.gh_format], timeout: timeout)
64
-
65
- if result[:success]
66
- {
67
- success: true,
68
- diff: result[:output],
69
- identifier: parsed.gh_format,
70
- source: build_source_label(parsed)
71
- }
72
- else
73
- handle_error(result[:error], parsed.gh_format)
74
- end
75
- rescue Errno::ENOENT
76
- raise Ace::Git::GhNotInstalledError, "GitHub CLI (gh) not installed. Install with: brew install gh"
77
- end
78
-
79
- # Fetch PR metadata (state, draft status, title, etc.)
80
- # @param identifier [String] PR identifier
81
- # @param timeout [Integer] Timeout in seconds (default from config)
82
- # @return [Hash] Result with :success, :metadata, :error
83
- def fetch_metadata(identifier, timeout: Ace::Git.network_timeout)
84
- parsed = Atoms::PrIdentifierParser.parse(identifier)
85
- raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
86
-
87
- # Validate identifier characters before command execution (defense in depth)
88
- validate_identifier_characters(parsed.gh_format)
89
-
90
- result = execute_gh_command(
91
- ["gh", "pr", "view", parsed.gh_format, "--json", PR_FIELDS.join(",")],
92
- timeout: timeout
93
- )
94
-
95
- if result[:success]
96
- metadata = JSON.parse(result[:output])
97
- {
98
- success: true,
99
- metadata: metadata,
100
- identifier: parsed.gh_format,
101
- parsed: {number: parsed.number, repo: parsed.repo}
102
- }
103
- else
104
- handle_error(result[:error], parsed.gh_format)
105
- end
106
- rescue JSON::ParserError => e
107
- {
108
- success: false,
109
- error: "Failed to parse PR metadata: #{e.message}"
110
- }
111
- rescue Errno::ENOENT
112
- raise Ace::Git::GhNotInstalledError, "GitHub CLI (gh) not installed. Install with: brew install gh"
113
- end
114
-
115
- # Fetch both diff and metadata
116
- # @param identifier [String] PR identifier
117
- # @param timeout [Integer] Timeout in seconds (default from config)
118
- # @return [Hash] Result with :success, :diff, :metadata, :error
119
- def fetch_pr(identifier, timeout: Ace::Git.network_timeout)
120
- diff_result = fetch_diff(identifier, timeout: timeout)
121
- return diff_result unless diff_result[:success]
122
-
123
- metadata_result = fetch_metadata(identifier, timeout: timeout)
124
- return metadata_result unless metadata_result[:success]
125
-
126
- {
127
- success: true,
128
- diff: diff_result[:diff],
129
- metadata: metadata_result[:metadata],
130
- identifier: diff_result[:identifier],
131
- source: diff_result[:source]
132
- }
133
- end
134
-
135
- # Find PR number for current branch
136
- # @param timeout [Integer] Timeout in seconds (default from config)
137
- # @return [String|nil] PR number or nil
138
- def find_pr_for_branch(timeout: Ace::Git.network_timeout)
139
- result = execute_gh_command(
140
- ["gh", "pr", "view", "--json", "number"],
141
- timeout: timeout
142
- )
143
-
144
- return nil unless result[:success]
145
-
146
- data = JSON.parse(result[:output])
147
- data["number"]&.to_s
148
- rescue JSON::ParserError, Errno::ENOENT
149
- nil
150
- end
151
-
152
- # Fetch recently merged PRs
153
- # @param limit [Integer] Maximum number of PRs to return (default from config)
154
- # @param timeout [Integer] Timeout in seconds
155
- # @return [Hash] Result with :success, :prs array, or :error
156
- def fetch_recently_merged(limit: Ace::Git.merged_prs_limit, timeout: Ace::Git.network_timeout)
157
- result = execute_gh_command(
158
- ["gh", "pr", "list", "--state", "merged", "--limit", limit.to_s,
159
- "--json", "number,title,mergedAt,author"],
160
- timeout: timeout
161
- )
162
-
163
- if result[:success]
164
- prs = JSON.parse(result[:output])
165
- {success: true, prs: prs}
166
- else
167
- {success: false, error: result[:error], prs: []}
168
- end
169
- rescue JSON::ParserError => e
170
- {success: false, error: "Failed to parse merged PRs: #{e.message}", prs: []}
171
- rescue Errno::ENOENT
172
- {success: false, error: "GitHub CLI (gh) not installed", prs: []}
173
- end
174
-
175
- # Fetch open PRs
176
- # @param exclude_branch [String, nil] Branch name to exclude from results
177
- # @param limit [Integer] Maximum number of PRs to return (default from config)
178
- # @param timeout [Integer] Timeout in seconds
179
- # @return [Hash] Result with :success, :prs array, or :error
180
- def fetch_open_prs(exclude_branch: nil, limit: Ace::Git.open_prs_limit, timeout: Ace::Git.network_timeout)
181
- result = execute_gh_command(
182
- ["gh", "pr", "list", "--state", "open", "--limit", limit.to_s,
183
- "--json", "number,title,author,headRefName"],
184
- timeout: timeout
185
- )
186
-
187
- if result[:success]
188
- prs = JSON.parse(result[:output])
189
- # Filter out current branch if specified
190
- if exclude_branch
191
- prs = prs.reject { |pr| pr["headRefName"] == exclude_branch }
192
- end
193
- {success: true, prs: prs}
194
- else
195
- {success: false, error: result[:error], prs: []}
196
- end
197
- rescue JSON::ParserError => e
198
- {success: false, error: "Failed to parse open PRs: #{e.message}", prs: []}
199
- rescue Errno::ENOENT
200
- {success: false, error: "GitHub CLI (gh) not installed", prs: []}
201
- end
202
-
203
- # Fetch all recent PRs in a single call for optimal performance
204
- # Returns open, merged, and closed PRs - caller filters by state locally
205
- # @param limit [Integer] Maximum PRs to fetch (default: 15, enough for typical use)
206
- # @param timeout [Integer] Timeout in seconds
207
- # @return [Hash] Result with :success, :prs array, or :error
208
- def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout)
209
- result = execute_gh_command(
210
- ["gh", "pr", "list", "--state", "all", "--limit", limit.to_s,
211
- "--json", "number,title,state,mergedAt,author,headRefName,isDraft,baseRefName,url,headRefOid,mergeCommit"],
212
- timeout: timeout
213
- )
214
-
215
- if result[:success]
216
- prs = JSON.parse(result[:output])
217
- {success: true, prs: prs}
218
- else
219
- {success: false, error: result[:error], prs: []}
220
- end
221
- rescue JSON::ParserError => e
222
- {success: false, error: "Failed to parse PR list: #{e.message}", prs: []}
223
- rescue Errno::ENOENT
224
- {success: false, error: "GitHub CLI (gh) not installed", prs: []}
225
- end
226
-
227
- private
228
-
229
- # Environment variables for consistent gh CLI output across all locales
230
- GH_ENV = {"LC_ALL" => "C"}.freeze
231
-
232
- # Execute gh command with timeout via CommandExecutor
233
- # @param args [Array<String>] Command arguments
234
- # @param timeout [Integer] Timeout in seconds
235
- # @return [Hash] Result with :success, :output, :error, :exit_code
236
- def execute_gh_command(args, timeout:)
237
- # Delegate to CommandExecutor with LC_ALL=C for consistent output format
238
- # regardless of user's locale settings
239
- result = Atoms::CommandExecutor.execute(*args, timeout: timeout, env: GH_ENV)
240
-
241
- # Check for timeout (CommandExecutor returns exit_code: -1 with timeout message)
242
- if result[:exit_code] == -1 && result[:error]&.include?("timed out")
243
- raise Ace::Git::TimeoutError, "gh command timed out after #{timeout}s: #{args.join(" ")}"
244
- end
245
-
246
- result
247
- end
248
-
249
- def build_source_label(parsed)
250
- if parsed.repo
251
- "pr:#{parsed.repo}##{parsed.number}"
252
- else
253
- "pr:#{parsed.number}"
254
- end
255
- end
256
-
257
- # Validate identifier characters to prevent shell metacharacter injection
258
- # Defense in depth - Open3.capture3 with array args is already safe,
259
- # but this adds explicit validation as a secondary security layer
260
- # @param identifier [String] Identifier to validate
261
- # @raise [ArgumentError] If identifier contains invalid characters
262
- def validate_identifier_characters(identifier)
263
- return if identifier.nil? || identifier.empty?
264
-
265
- unless identifier.match?(VALID_IDENTIFIER_PATTERN)
266
- raise ArgumentError, "Invalid identifier characters: #{identifier}"
267
- end
268
- end
269
-
270
- def handle_error(error_message, identifier)
271
- error_str = error_message.to_s
272
-
273
- if error_str.match?(PR_NOT_FOUND_PATTERN)
274
- raise Ace::Git::PrNotFoundError, "PR not found: #{identifier}"
275
- elsif error_str.match?(AUTH_ERROR_PATTERN)
276
- raise Ace::Git::GhAuthenticationError, "Not authenticated with GitHub. Run: gh auth login"
277
- else
278
- {
279
- success: false,
280
- error: "gh pr command failed: #{error_str}"
281
- }
282
- end
283
- end
284
- end
285
- end
286
- end
287
- end
288
- end