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,88 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ace
4
- module Git
5
- module Atoms
6
- # Parse PR identifiers into structured format
7
- #
8
- # Supports three formats:
9
- # - Simple number: "123"
10
- # - Qualified reference: "owner/repo#456"
11
- # - GitHub URL: "https://github.com/owner/repo/pull/789"
12
- #
13
- # Consolidated from ace-bundle PrIdentifierParser
14
- module PrIdentifierParser
15
- # Parsed PR identifier result
16
- ParseResult = Data.define(:number, :repo, :gh_format)
17
-
18
- # Parse a PR identifier string
19
- #
20
- # Returns nil for nil/empty input (no PR specified), raises ArgumentError for
21
- # invalid formats. This design allows callers to distinguish between "no PR"
22
- # (nil input -> nil result) and "invalid PR" (bad format -> exception).
23
- #
24
- # @param input [String, Integer] PR identifier
25
- # @return [ParseResult, nil] Parsed identifier with number, repo, and gh_format,
26
- # or nil if input is nil/empty
27
- # @raise [ArgumentError] if identifier format is invalid (non-empty but malformed)
28
- #
29
- # @example Simple number
30
- # parse(123)
31
- # # => ParseResult(number: "123", repo: nil, gh_format: "123")
32
- #
33
- # @example Qualified reference
34
- # parse("owner/repo#456")
35
- # # => ParseResult(number: "456", repo: "owner/repo", gh_format: "owner/repo#456")
36
- #
37
- # @example GitHub URL
38
- # parse("https://github.com/owner/repo/pull/789")
39
- # # => ParseResult(number: "789", repo: "owner/repo", gh_format: "owner/repo#789")
40
- #
41
- # @example Nil/empty input
42
- # parse(nil) # => nil
43
- # parse("") # => nil
44
- # parse(" ") # => nil
45
- # Maximum length for PR identifier to prevent ReDoS attacks
46
- # GitHub URLs are typically under 200 chars, this provides generous margin
47
- MAX_IDENTIFIER_LENGTH = 256
48
-
49
- def self.parse(input)
50
- return nil if input.nil?
51
-
52
- input_str = input.to_s.strip
53
- return nil if input_str.empty?
54
-
55
- # Validate length to prevent ReDoS attacks on regex patterns
56
- if input_str.length > MAX_IDENTIFIER_LENGTH
57
- raise ArgumentError, "PR identifier too long (max #{MAX_IDENTIFIER_LENGTH} characters)"
58
- end
59
-
60
- case input_str
61
- when /^(\d+)$/
62
- # Simple PR number: "123"
63
- number = ::Regexp.last_match(1)
64
- # Reject zero: GitHub PR numbers are positive integers starting from 1
65
- raise ArgumentError, "Invalid PR identifier format: #{input_str}" if number.to_i.zero?
66
- # Normalize to canonical form (strip leading zeros) for consistent gh_format
67
- canonical_number = number.to_i.to_s
68
- ParseResult.new(number: canonical_number, repo: nil, gh_format: canonical_number)
69
-
70
- when /^(?<repo>[a-zA-Z0-9_\-.]+\/[a-zA-Z0-9_\-.]+)#(?<number>\d+)$/
71
- # Qualified reference: "owner/repo#456"
72
- # GitHub owner/repo names: alphanumeric, hyphens, underscores, dots only
73
- match = ::Regexp.last_match
74
- ParseResult.new(number: match[:number], repo: match[:repo], gh_format: "#{match[:repo]}##{match[:number]}")
75
-
76
- when %r{github\.com/(?<repo>[^/]+/[^/]+)/pull/(?<number>\d+)}
77
- # GitHub URL: "https://github.com/owner/repo/pull/789"
78
- match = ::Regexp.last_match
79
- ParseResult.new(number: match[:number], repo: match[:repo], gh_format: "#{match[:repo]}##{match[:number]}")
80
-
81
- else
82
- raise ArgumentError, "Invalid PR identifier format: #{input_str}"
83
- end
84
- end
85
- end
86
- end
87
- end
88
- end
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
- require "timeout"
5
-
6
- module Ace
7
- module Git
8
- module Molecules
9
- # Safely execute gh CLI commands with timeout and structured output.
10
- class GhCliExecutor
11
- DEFAULT_GH_TIMEOUT = 30
12
- DEFAULT_SIMPLE_TIMEOUT = 10
13
-
14
- def self.execute(subcommand, args = [], timeout: nil)
15
- check_installed
16
- timeout_seconds = timeout || Ace::Git.network_timeout || DEFAULT_GH_TIMEOUT
17
- command = ["gh", subcommand] + args
18
- run_command(command, timeout_seconds)
19
- rescue Timeout::Error
20
- raise Ace::Git::TimeoutError, "gh command timed out after #{timeout_seconds} seconds"
21
- end
22
-
23
- def self.check_installed
24
- result = execute_simple("--version")
25
- raise Ace::Git::GhNotInstalledError unless result[:success]
26
-
27
- true
28
- end
29
-
30
- def self.check_authenticated
31
- result = execute_simple("auth", ["status"])
32
- raise Ace::Git::GhAuthenticationError unless result[:success]
33
-
34
- {
35
- authenticated: true,
36
- username: extract_username(result[:stderr])
37
- }
38
- end
39
-
40
- def self.execute_simple(command, args = [], timeout_seconds = nil)
41
- timeout_seconds ||= Ace::Git.network_timeout || DEFAULT_SIMPLE_TIMEOUT
42
- cmd = ["gh", command] + args
43
- run_command(cmd, timeout_seconds)
44
- rescue Timeout::Error
45
- {
46
- success: false,
47
- stdout: "",
48
- stderr: "Command timed out",
49
- exit_code: 1
50
- }
51
- end
52
-
53
- def self.extract_username(output)
54
- match = output.match(/Logged in to .+ as (\S+)/)
55
- match ? match[1] : nil
56
- end
57
-
58
- def self.run_command(command, timeout_seconds)
59
- stdout_str, stderr_str, status = Timeout.timeout(timeout_seconds) do
60
- Open3.capture3(*command)
61
- end
62
-
63
- {
64
- success: status.success?,
65
- stdout: stdout_str,
66
- stderr: stderr_str,
67
- exit_code: status.exitstatus
68
- }
69
- rescue Errno::ENOENT
70
- raise Ace::Git::GhNotInstalledError
71
- end
72
-
73
- private_class_method :execute_simple, :extract_username, :run_command
74
- end
75
- end
76
- end
77
- end
@@ -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