ace-git 0.23.0 → 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.
@@ -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