ace-review 0.49.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/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
- data/.ace-defaults/review/config.yml +79 -0
- data/.ace-defaults/review/presets/code-fit.yml +64 -0
- data/.ace-defaults/review/presets/code-shine.yml +44 -0
- data/.ace-defaults/review/presets/code-valid.yml +39 -0
- data/.ace-defaults/review/presets/docs.yml +42 -0
- data/.ace-defaults/review/presets/spec.yml +37 -0
- data/CHANGELOG.md +1780 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-review +27 -0
- data/exe/ace-review-feedback +17 -0
- data/handbook/guides/code-review-process.g.md +234 -0
- data/handbook/prompts/base/sections.md +23 -0
- data/handbook/prompts/base/system.md +60 -0
- data/handbook/prompts/focus/architecture/atom.md +30 -0
- data/handbook/prompts/focus/architecture/reflection.md +60 -0
- data/handbook/prompts/focus/frameworks/rails.md +40 -0
- data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
- data/handbook/prompts/focus/languages/ruby.md +50 -0
- data/handbook/prompts/focus/phase/correctness.md +51 -0
- data/handbook/prompts/focus/phase/polish.md +43 -0
- data/handbook/prompts/focus/phase/quality.md +42 -0
- data/handbook/prompts/focus/quality/performance.md +48 -0
- data/handbook/prompts/focus/quality/security.md +47 -0
- data/handbook/prompts/focus/scope/docs.md +38 -0
- data/handbook/prompts/focus/scope/spec.md +58 -0
- data/handbook/prompts/focus/scope/tests.md +36 -0
- data/handbook/prompts/format/compact.md +12 -0
- data/handbook/prompts/format/detailed.md +39 -0
- data/handbook/prompts/format/standard.md +16 -0
- data/handbook/prompts/guidelines/icons.md +19 -0
- data/handbook/prompts/guidelines/tone.md +21 -0
- data/handbook/prompts/synthesis-review-reports.system.md +318 -0
- data/handbook/prompts/synthesize-feedback.system.md +147 -0
- data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
- data/handbook/skills/as-review-package/SKILL.md +36 -0
- data/handbook/skills/as-review-pr/SKILL.md +38 -0
- data/handbook/skills/as-review-run/SKILL.md +30 -0
- data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
- data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
- data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
- data/handbook/workflow-instructions/review/package.wf.md +16 -0
- data/handbook/workflow-instructions/review/pr.wf.md +284 -0
- data/handbook/workflow-instructions/review/run.wf.md +262 -0
- data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
- data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
- data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
- data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
- data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
- data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
- data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
- data/lib/ace/review/atoms/preset_validator.rb +103 -0
- data/lib/ace/review/atoms/priority_filter.rb +115 -0
- data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
- data/lib/ace/review/atoms/slug_generator.rb +50 -0
- data/lib/ace/review/atoms/token_estimator.rb +86 -0
- data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
- data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
- data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
- data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
- data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
- data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
- data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
- data/lib/ace/review/cli/commands/feedback.rb +79 -0
- data/lib/ace/review/cli/commands/review.rb +378 -0
- data/lib/ace/review/cli/feedback_cli.rb +71 -0
- data/lib/ace/review/cli.rb +103 -0
- data/lib/ace/review/errors.rb +146 -0
- data/lib/ace/review/models/feedback_item.rb +216 -0
- data/lib/ace/review/models/review_options.rb +208 -0
- data/lib/ace/review/models/reviewer.rb +181 -0
- data/lib/ace/review/molecules/context_composer.rb +123 -0
- data/lib/ace/review/molecules/context_extractor.rb +159 -0
- data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
- data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
- data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
- data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
- data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
- data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
- data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
- data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
- data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
- data/lib/ace/review/molecules/llm_executor.rb +142 -0
- data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
- data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
- data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
- data/lib/ace/review/molecules/preset_manager.rb +494 -0
- data/lib/ace/review/molecules/prompt_composer.rb +76 -0
- data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
- data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
- data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
- data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
- data/lib/ace/review/molecules/subject_extractor.rb +315 -0
- data/lib/ace/review/molecules/subject_filter.rb +199 -0
- data/lib/ace/review/molecules/subject_strategy.rb +96 -0
- data/lib/ace/review/molecules/task_report_saver.rb +161 -0
- data/lib/ace/review/molecules/task_resolver.rb +48 -0
- data/lib/ace/review/organisms/feedback_manager.rb +386 -0
- data/lib/ace/review/organisms/review_manager.rb +1059 -0
- data/lib/ace/review/version.rb +7 -0
- data/lib/ace/review.rb +135 -0
- metadata +351 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Review
|
|
8
|
+
module Molecules
|
|
9
|
+
# Safely execute gh CLI commands with error handling
|
|
10
|
+
class GhCliExecutor
|
|
11
|
+
# Default timeout for gh CLI operations
|
|
12
|
+
DEFAULT_GH_TIMEOUT = 30
|
|
13
|
+
|
|
14
|
+
# Execute a gh CLI command
|
|
15
|
+
#
|
|
16
|
+
# @param subcommand [String] The gh subcommand (e.g., "pr", "api")
|
|
17
|
+
# @param args [Array<String>] Arguments to pass to the subcommand
|
|
18
|
+
# @param options [Hash] Additional options
|
|
19
|
+
# @option options [Integer] :timeout Timeout in seconds (default: from config or 30)
|
|
20
|
+
# @return [Hash] Result with :success, :stdout, :stderr, :exit_code
|
|
21
|
+
def self.execute(subcommand, args = [], options = {})
|
|
22
|
+
check_installed
|
|
23
|
+
|
|
24
|
+
timeout_seconds = options[:timeout] ||
|
|
25
|
+
Ace::Review.get("defaults", "gh_timeout") ||
|
|
26
|
+
DEFAULT_GH_TIMEOUT
|
|
27
|
+
command = ["gh", subcommand] + args
|
|
28
|
+
|
|
29
|
+
run_command(command, timeout_seconds)
|
|
30
|
+
rescue Timeout::Error
|
|
31
|
+
raise Ace::Review::Errors::GhNetworkError, "gh command timed out after #{timeout_seconds} seconds"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if gh CLI is installed
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] true if installed
|
|
37
|
+
# @raise [GhCliNotInstalledError] if not installed
|
|
38
|
+
def self.check_installed
|
|
39
|
+
result = execute_simple("--version")
|
|
40
|
+
result[:success]
|
|
41
|
+
rescue Ace::Review::Errors::GhCliNotInstalledError
|
|
42
|
+
raise
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if user is authenticated with GitHub
|
|
46
|
+
#
|
|
47
|
+
# @return [Hash] Auth status with :authenticated, :username
|
|
48
|
+
# @raise [GhAuthenticationError] if not authenticated
|
|
49
|
+
def self.check_authenticated
|
|
50
|
+
result = execute_simple("auth", ["status"])
|
|
51
|
+
|
|
52
|
+
if result[:success]
|
|
53
|
+
# Extract username from stderr (gh auth status outputs to stderr)
|
|
54
|
+
username = extract_username(result[:stderr])
|
|
55
|
+
{
|
|
56
|
+
authenticated: true,
|
|
57
|
+
username: username
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
raise Ace::Review::Errors::GhAuthenticationError
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Default timeout for simple operations
|
|
65
|
+
DEFAULT_SIMPLE_TIMEOUT = 10
|
|
66
|
+
|
|
67
|
+
# Execute a simple gh command without error checking
|
|
68
|
+
# Used internally to avoid infinite recursion in check_installed
|
|
69
|
+
#
|
|
70
|
+
# @param command [String] The gh subcommand
|
|
71
|
+
# @param args [Array<String>] Arguments
|
|
72
|
+
# @param timeout_seconds [Integer] Timeout in seconds (default: from config or 10)
|
|
73
|
+
# @return [Hash] Result hash
|
|
74
|
+
def self.execute_simple(command, args = [], timeout_seconds = nil)
|
|
75
|
+
timeout_seconds ||= Ace::Review.get("defaults", "gh_simple_timeout") || DEFAULT_SIMPLE_TIMEOUT
|
|
76
|
+
cmd = ["gh", command] + args
|
|
77
|
+
run_command(cmd, timeout_seconds)
|
|
78
|
+
rescue Timeout::Error
|
|
79
|
+
{
|
|
80
|
+
success: false,
|
|
81
|
+
stdout: "",
|
|
82
|
+
stderr: "Command timed out",
|
|
83
|
+
exit_code: 1
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Extract username from gh auth status output
|
|
88
|
+
#
|
|
89
|
+
# @param output [String] Output from gh auth status
|
|
90
|
+
# @return [String, nil] Username if found
|
|
91
|
+
def self.extract_username(output)
|
|
92
|
+
# gh auth status output format: "✓ Logged in to github.com as username ..."
|
|
93
|
+
match = output.match(/Logged in to .+ as (\S+)/)
|
|
94
|
+
match ? match[1] : nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Execute a command with timeout and error handling
|
|
98
|
+
# Private helper to reduce duplication between execute and execute_simple
|
|
99
|
+
#
|
|
100
|
+
# @param command [Array<String>] Full command array including "gh"
|
|
101
|
+
# @param timeout_seconds [Integer] Timeout in seconds
|
|
102
|
+
# @return [Hash] Result with :success, :stdout, :stderr, :exit_code
|
|
103
|
+
# @raise [GhCliNotInstalledError] if gh is not installed
|
|
104
|
+
# @raise [Timeout::Error] if command times out (caller should handle)
|
|
105
|
+
def self.run_command(command, timeout_seconds)
|
|
106
|
+
stdout_str, stderr_str, status = Timeout.timeout(timeout_seconds) do
|
|
107
|
+
Open3.capture3(*command)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
success: status.success?,
|
|
112
|
+
stdout: stdout_str,
|
|
113
|
+
stderr: stderr_str,
|
|
114
|
+
exit_code: status.exitstatus
|
|
115
|
+
}
|
|
116
|
+
rescue Errno::ENOENT
|
|
117
|
+
raise Ace::Review::Errors::GhCliNotInstalledError
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private_class_method :execute_simple, :extract_username, :run_command
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Review
|
|
7
|
+
module Molecules
|
|
8
|
+
# Post review comments to GitHub PR
|
|
9
|
+
class GhCommentPoster
|
|
10
|
+
# Post a review comment to a PR
|
|
11
|
+
#
|
|
12
|
+
# @param pr_identifier [String] PR identifier
|
|
13
|
+
# @param review_text [String] Review content
|
|
14
|
+
# @param options [Hash] Posting options
|
|
15
|
+
# @option options [Boolean] :dry_run Don't actually post (default: false)
|
|
16
|
+
# @option options [Hash] :metadata Review metadata (preset, model, timestamp)
|
|
17
|
+
# @return [Hash] Result with :success, :comment_url, :preview, :error
|
|
18
|
+
def self.post_comment(pr_identifier, review_text, options = {})
|
|
19
|
+
# Parse identifier using ace-git
|
|
20
|
+
parsed = Ace::Git::Atoms::PrIdentifierParser.parse(pr_identifier)
|
|
21
|
+
gh_format = parsed.gh_format
|
|
22
|
+
|
|
23
|
+
# Check PR state before posting
|
|
24
|
+
state_check = check_pr_state(gh_format)
|
|
25
|
+
return state_check unless state_check[:success]
|
|
26
|
+
|
|
27
|
+
# Format comment with metadata
|
|
28
|
+
formatted_comment = format_review_comment(review_text, options[:metadata] || {})
|
|
29
|
+
|
|
30
|
+
# Handle dry run
|
|
31
|
+
if options[:dry_run]
|
|
32
|
+
return {
|
|
33
|
+
success: true,
|
|
34
|
+
dry_run: true,
|
|
35
|
+
preview: formatted_comment,
|
|
36
|
+
pr_identifier: gh_format
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Post comment via gh CLI
|
|
41
|
+
result = post_via_gh(gh_format, formatted_comment)
|
|
42
|
+
|
|
43
|
+
if result[:success]
|
|
44
|
+
comment_url = extract_comment_url(result[:stdout], parsed.to_h)
|
|
45
|
+
{
|
|
46
|
+
success: true,
|
|
47
|
+
comment_url: comment_url,
|
|
48
|
+
pr_identifier: gh_format
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
{
|
|
52
|
+
success: false,
|
|
53
|
+
error: "Failed to post comment: #{result[:stderr]}"
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
|
|
57
|
+
raise
|
|
58
|
+
rescue => e
|
|
59
|
+
{
|
|
60
|
+
success: false,
|
|
61
|
+
error: "Failed to post comment: #{e.message}"
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if PR is in a state that allows comments
|
|
66
|
+
#
|
|
67
|
+
# @param gh_format [String] PR identifier in gh format
|
|
68
|
+
# @return [Hash] Result with :success or :error
|
|
69
|
+
def self.check_pr_state(gh_format)
|
|
70
|
+
# Fetch PR metadata
|
|
71
|
+
result = Ace::Review::Molecules::GhCliExecutor.execute(
|
|
72
|
+
"pr",
|
|
73
|
+
["view", gh_format, "--json", "state,number"]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
unless result[:success]
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
error: "Failed to check PR state: #{result[:stderr]}"
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
metadata = JSON.parse(result[:stdout])
|
|
84
|
+
state = metadata["state"]
|
|
85
|
+
number = metadata["number"]
|
|
86
|
+
|
|
87
|
+
# Check if state allows posting
|
|
88
|
+
unless state == "OPEN"
|
|
89
|
+
raise Ace::Review::Errors::PrStateError.new(number, state.downcase)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
{success: true}
|
|
93
|
+
rescue JSON::ParserError => e
|
|
94
|
+
{
|
|
95
|
+
success: false,
|
|
96
|
+
error: "Failed to parse PR state: #{e.message}"
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Format review comment with metadata header
|
|
101
|
+
#
|
|
102
|
+
# @param review_text [String] Raw review content
|
|
103
|
+
# @param metadata [Hash] Review metadata
|
|
104
|
+
# @option metadata [String] :preset Review preset name
|
|
105
|
+
# @option metadata [String] :model LLM model used
|
|
106
|
+
# @option metadata [String] :timestamp Generation timestamp
|
|
107
|
+
# @return [String] Formatted comment
|
|
108
|
+
def self.format_review_comment(review_text, metadata = {})
|
|
109
|
+
header = "## Code Review - ace-review\n\n"
|
|
110
|
+
|
|
111
|
+
if metadata[:preset]
|
|
112
|
+
header += "**Preset**: #{metadata[:preset]}\n"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if metadata[:model]
|
|
116
|
+
header += "**Model**: #{metadata[:model]}\n"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if metadata[:timestamp]
|
|
120
|
+
header += "**Generated**: #{metadata[:timestamp]}\n"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
header += "\n" unless metadata.empty?
|
|
124
|
+
|
|
125
|
+
# Sanitize review text (escape any problematic characters)
|
|
126
|
+
sanitized_review = sanitize_markdown(review_text)
|
|
127
|
+
|
|
128
|
+
header + sanitized_review
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Sanitize markdown content to prevent formatting issues
|
|
132
|
+
#
|
|
133
|
+
# Ensures code fences are properly closed and wraps content in a
|
|
134
|
+
# collapsible details tag to contain any formatting issues.
|
|
135
|
+
#
|
|
136
|
+
# @param content [String] Content to sanitize
|
|
137
|
+
# @return [String] Sanitized and wrapped content
|
|
138
|
+
def self.sanitize_markdown(content)
|
|
139
|
+
sanitized = content.to_s
|
|
140
|
+
|
|
141
|
+
# Ensure code fences are closed
|
|
142
|
+
# Count occurrences of code fence markers (```)
|
|
143
|
+
fence_count = sanitized.scan(/^```/).count
|
|
144
|
+
if fence_count.odd?
|
|
145
|
+
# Unclosed code fence - close it
|
|
146
|
+
sanitized += "\n```\n"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Wrap in collapsible details to contain any formatting issues
|
|
150
|
+
<<~MARKDOWN
|
|
151
|
+
<details>
|
|
152
|
+
<summary><b>📋 Full Review</b> (click to expand)</summary>
|
|
153
|
+
|
|
154
|
+
#{sanitized}
|
|
155
|
+
|
|
156
|
+
</details>
|
|
157
|
+
MARKDOWN
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Post comment via gh CLI using a temp file for the body
|
|
161
|
+
#
|
|
162
|
+
# @param gh_format [String] PR identifier in gh format
|
|
163
|
+
# @param comment_body [String] Comment content
|
|
164
|
+
# @return [Hash] Result from gh CLI
|
|
165
|
+
def self.post_via_gh(gh_format, comment_body)
|
|
166
|
+
# Write comment to temp file (gh pr comment reads from file)
|
|
167
|
+
Tempfile.create(["review-comment", ".md"]) do |file|
|
|
168
|
+
file.write(comment_body)
|
|
169
|
+
file.flush
|
|
170
|
+
|
|
171
|
+
# Post using gh pr comment
|
|
172
|
+
Ace::Review::Molecules::GhCliExecutor.execute(
|
|
173
|
+
"pr",
|
|
174
|
+
["comment", gh_format, "--body-file", file.path]
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Extract comment URL from gh CLI output
|
|
180
|
+
#
|
|
181
|
+
# @param output [String] gh CLI stdout
|
|
182
|
+
# @param parsed [Hash] Parsed PR identifier with :repo (owner/repo format), :number
|
|
183
|
+
# @return [String] Comment URL
|
|
184
|
+
def self.extract_comment_url(output, parsed)
|
|
185
|
+
# gh pr comment returns the comment URL on success
|
|
186
|
+
# Format: https://github.com/owner/repo/pull/123#issuecomment-123456
|
|
187
|
+
url_match = output.match(%r{(https://[^\s]+)})
|
|
188
|
+
|
|
189
|
+
if url_match
|
|
190
|
+
url_match[1]
|
|
191
|
+
else
|
|
192
|
+
# Fallback: construct URL from parsed identifier
|
|
193
|
+
# ace-git's ParseResult provides :repo in "owner/repo" combined format
|
|
194
|
+
repo = parsed[:repo] || parsed[:gh_format].to_s.split("#").first
|
|
195
|
+
number = parsed[:number]
|
|
196
|
+
"https://github.com/#{repo}/pull/#{number}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private_class_method :check_pr_state, :format_review_comment, :sanitize_markdown,
|
|
201
|
+
:post_via_gh, :extract_comment_url
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Review
|
|
7
|
+
module Molecules
|
|
8
|
+
# Resolve PR comments by replying and/or resolving threads
|
|
9
|
+
# Used by the review-pr workflow to mark feedback as addressed
|
|
10
|
+
class GhCommentResolver
|
|
11
|
+
# Reply to a PR with a comment indicating a fix
|
|
12
|
+
#
|
|
13
|
+
# @param pr_identifier [String] PR identifier (number, URL, or owner/repo#number)
|
|
14
|
+
# @param commit_sha [String] Commit SHA that addresses the feedback
|
|
15
|
+
# @param message [String, nil] Optional custom message (default: "Fixed in {sha}")
|
|
16
|
+
# @param options [Hash] Options
|
|
17
|
+
# @option options [Integer] :timeout Timeout in seconds (default: 30)
|
|
18
|
+
# @return [Hash] Result with :success, :comment_url, :error
|
|
19
|
+
def self.reply(pr_identifier, commit_sha, message: nil, options: {})
|
|
20
|
+
# Guard: require either commit_sha or custom message
|
|
21
|
+
if (commit_sha.nil? || commit_sha.to_s.strip.empty?) && (message.nil? || message.strip.empty?)
|
|
22
|
+
return {success: false, error: "Commit SHA or message required"}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse identifier using ace-git
|
|
26
|
+
parsed = Ace::Git::Atoms::PrIdentifierParser.parse(pr_identifier)
|
|
27
|
+
gh_format = parsed.gh_format
|
|
28
|
+
|
|
29
|
+
# Build message
|
|
30
|
+
short_sha = commit_sha.to_s[0..6]
|
|
31
|
+
body = message || "Fixed in #{short_sha}"
|
|
32
|
+
|
|
33
|
+
# Default timeout
|
|
34
|
+
timeout = options[:timeout] || 30
|
|
35
|
+
|
|
36
|
+
# Post comment using gh CLI
|
|
37
|
+
result = Ace::Review::Molecules::GhCliExecutor.execute(
|
|
38
|
+
"pr",
|
|
39
|
+
["comment", gh_format, "--body", body],
|
|
40
|
+
timeout: timeout
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if result[:success]
|
|
44
|
+
# Try to extract comment URL from output
|
|
45
|
+
comment_url = extract_comment_url(result[:stdout])
|
|
46
|
+
{
|
|
47
|
+
success: true,
|
|
48
|
+
comment_url: comment_url,
|
|
49
|
+
message: body
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
{
|
|
53
|
+
success: false,
|
|
54
|
+
error: "Failed to post reply: #{result[:stderr]}"
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
|
|
58
|
+
raise
|
|
59
|
+
rescue => e
|
|
60
|
+
{
|
|
61
|
+
success: false,
|
|
62
|
+
error: "Failed to post reply: #{e.message}"
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Valid thread ID pattern (GitHub GraphQL node IDs)
|
|
67
|
+
# PRRT_ = Pull Request Review Thread
|
|
68
|
+
THREAD_ID_PATTERN = /\APRRT_[A-Za-z0-9_-]+\z/
|
|
69
|
+
|
|
70
|
+
# Resolve a review thread by thread ID using GitHub GraphQL API
|
|
71
|
+
#
|
|
72
|
+
# Note: This requires the thread's node ID (starts with "PRRT_")
|
|
73
|
+
# which can be obtained from the GhPrCommentFetcher results
|
|
74
|
+
#
|
|
75
|
+
# @param thread_id [String] GraphQL node ID of the review thread (e.g., "PRRT_abc123")
|
|
76
|
+
# @param options [Hash] Options
|
|
77
|
+
# @option options [Integer] :timeout Timeout in seconds (default: 30)
|
|
78
|
+
# @return [Hash] Result with :success, :resolved, :error
|
|
79
|
+
def self.resolve_thread(thread_id, options: {})
|
|
80
|
+
return {success: false, error: "Thread ID required"} if thread_id.nil? || thread_id.empty?
|
|
81
|
+
|
|
82
|
+
# Validate thread_id format to prevent GraphQL injection
|
|
83
|
+
unless thread_id.match?(THREAD_ID_PATTERN)
|
|
84
|
+
return {success: false, error: "Invalid thread ID format. Expected PRRT_xxx pattern."}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Default timeout
|
|
88
|
+
timeout = options[:timeout] || 30
|
|
89
|
+
|
|
90
|
+
# Build and execute GraphQL mutation
|
|
91
|
+
mutation = build_resolve_thread_mutation(thread_id)
|
|
92
|
+
|
|
93
|
+
# Execute via gh api graphql
|
|
94
|
+
result = Ace::Review::Molecules::GhCliExecutor.execute(
|
|
95
|
+
"api",
|
|
96
|
+
["graphql", "-f", "query=#{mutation}"],
|
|
97
|
+
timeout: timeout
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if result[:success]
|
|
101
|
+
begin
|
|
102
|
+
response = JSON.parse(result[:stdout])
|
|
103
|
+
is_resolved = response.dig("data", "resolveReviewThread", "thread", "isResolved")
|
|
104
|
+
|
|
105
|
+
if is_resolved
|
|
106
|
+
{success: true, resolved: true}
|
|
107
|
+
elsif response["errors"]
|
|
108
|
+
{success: false, error: response["errors"].first["message"]}
|
|
109
|
+
else
|
|
110
|
+
{success: false, error: "Thread not resolved"}
|
|
111
|
+
end
|
|
112
|
+
rescue JSON::ParserError => e
|
|
113
|
+
{success: false, error: "Failed to parse response: #{e.message}"}
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
{
|
|
117
|
+
success: false,
|
|
118
|
+
error: "Failed to resolve thread: #{result[:stderr]}"
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
|
|
122
|
+
raise
|
|
123
|
+
rescue => e
|
|
124
|
+
{
|
|
125
|
+
success: false,
|
|
126
|
+
error: "Failed to resolve thread: #{e.message}"
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reply to PR and resolve thread in one operation
|
|
131
|
+
#
|
|
132
|
+
# @param pr_identifier [String] PR identifier
|
|
133
|
+
# @param thread_id [String, nil] Thread ID to resolve (optional)
|
|
134
|
+
# @param commit_sha [String] Commit SHA that addresses the feedback
|
|
135
|
+
# @param message [String, nil] Optional custom message
|
|
136
|
+
# @param options [Hash] Options
|
|
137
|
+
# @return [Hash] Result with :success, :reply_result, :resolve_result, :error
|
|
138
|
+
def self.reply_and_resolve(pr_identifier, commit_sha, thread_id: nil, message: nil, options: {})
|
|
139
|
+
results = {success: true}
|
|
140
|
+
|
|
141
|
+
# Step 1: Reply with commit reference
|
|
142
|
+
reply_result = reply(pr_identifier, commit_sha, message: message, options: options)
|
|
143
|
+
results[:reply_result] = reply_result
|
|
144
|
+
|
|
145
|
+
unless reply_result[:success]
|
|
146
|
+
results[:success] = false
|
|
147
|
+
results[:error] = reply_result[:error]
|
|
148
|
+
return results
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Step 2: Resolve thread if thread_id provided
|
|
152
|
+
if thread_id && !thread_id.empty?
|
|
153
|
+
resolve_result = resolve_thread(thread_id, options: options)
|
|
154
|
+
results[:resolve_result] = resolve_result
|
|
155
|
+
|
|
156
|
+
# Thread resolution failure is not fatal - reply succeeded
|
|
157
|
+
unless resolve_result[:success]
|
|
158
|
+
results[:partial] = true
|
|
159
|
+
results[:warning] = "Reply posted but thread not resolved: #{resolve_result[:error]}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
results
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Build GraphQL mutation for resolving a review thread
|
|
169
|
+
#
|
|
170
|
+
# @param thread_id [String] Thread ID (validated before calling)
|
|
171
|
+
# @return [String] GraphQL mutation query
|
|
172
|
+
def self.build_resolve_thread_mutation(thread_id)
|
|
173
|
+
<<~GRAPHQL
|
|
174
|
+
mutation {
|
|
175
|
+
resolveReviewThread(input: {threadId: "#{thread_id}"}) {
|
|
176
|
+
thread {
|
|
177
|
+
isResolved
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
GRAPHQL
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Extract comment URL from gh CLI output
|
|
185
|
+
#
|
|
186
|
+
# @param output [String] gh CLI stdout
|
|
187
|
+
# @return [String, nil] Comment URL or nil
|
|
188
|
+
def self.extract_comment_url(output)
|
|
189
|
+
return nil if output.nil? || output.empty?
|
|
190
|
+
|
|
191
|
+
# gh pr comment outputs the URL of the created comment
|
|
192
|
+
output.strip if output.include?("github.com")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private_class_method :build_resolve_thread_mutation, :extract_comment_url
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|