ace-git-github 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0e706c505528940d27bbfeb97b2cc408293f4082c4ed8bbbf5735e2db9470db1
4
+ data.tar.gz: a6e07ea07d307f870f015736c5f2d6039f4e7b480ff1824241540e0f21cb9eac
5
+ SHA512:
6
+ metadata.gz: 5c9f2355511d607ef3910972145dab113234012e6935d11e491735775a6a25c83f23c7efb798779b7324ae0f7f6bea5dd07686b1c8651c45da33103ceb284d9f
7
+ data.tar.gz: 37235ad5c2af2931d4cdf7e76177c82b572f047e0463bb7ae9a8594b52ae038f4a704bec4a6c3c4093e0f9c7b8b9bacaeb74a6a7b514eb377cb6ffe970d4674b
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to ace-git-github will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - Initial GitHub provider package implementing the forge-neutral ace-git provider contract.
15
+ - Owns all `gh` CLI invocation, output parsing, and authentication verification
16
+ (moved out of the ace-git core as part of foundation chunk F0, task 8wk.t.l1e).
17
+ - Normalized evidence translation for pull requests, issues, checks, and repository
18
+ metadata, with the shared classified failure taxonomy (no silent fallbacks).
19
+ - Shared provider-contract parity suite coverage against scripted fakes.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Michal Czyz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # ace-git-github
2
+
3
+ GitHub provider for the forge-neutral [`ace-git`](../ace-git) core.
4
+
5
+ This package owns **all** GitHub-specific behavior for ACE:
6
+
7
+ - `gh` CLI invocation (with timeout handling and structured output)
8
+ - `gh` output parsing and normalization
9
+ - `gh` authentication verification
10
+ - GitHub-specific identifier formats (including `github.com` PR URLs)
11
+
12
+ It implements the provider contract defined by the core
13
+ (`Ace::Git::Providers::Base`) and registers itself under the `:github`
14
+ provider type, so consumers resolve it through
15
+ `Ace::Git::Providers.for(server)` and read normalized evidence types
16
+ (`Ace::Git::ProviderPullRequest`, `Ace::Git::ProviderIssue`,
17
+ `Ace::Git::ProviderCheck`, `Ace::Git::ProviderRepository`).
18
+
19
+ Failures are classified with the shared taxonomy
20
+ (`ProviderCliMissingError`, `ProviderAuthenticationError`,
21
+ `ProviderUnreachableError`, `ProviderMalformedOutputError`,
22
+ `ProviderObjectNotFoundError`). There is no silent fallback.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.pattern = "test/**/*_test.rb"
9
+ t.warning = false
10
+ t.verbose = false
11
+ end
12
+
13
+ task default: :test
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Ace
7
+ module Git
8
+ module Github
9
+ # Executes the GitHub CLI (`gh`) with timeout and structured output.
10
+ #
11
+ # Owns every `gh` subprocess invocation for ACE: presence probes, auth
12
+ # probes, and arbitrary `gh` subcommands. Failures are classified with
13
+ # the shared provider taxonomy. A test runner may be injected instead of
14
+ # spawning real processes (see Providers::Base).
15
+ module CliExecutor
16
+ DEFAULT_TIMEOUT = 30
17
+ DEFAULT_SIMPLE_TIMEOUT = 10
18
+ BINARY = "gh"
19
+
20
+ class << self
21
+ # Execute a `gh` subcommand with timeout and structured output.
22
+ #
23
+ # @param subcommand [String] gh subcommand path (e.g. "pr")
24
+ # @param args [Array<String>] remaining arguments
25
+ # @param timeout [Integer, nil] seconds before the call times out
26
+ # @param runner [Proc, nil] injectable command runner for tests;
27
+ # receives kwargs (args:, timeout:, env:), returns result hash
28
+ # @return [Hash] {success:, stdout:, stderr:, exit_code:}
29
+ # @raise [Ace::Git::ProviderCliMissingError] when `gh` is missing
30
+ # @raise [Ace::Git::ProviderUnreachableError] when the call times out
31
+ def execute(subcommand, args = [], timeout: nil, runner: nil)
32
+ timeout_seconds = timeout || Ace::Git.network_timeout || DEFAULT_TIMEOUT
33
+ result = run_command([BINARY, subcommand] + args, timeout_seconds, runner)
34
+ if result == :timeout
35
+ raise Ace::Git::ProviderUnreachableError,
36
+ "gh command timed out after #{timeout_seconds} seconds: #{([subcommand] + args).join(" ")}"
37
+ end
38
+
39
+ result
40
+ end
41
+
42
+ # Probe whether the `gh` binary is installed and runnable.
43
+ #
44
+ # @param runner [Proc, nil] injectable command runner for tests
45
+ # @return [Boolean]
46
+ def installed?(runner: nil)
47
+ result = execute_simple(["--version"], runner: runner)
48
+ result != :timeout && result[:success]
49
+ rescue Ace::Git::ProviderCliMissingError
50
+ false
51
+ end
52
+
53
+ # Probe whether `gh` is authenticated for the current host.
54
+ #
55
+ # @param runner [Proc, nil] injectable command runner for tests
56
+ # @return [Boolean]
57
+ def authenticated?(runner: nil)
58
+ result = execute_simple(["auth", "status"], runner: runner)
59
+ result != :timeout && result[:success]
60
+ rescue Ace::Git::ProviderCliMissingError
61
+ false
62
+ end
63
+
64
+ # @raise [Ace::Git::ProviderCliMissingError] when `gh` is missing
65
+ def check_installed!(runner: nil)
66
+ return true if installed?(runner: runner)
67
+
68
+ raise Ace::Git::ProviderCliMissingError,
69
+ "GitHub CLI (gh) is not installed or not runnable; install the GitHub CLI and retry"
70
+ end
71
+
72
+ # @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
73
+ def check_authenticated!(runner: nil)
74
+ return true if authenticated?(runner: runner)
75
+
76
+ raise Ace::Git::ProviderAuthenticationError,
77
+ "GitHub CLI (gh) is not authenticated; run: gh auth login"
78
+ end
79
+
80
+ # Run a full argument vector against `gh`.
81
+ #
82
+ # @return [Hash] result hash, or :timeout sentinel on timeout
83
+ def run_command(command, timeout_seconds, runner = nil)
84
+ if runner
85
+ call_runner(runner, command, timeout_seconds)
86
+ else
87
+ spawn_with_timeout(command, timeout_seconds)
88
+ end
89
+ end
90
+
91
+ def execute_simple(args, timeout: nil, runner: nil)
92
+ timeout_seconds = timeout || Ace::Git.network_timeout || DEFAULT_SIMPLE_TIMEOUT
93
+ run_command([BINARY] + args, timeout_seconds, runner)
94
+ end
95
+
96
+ def call_runner(runner, command, timeout_seconds)
97
+ runner.call(args: command, timeout: timeout_seconds, env: {"LC_ALL" => "C"})
98
+ rescue StandardError => e
99
+ {success: false, stdout: "", stderr: e.message, exit_code: 1}
100
+ end
101
+
102
+ def spawn_with_timeout(command, timeout_seconds)
103
+ stdout_str, stderr_str, status = Timeout.timeout(timeout_seconds) do
104
+ Open3.capture3({"LC_ALL" => "C"}, *command)
105
+ end
106
+
107
+ {
108
+ success: status.success?,
109
+ stdout: stdout_str,
110
+ stderr: stderr_str,
111
+ exit_code: status.exitstatus
112
+ }
113
+ rescue Timeout::Error
114
+ :timeout
115
+ rescue Errno::ENOENT
116
+ raise Ace::Git::ProviderCliMissingError,
117
+ "GitHub CLI (gh) is not installed or not runnable; install the GitHub CLI and retry"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Ace
7
+ module Git
8
+ module Github
9
+ # Synchronize ACE task linkage metadata to GitHub issues.
10
+ class IssueSync
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 = CliExecutor.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
+ CliExecutor.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
+ CliExecutor.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
+ CliExecutor.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 = CliExecutor.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 = CliExecutor.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 = CliExecutor.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 = CliExecutor.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
+ end
245
+ end
246
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Git
7
+ module Github
8
+ # Fetch pull request data via the GitHub CLI (`gh`).
9
+ #
10
+ # Owns all `gh pr` invocation and output parsing for ACE. Failures are
11
+ # classified with the shared provider taxonomy; there is no fallback to
12
+ # any other transport.
13
+ module PrFetcher
14
+ # Error message patterns from the `gh` CLI
15
+ PR_NOT_FOUND_PATTERN = /not found|Could not resolve/i
16
+ AUTH_ERROR_PATTERN = /authentication|Unauthorized|not logged in|auth login/i
17
+
18
+ # Valid characters for PR identifiers (owner/repo#number format)
19
+ VALID_IDENTIFIER_PATTERN = /\A[\w\/.\-#@:]+\z/
20
+
21
+ # Fields to fetch for PR metadata
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
+ LIST_FIELDS = "number,title,state,mergedAt,author,headRefName,isDraft,baseRefName,url,headRefOid,mergeCommit"
38
+
39
+ class << self
40
+ # @return [Boolean] true when the `gh` binary is installed
41
+ def installed?(runner: nil)
42
+ CliExecutor.installed?(runner: runner)
43
+ end
44
+
45
+ # @return [Boolean] true when the `gh` CLI is authenticated
46
+ def authenticated?(runner: nil)
47
+ CliExecutor.authenticated?(runner: runner)
48
+ end
49
+
50
+ # Fetch PR diff content.
51
+ #
52
+ # @param identifier [String] PR identifier (number, URL, or owner/repo#number)
53
+ # @param timeout [Integer] Timeout in seconds
54
+ # @param runner [Proc, nil] injectable command runner for tests
55
+ # @return [Hash] Result with :success, :diff, :identifier, :source
56
+ def fetch_diff(identifier, timeout: Ace::Git.network_timeout, runner: nil)
57
+ parsed = PrIdentifier.parse(identifier)
58
+ raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
59
+
60
+ validate_identifier_characters(parsed.gh_format)
61
+
62
+ result = CliExecutor.execute("pr", ["diff", parsed.gh_format], timeout: timeout, runner: runner)
63
+
64
+ if result[:success]
65
+ {
66
+ success: true,
67
+ diff: result[:stdout],
68
+ identifier: parsed.gh_format,
69
+ source: build_source_label(parsed)
70
+ }
71
+ else
72
+ handle_error(result[:stderr], parsed.gh_format)
73
+ end
74
+ end
75
+
76
+ # Fetch PR metadata (state, draft status, title, etc.).
77
+ #
78
+ # @param identifier [String] PR identifier
79
+ # @param timeout [Integer] Timeout in seconds
80
+ # @param runner [Proc, nil] injectable command runner for tests
81
+ # @return [Hash] Result with :success, :metadata, :identifier, :parsed
82
+ def fetch_metadata(identifier, timeout: Ace::Git.network_timeout, runner: nil)
83
+ parsed = PrIdentifier.parse(identifier)
84
+ raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
85
+
86
+ validate_identifier_characters(parsed.gh_format)
87
+
88
+ result = CliExecutor.execute(
89
+ "pr", ["view", parsed.gh_format, "--json", PR_FIELDS.join(",")],
90
+ timeout: timeout, runner: runner
91
+ )
92
+
93
+ if result[:success]
94
+ metadata = JSON.parse(result[:stdout])
95
+ {
96
+ success: true,
97
+ metadata: metadata,
98
+ identifier: parsed.gh_format,
99
+ parsed: {number: parsed.number, repo: parsed.repo}
100
+ }
101
+ else
102
+ handle_error(result[:stderr], parsed.gh_format)
103
+ end
104
+ rescue JSON::ParserError => e
105
+ raise Ace::Git::ProviderMalformedOutputError, "Failed to parse PR metadata: #{e.message}"
106
+ end
107
+
108
+ # Fetch both diff and metadata.
109
+ #
110
+ # @param identifier [String] PR identifier
111
+ # @param timeout [Integer] Timeout in seconds
112
+ # @param runner [Proc, nil] injectable command runner for tests
113
+ # @return [Hash] Result with :success, :diff, :metadata, :identifier, :source
114
+ def fetch_pr(identifier, timeout: Ace::Git.network_timeout, runner: nil)
115
+ diff_result = fetch_diff(identifier, timeout: timeout, runner: runner)
116
+ return diff_result unless diff_result[:success]
117
+
118
+ metadata_result = fetch_metadata(identifier, timeout: timeout, runner: runner)
119
+ return metadata_result unless metadata_result[:success]
120
+
121
+ {
122
+ success: true,
123
+ diff: diff_result[:diff],
124
+ metadata: metadata_result[:metadata],
125
+ identifier: diff_result[:identifier],
126
+ source: diff_result[:source]
127
+ }
128
+ end
129
+
130
+ # Find PR number for the current branch.
131
+ #
132
+ # @param timeout [Integer] Timeout in seconds
133
+ # @param runner [Proc, nil] injectable command runner for tests
134
+ # @return [String, nil] PR number or nil
135
+ def find_pr_for_branch(timeout: Ace::Git.network_timeout, runner: nil)
136
+ result = CliExecutor.execute("pr", ["view", "--json", "number"], timeout: timeout, runner: runner)
137
+
138
+ return nil unless result[:success]
139
+
140
+ JSON.parse(result[:stdout])["number"]&.to_s
141
+ rescue JSON::ParserError
142
+ nil
143
+ end
144
+
145
+ # Fetch recently merged PRs.
146
+ #
147
+ # @param limit [Integer] Maximum number of PRs to return
148
+ # @param timeout [Integer] Timeout in seconds
149
+ # @param runner [Proc, nil] injectable command runner for tests
150
+ # @return [Hash] Result with :success, :prs array, or :error
151
+ def fetch_recently_merged(limit: Ace::Git.merged_prs_limit, timeout: Ace::Git.network_timeout, runner: nil)
152
+ result = CliExecutor.execute(
153
+ "pr", ["list", "--state", "merged", "--limit", limit.to_s, "--json", "number,title,mergedAt,author"],
154
+ timeout: timeout, runner: runner
155
+ )
156
+
157
+ list_result(result, parse_error_prefix: "Failed to parse merged PRs")
158
+ end
159
+
160
+ # Fetch open PRs.
161
+ #
162
+ # @param exclude_branch [String, nil] Branch name to exclude from results
163
+ # @param limit [Integer] Maximum number of PRs to return
164
+ # @param timeout [Integer] Timeout in seconds
165
+ # @param runner [Proc, nil] injectable command runner for tests
166
+ # @return [Hash] Result with :success, :prs array, or :error
167
+ def fetch_open_prs(exclude_branch: nil, limit: Ace::Git.open_prs_limit, timeout: Ace::Git.network_timeout, runner: nil)
168
+ result = CliExecutor.execute(
169
+ "pr", ["list", "--state", "open", "--limit", limit.to_s, "--json", "number,title,author,headRefName"],
170
+ timeout: timeout, runner: runner
171
+ )
172
+
173
+ parsed = list_result(result, parse_error_prefix: "Failed to parse open PRs")
174
+ return parsed unless parsed[:success] && exclude_branch
175
+
176
+ parsed.merge(prs: parsed[:prs].reject { |pr| pr["headRefName"] == exclude_branch })
177
+ end
178
+
179
+ # Fetch all recent PRs in a single call (open, merged, closed).
180
+ #
181
+ # @param limit [Integer] Maximum PRs to fetch
182
+ # @param timeout [Integer] Timeout in seconds
183
+ # @param runner [Proc, nil] injectable command runner for tests
184
+ # @return [Hash] Result with :success, :prs array, or :error
185
+ def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout, runner: nil)
186
+ result = CliExecutor.execute(
187
+ "pr", ["list", "--state", "all", "--limit", limit.to_s, "--json", LIST_FIELDS],
188
+ timeout: timeout, runner: runner
189
+ )
190
+
191
+ list_result(result, parse_error_prefix: "Failed to parse PR list")
192
+ end
193
+
194
+ private
195
+
196
+ def list_result(result, parse_error_prefix:)
197
+ if result[:success]
198
+ begin
199
+ {success: true, prs: JSON.parse(result[:stdout])}
200
+ rescue JSON::ParserError => e
201
+ raise Ace::Git::ProviderMalformedOutputError, "#{parse_error_prefix}: #{e.message}"
202
+ end
203
+ else
204
+ {success: false, error: result[:stderr], prs: []}
205
+ end
206
+ end
207
+
208
+ def build_source_label(parsed)
209
+ if parsed.repo
210
+ "pr:#{parsed.repo}##{parsed.number}"
211
+ else
212
+ "pr:#{parsed.number}"
213
+ end
214
+ end
215
+
216
+ # Validate identifier characters to prevent shell metacharacter injection
217
+ def validate_identifier_characters(identifier)
218
+ return if identifier.nil? || identifier.empty?
219
+
220
+ unless identifier.match?(VALID_IDENTIFIER_PATTERN)
221
+ raise ArgumentError, "Invalid identifier characters: #{identifier}"
222
+ end
223
+ end
224
+
225
+ def handle_error(error_message, identifier)
226
+ error_str = error_message.to_s
227
+
228
+ if error_str.match?(PR_NOT_FOUND_PATTERN)
229
+ raise Ace::Git::ProviderObjectNotFoundError, "PR not found: #{identifier}"
230
+ elsif error_str.match?(AUTH_ERROR_PATTERN)
231
+ raise Ace::Git::ProviderAuthenticationError, "Not authenticated with GitHub. Run: gh auth login"
232
+ else
233
+ {
234
+ success: false,
235
+ error: "gh pr command failed: #{error_str}"
236
+ }
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Github
6
+ # Parse GitHub pull request identifiers into structured form.
7
+ #
8
+ # Supports the GitHub-specific formats:
9
+ # - Simple number: "123"
10
+ # - Qualified reference: "owner/repo#456"
11
+ # - GitHub URL: "https://github.com/owner/repo/pull/789"
12
+ module PrIdentifier
13
+ # Parsed PR identifier result. `gh_format` is the form accepted by
14
+ # `gh pr` commands.
15
+ ParseResult = Data.define(:number, :repo, :gh_format)
16
+
17
+ # Maximum length for PR identifier to bound regex work
18
+ MAX_IDENTIFIER_LENGTH = 256
19
+
20
+ def self.parse(input)
21
+ return nil if input.nil?
22
+
23
+ input_str = input.to_s.strip
24
+ return nil if input_str.empty?
25
+
26
+ if input_str.length > MAX_IDENTIFIER_LENGTH
27
+ raise ArgumentError, "PR identifier too long (max #{MAX_IDENTIFIER_LENGTH} characters)"
28
+ end
29
+
30
+ case input_str
31
+ when /\A(\d+)\z/
32
+ number = ::Regexp.last_match(1)
33
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}" if number.to_i.zero?
34
+
35
+ canonical_number = number.to_i.to_s
36
+ ParseResult.new(number: canonical_number, repo: nil, gh_format: canonical_number)
37
+ when /\A(?<repo>[a-zA-Z0-9_\-.]+\/[a-zA-Z0-9_\-.]+)#(?<number>\d+)\z/
38
+ match = ::Regexp.last_match
39
+ ParseResult.new(number: match[:number], repo: match[:repo], gh_format: "#{match[:repo]}##{match[:number]}")
40
+ when %r{\Ahttps://github\.com/(?<repo>[^/]+/[^/]+)/pull/(?<number>\d+)}
41
+ match = ::Regexp.last_match
42
+ ParseResult.new(number: match[:number], repo: match[:repo], gh_format: "#{match[:repo]}##{match[:number]}")
43
+ else
44
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Git
7
+ module Github
8
+ # GitHub provider implementation of the shared provider contract.
9
+ #
10
+ # Owns GitHub terminology and translates `gh` responses into the
11
+ # normalized evidence types defined by the ace-git core. Every failure is
12
+ # classified with the shared taxonomy; nothing falls back silently.
13
+ class Provider < Ace::Git::Providers::Base
14
+ STATE_MAP = {
15
+ "OPEN" => :open,
16
+ "MERGED" => :merged,
17
+ "CLOSED" => :closed
18
+ }.freeze
19
+
20
+ # `gh` check bucket vocabulary mapped onto neutral conclusions
21
+ BUCKET_MAP = {
22
+ "pass" => :success,
23
+ "fail" => :failure,
24
+ "pending" => :pending,
25
+ "skipping" => :skipped
26
+ }.freeze
27
+
28
+ PR_FIELDS = "number,state,isDraft,title,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt"
29
+ LIST_FIELDS = PrFetcher::LIST_FIELDS
30
+ REPO_FIELDS = "nameWithOwner,defaultBranchRef,url"
31
+ ISSUE_FIELDS = "number,title,state,author,url"
32
+
33
+ class << self
34
+ # Human-facing provider type name used in failure messages.
35
+ def display_name
36
+ "GitHub"
37
+ end
38
+ end
39
+
40
+ # @return [Boolean] true when the `gh` binary is installed
41
+ def available?
42
+ PrFetcher.installed?(runner: runner)
43
+ end
44
+
45
+ # @raise [Ace::Git::ProviderCliMissingError] when `gh` is missing
46
+ def check_available!
47
+ CliExecutor.check_installed!(runner: runner)
48
+ end
49
+
50
+ # @return [Boolean] true when `gh` is authenticated
51
+ def authenticated?
52
+ PrFetcher.authenticated?(runner: runner)
53
+ end
54
+
55
+ # @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
56
+ def check_authenticated!
57
+ CliExecutor.check_authenticated!(runner: runner)
58
+ end
59
+
60
+ # @return [ProviderPullRequest] normalized pull request evidence
61
+ # @raise [ProviderObjectNotFoundError] when the PR does not exist
62
+ # @raise [ProviderMalformedOutputError] when `gh` returns invalid JSON
63
+ # @raise [ProviderUnreachableError] when the endpoint is unreachable
64
+ def pull_request(number:)
65
+ data = gh_json(["pr", "view", number.to_s, "--json", PR_FIELDS])
66
+ normalize_pr(data, server.name)
67
+ end
68
+
69
+ # @return [ProviderPullRequest, nil] evidence for the branch's PR
70
+ def pull_request_for_branch(branch:)
71
+ candidates = recent_pull_requests(limit: 30)
72
+ branch_prs = candidates.select { |pr| pr.head_ref == branch }
73
+ return nil if branch_prs.empty?
74
+
75
+ branch_prs.min_by { |pr| STATE_ORDER.fetch(pr.state, 3) }
76
+ end
77
+
78
+ # @return [String] unified diff text for the pull request
79
+ def pull_request_diff(number:)
80
+ result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner)
81
+ result[:diff]
82
+ end
83
+
84
+ # @return [Array<ProviderPullRequest>] recent PRs, newest first
85
+ def recent_pull_requests(limit:)
86
+ result = PrFetcher.fetch_all_prs(limit: limit, timeout: timeout, runner: runner)
87
+ result[:prs]
88
+ .map { |pr| normalize_pr(pr, server.name) }
89
+ .sort_by { |pr| -pr.number.to_i }
90
+ end
91
+
92
+ # @return [ProviderIssue] normalized issue evidence
93
+ def issue(number:)
94
+ output = gh_json(["issue", "view", number.to_s, "--json", ISSUE_FIELDS])
95
+ normalize_issue(output)
96
+ end
97
+
98
+ # @return [Array<ProviderCheck>] normalized check evidence for a ref
99
+ def checks(ref:)
100
+ output = gh_json(["pr", "checks", ref.to_s, "--json", "name,state,bucket"])
101
+ output.map do |check|
102
+ bucket = check["bucket"].to_s.downcase
103
+ Ace::Git::ProviderCheck.new(
104
+ server_name: server.name,
105
+ name: check["name"].to_s,
106
+ state: check["state"].to_s.downcase.to_sym,
107
+ conclusion: BUCKET_MAP.fetch(bucket, bucket.empty? ? nil : bucket.to_sym),
108
+ url: nil
109
+ )
110
+ end
111
+ end
112
+
113
+ # @return [ProviderRepository] normalized repository evidence
114
+ def repository
115
+ output = gh_json(["repo", "view", "--json", REPO_FIELDS])
116
+ Ace::Git::ProviderRepository.new(
117
+ server_name: server.name,
118
+ full_name: output["nameWithOwner"],
119
+ default_branch: output.dig("defaultBranchRef", "name"),
120
+ url: output["url"]
121
+ )
122
+ end
123
+
124
+ private
125
+
126
+ STATE_ORDER = {open: 0, merged: 1, closed: 2}.freeze
127
+
128
+ # Run a `gh` command expecting JSON output; classify all failures.
129
+ def gh_json(args)
130
+ result = CliExecutor.execute(args.first, args[1..] || [], timeout: timeout, runner: runner)
131
+ unless result[:success]
132
+ classify_failure(result[:stderr], context: args.join(" "))
133
+ end
134
+
135
+ begin
136
+ JSON.parse(result[:stdout])
137
+ rescue JSON::ParserError => e
138
+ raise Ace::Git::ProviderMalformedOutputError,
139
+ "Malformed JSON from gh (#{args.join(" ")}): #{e.message}"
140
+ end
141
+ end
142
+
143
+ def normalize_pr(data, server_name)
144
+ Ace::Git::ProviderPullRequest.new(
145
+ server_name: server_name,
146
+ number: data["number"],
147
+ title: data["title"],
148
+ state: STATE_MAP.fetch(data["state"].to_s.upcase, data["state"].to_s.downcase.to_sym),
149
+ head_ref: data["headRefName"],
150
+ base_ref: data["baseRefName"],
151
+ head_sha: data["headRefOid"],
152
+ author: normalize_author(data["author"]),
153
+ url: data["url"],
154
+ draft: data["isDraft"],
155
+ merged_at: merged_at_of(data)
156
+ )
157
+ end
158
+
159
+ def merged_at_of(data)
160
+ data["mergedAt"]
161
+ end
162
+
163
+ def normalize_author(author)
164
+ return author["login"] if author.is_a?(Hash)
165
+
166
+ author
167
+ end
168
+
169
+ def normalize_issue(data)
170
+ Ace::Git::ProviderIssue.new(
171
+ server_name: server.name,
172
+ number: data["number"],
173
+ title: data["title"],
174
+ state: data["state"].to_s.upcase == "OPEN" ? :open : :closed,
175
+ author: normalize_author(data["author"]),
176
+ url: data["url"],
177
+ labels: nil
178
+ )
179
+ end
180
+
181
+ def classify_failure(stderr, context:)
182
+ message = stderr.to_s
183
+ if message.match?(PrFetcher::PR_NOT_FOUND_PATTERN)
184
+ raise Ace::Git::ProviderObjectNotFoundError, "Object not found: #{context}: #{message}"
185
+ elsif message.match?(PrFetcher::AUTH_ERROR_PATTERN)
186
+ raise Ace::Git::ProviderAuthenticationError, "Not authenticated with GitHub: #{message}"
187
+ else
188
+ raise Ace::Git::ProviderUnreachableError, "GitHub request failed (#{context}): #{message}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Github
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ require_relative "github/version"
6
+ require_relative "github/cli_executor"
7
+ require_relative "github/pr_identifier"
8
+ require_relative "github/pr_fetcher"
9
+ require_relative "github/issue_sync"
10
+ require_relative "github/provider"
11
+
12
+ module Ace
13
+ module Git
14
+ # GitHub provider package for the forge-neutral ace-git core.
15
+ #
16
+ # Owns all GitHub-specific behavior: `gh` CLI invocation, output parsing,
17
+ # authentication verification, and GitHub terminology. Implements the
18
+ # shared provider contract exposed by the core and registers itself in the
19
+ # core provider registry.
20
+ module Github
21
+ PROVIDER_TYPE = :github
22
+ end
23
+ end
24
+ end
25
+
26
+ Ace::Git::Providers.register(Ace::Git::Github::PROVIDER_TYPE, Ace::Git::Github::Provider)
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-git-github
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-09-23 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ace-git
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.24'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.24'
26
+ description: 'Implements the shared ace-git provider contract for GitHub: all `gh`
27
+ CLI invocation, output parsing, authentication verification, and classified failure
28
+ handling live behind one normalized evidence boundary.'
29
+ email:
30
+ - mc@cs3b.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - Rakefile
39
+ - lib/ace/git/github.rb
40
+ - lib/ace/git/github/cli_executor.rb
41
+ - lib/ace/git/github/issue_sync.rb
42
+ - lib/ace/git/github/pr_fetcher.rb
43
+ - lib/ace/git/github/pr_identifier.rb
44
+ - lib/ace/git/github/provider.rb
45
+ - lib/ace/git/github/version.rb
46
+ homepage: https://forgejo.tail6c0887.ts.net/cs3b/ace
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ allowed_push_host: https://rubygems.org
51
+ homepage_uri: https://forgejo.tail6c0887.ts.net/cs3b/ace
52
+ source_code_uri: https://forgejo.tail6c0887.ts.net/cs3b/ace/src/branch/main/ace-git-github
53
+ changelog_uri: https://forgejo.tail6c0887.ts.net/cs3b/ace/src/branch/main/ace-git-github/CHANGELOG.md
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.2.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.6.9
69
+ specification_version: 4
70
+ summary: GitHub provider for the forge-neutral ace-git core
71
+ test_files: []