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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +22 -0
- data/Rakefile +13 -0
- data/lib/ace/git/github/cli_executor.rb +123 -0
- data/lib/ace/git/github/issue_sync.rb +246 -0
- data/lib/ace/git/github/pr_fetcher.rb +243 -0
- data/lib/ace/git/github/pr_identifier.rb +50 -0
- data/lib/ace/git/github/provider.rb +194 -0
- data/lib/ace/git/github/version.rb +9 -0
- data/lib/ace/git/github.rb +26 -0
- metadata +71 -0
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,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,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: []
|