ace-git 0.23.1 → 0.24.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 +16 -1
- data/CHANGELOG.md +17 -0
- data/docs/getting-started.md +4 -4
- data/docs/usage.md +13 -44
- data/lib/ace/git/atoms/command_executor.rb +1 -1
- data/lib/ace/git/atoms/pr_identifier.rb +66 -0
- data/lib/ace/git/atoms/server_url.rb +56 -0
- data/lib/ace/git/atoms/status_formatter.rb +22 -27
- data/lib/ace/git/cli/commands/status.rb +16 -6
- data/lib/ace/git/cli.rb +1 -4
- data/lib/ace/git/errors.rb +56 -0
- data/lib/ace/git/models/diff_config.rb +2 -2
- data/lib/ace/git/models/repo_status.rb +1 -1
- data/lib/ace/git/molecules/branch_reader.rb +13 -7
- data/lib/ace/git/molecules/config_loader.rb +1 -1
- data/lib/ace/git/organisms/repo_status_loader.rb +72 -136
- data/lib/ace/git/providers/base.rb +115 -0
- data/lib/ace/git/providers/evidence.rb +26 -0
- data/lib/ace/git/providers.rb +92 -0
- data/lib/ace/git/resolved_server.rb +20 -0
- data/lib/ace/git/server_registry.rb +156 -0
- data/lib/ace/git/version.rb +1 -1
- data/lib/ace/git.rb +13 -14
- metadata +17 -14
- data/lib/ace/git/atoms/pr_identifier_parser.rb +0 -88
- data/lib/ace/git/cli/commands/pr.rb +0 -119
- data/lib/ace/git/molecules/gh_cli_executor.rb +0 -77
- data/lib/ace/git/molecules/github_issue_sync.rb +0 -248
- data/lib/ace/git/molecules/pr_metadata_fetcher.rb +0 -288
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "open3"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Git
|
|
8
|
-
module Molecules
|
|
9
|
-
# Synchronize ACE task linkage metadata to GitHub issues.
|
|
10
|
-
class GithubIssueSync
|
|
11
|
-
class OwnershipConflict < StandardError; end
|
|
12
|
-
|
|
13
|
-
STICKY_MARKER = "<!-- ace-task:tracked -->"
|
|
14
|
-
TRACKED_LABEL = "ace:tracked"
|
|
15
|
-
TERMINAL_STATUSES = %w[done completed shipped closed cancelled skipped archived].freeze
|
|
16
|
-
|
|
17
|
-
def self.sync_task(task_id:, task_title:, task_status:, task_path:, issue_ids:, reason:, previous: nil,
|
|
18
|
-
current_issue_ids: nil)
|
|
19
|
-
current_ids = if current_issue_ids.nil?
|
|
20
|
-
Array(issue_ids).map(&:to_i).uniq
|
|
21
|
-
else
|
|
22
|
-
Array(current_issue_ids).map(&:to_i).uniq
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
issue_ids.each do |issue_id|
|
|
26
|
-
sync_issue(
|
|
27
|
-
issue_id: issue_id.to_i,
|
|
28
|
-
task_id: task_id,
|
|
29
|
-
task_title: task_title,
|
|
30
|
-
task_status: task_status,
|
|
31
|
-
task_path: task_path,
|
|
32
|
-
reason: reason,
|
|
33
|
-
previous: previous,
|
|
34
|
-
currently_linked: current_ids.include?(issue_id.to_i)
|
|
35
|
-
)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
{success: true, synced: issue_ids.length, issues: issue_ids}
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def self.sync_issue(issue_id:, task_id:, task_title:, task_status:, task_path:, reason:, previous:,
|
|
42
|
-
currently_linked:)
|
|
43
|
-
issue = fetch_issue(issue_id)
|
|
44
|
-
sticky = find_sticky_comment(issue["comments"] || [])
|
|
45
|
-
|
|
46
|
-
lines = tracked_lines(sticky&.dig("body"))
|
|
47
|
-
validate_link!(issue_id: issue_id, task_id: task_id, previous_task_id: previous&.[](:id), lines: lines) if currently_linked
|
|
48
|
-
lines = remove_previous_line(lines, previous)
|
|
49
|
-
lines = upsert_line(lines, task_id: task_id, task_title: task_title, task_path: task_path) if currently_linked
|
|
50
|
-
if lines.empty?
|
|
51
|
-
cleanup_tracking_artifacts(issue_id: issue_id, sticky: sticky, labels: issue["labels"] || [])
|
|
52
|
-
return {success: true, issue: issue_id, reason: reason}
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
body = render_body(lines)
|
|
56
|
-
|
|
57
|
-
if sticky
|
|
58
|
-
update_comment(issue_id: issue_id, comment: sticky, body: body)
|
|
59
|
-
else
|
|
60
|
-
create_comment(issue_id: issue_id, body: body)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
ensure_label(issue_id, issue["labels"] || [])
|
|
64
|
-
if currently_linked
|
|
65
|
-
sync_lifecycle(
|
|
66
|
-
issue_id: issue_id,
|
|
67
|
-
issue_state: issue["state"],
|
|
68
|
-
task_status: task_status
|
|
69
|
-
)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
{success: true, issue: issue_id, reason: reason}
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def self.cleanup_tracking_artifacts(issue_id:, sticky:, labels:)
|
|
76
|
-
delete_comment(issue_id: issue_id, comment: sticky) if sticky
|
|
77
|
-
remove_label(issue_id, labels)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def self.fetch_issue(issue_id)
|
|
81
|
-
result = GhCliExecutor.execute("issue", ["view", issue_id.to_s, "--json", "state,comments,labels"])
|
|
82
|
-
raise "Failed to fetch issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
83
|
-
|
|
84
|
-
JSON.parse(result[:stdout])
|
|
85
|
-
rescue JSON::ParserError => e
|
|
86
|
-
raise "Failed to parse issue #{issue_id} response: #{e.message}"
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def self.find_sticky_comment(comments)
|
|
90
|
-
comments.find { |comment| comment["body"].to_s.include?(STICKY_MARKER) }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def self.tracked_lines(body)
|
|
94
|
-
body.to_s.lines.map(&:rstrip).select { |line| line.start_with?("Tracked in ace-task: ") }
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def self.remove_previous_line(lines, previous)
|
|
98
|
-
return lines unless previous.is_a?(Hash)
|
|
99
|
-
|
|
100
|
-
previous_id = previous[:id] || previous["id"]
|
|
101
|
-
return lines unless previous_id
|
|
102
|
-
|
|
103
|
-
lines.reject { |line| extract_task_id(line) == previous_id.to_s }
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def self.upsert_line(lines, task_id:, task_title:, task_path:)
|
|
107
|
-
filtered = lines.reject { |line| extract_task_id(line) == task_id.to_s }
|
|
108
|
-
filtered << tracked_line(task_id: task_id, task_title: task_title, task_path: task_path)
|
|
109
|
-
filtered.uniq.sort
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def self.tracked_line(task_id:, task_title:, task_path:)
|
|
113
|
-
"Tracked in ace-task: [#{task_id}](#{task_url(task_path)})"
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def self.extract_task_id(line)
|
|
117
|
-
match = line.match(/\[([^\]]+)\]\(/)
|
|
118
|
-
match ? match[1] : nil
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def self.render_body(lines)
|
|
122
|
-
([STICKY_MARKER] + lines).join("\n")
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def self.create_comment(issue_id:, body:)
|
|
126
|
-
GhCliExecutor.execute("issue", ["comment", issue_id.to_s, "--body", body]).tap do |result|
|
|
127
|
-
raise "Failed to create sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def self.update_comment(issue_id:, comment:, body:)
|
|
132
|
-
comment_id = comment_api_id(comment)
|
|
133
|
-
GhCliExecutor.execute("api", [
|
|
134
|
-
"repos/{owner}/{repo}/issues/comments/#{comment_id}",
|
|
135
|
-
"--method", "PATCH",
|
|
136
|
-
"--field", "body=#{body}"
|
|
137
|
-
]).tap do |result|
|
|
138
|
-
raise "Failed to update sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def self.delete_comment(issue_id:, comment:)
|
|
143
|
-
comment_id = comment_api_id(comment)
|
|
144
|
-
GhCliExecutor.execute("api", [
|
|
145
|
-
"repos/{owner}/{repo}/issues/comments/#{comment_id}",
|
|
146
|
-
"--method", "DELETE"
|
|
147
|
-
]).tap do |result|
|
|
148
|
-
raise "Failed to delete sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def self.comment_api_id(comment)
|
|
153
|
-
database_id = comment["databaseId"] || comment[:databaseId]
|
|
154
|
-
return database_id.to_s if database_id.to_s.match?(/\A\d+\z/)
|
|
155
|
-
|
|
156
|
-
url = comment["url"] || comment[:url]
|
|
157
|
-
return Regexp.last_match(1) if url.to_s.match(/issuecomment-(\d+)/)
|
|
158
|
-
|
|
159
|
-
(comment["id"] || comment[:id]).to_s
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def self.ensure_label(issue_id, labels)
|
|
163
|
-
return if labels.any? { |label| label["name"] == TRACKED_LABEL }
|
|
164
|
-
|
|
165
|
-
result = GhCliExecutor.execute("issue", ["edit", issue_id.to_s, "--add-label", TRACKED_LABEL])
|
|
166
|
-
raise "Failed to add #{TRACKED_LABEL} label to issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def self.remove_label(issue_id, labels)
|
|
170
|
-
return unless labels.any? { |label| label["name"] == TRACKED_LABEL }
|
|
171
|
-
|
|
172
|
-
result = GhCliExecutor.execute("issue", ["edit", issue_id.to_s, "--remove-label", TRACKED_LABEL])
|
|
173
|
-
raise "Failed to remove #{TRACKED_LABEL} label from issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def self.validate_link!(issue_id:, task_id:, previous_task_id: nil, lines: nil)
|
|
177
|
-
issue = fetch_issue(issue_id)
|
|
178
|
-
sticky = find_sticky_comment(issue["comments"] || [])
|
|
179
|
-
lines ||= tracked_lines(sticky&.dig("body"))
|
|
180
|
-
owners = lines.map { |line| extract_task_id(line) }.compact.uniq
|
|
181
|
-
return true if owners.empty?
|
|
182
|
-
return true if owners == [task_id.to_s]
|
|
183
|
-
return true if previous_task_id && owners == [previous_task_id.to_s]
|
|
184
|
-
|
|
185
|
-
raise OwnershipConflict, "GitHub issue ##{issue_id} is already owned by task #{owners.first}"
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def self.sync_lifecycle(issue_id:, issue_state:, task_status:)
|
|
189
|
-
terminal = TERMINAL_STATUSES.include?(task_status.to_s.downcase)
|
|
190
|
-
issue_open = issue_state.to_s.upcase == "OPEN"
|
|
191
|
-
|
|
192
|
-
if terminal && issue_open
|
|
193
|
-
close_result = GhCliExecutor.execute("issue", ["close", issue_id.to_s])
|
|
194
|
-
raise "Failed to close issue #{issue_id}: #{close_result[:stderr]}" unless close_result[:success]
|
|
195
|
-
elsif !terminal && !issue_open
|
|
196
|
-
reopen_result = GhCliExecutor.execute("issue", ["reopen", issue_id.to_s])
|
|
197
|
-
raise "Failed to reopen issue #{issue_id}: #{reopen_result[:stderr]}" unless reopen_result[:success]
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def self.task_url(task_path)
|
|
202
|
-
return task_path.to_s if task_path.to_s.empty?
|
|
203
|
-
|
|
204
|
-
repo_root = repo_root_path
|
|
205
|
-
relative = if repo_root && task_path.start_with?(repo_root + "/")
|
|
206
|
-
task_path.delete_prefix(repo_root + "/")
|
|
207
|
-
else
|
|
208
|
-
task_path.to_s
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
repo_slug = github_repo_slug
|
|
212
|
-
return relative unless repo_slug
|
|
213
|
-
|
|
214
|
-
"https://github.com/#{repo_slug}/blob/HEAD/#{relative}"
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def self.repo_root_path
|
|
218
|
-
stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
|
|
219
|
-
return nil unless status.success?
|
|
220
|
-
|
|
221
|
-
stdout.strip
|
|
222
|
-
rescue
|
|
223
|
-
nil
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def self.github_repo_slug
|
|
227
|
-
stdout, _stderr, status = Open3.capture3("git", "remote", "get-url", "origin")
|
|
228
|
-
return nil unless status.success?
|
|
229
|
-
|
|
230
|
-
url = stdout.strip
|
|
231
|
-
return Regexp.last_match(1) if url.match(%r{github\.com[:/](.+?)(?:\.git)?$})
|
|
232
|
-
|
|
233
|
-
nil
|
|
234
|
-
rescue
|
|
235
|
-
nil
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
private_class_method :sync_issue, :fetch_issue, :find_sticky_comment, :tracked_lines, :remove_previous_line,
|
|
239
|
-
:upsert_line, :tracked_line, :extract_task_id, :render_body, :create_comment, :update_comment, :delete_comment,
|
|
240
|
-
:comment_api_id, :ensure_label, :remove_label, :cleanup_tracking_artifacts, :sync_lifecycle, :task_url, :repo_root_path,
|
|
241
|
-
:github_repo_slug
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Backward-compatible alias for early integration experiments.
|
|
245
|
-
IssueSync = GithubIssueSync
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
end
|
|
@@ -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
|