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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
  3. data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
  6. data/.ace-defaults/review/config.yml +79 -0
  7. data/.ace-defaults/review/presets/code-fit.yml +64 -0
  8. data/.ace-defaults/review/presets/code-shine.yml +44 -0
  9. data/.ace-defaults/review/presets/code-valid.yml +39 -0
  10. data/.ace-defaults/review/presets/docs.yml +42 -0
  11. data/.ace-defaults/review/presets/spec.yml +37 -0
  12. data/CHANGELOG.md +1780 -0
  13. data/LICENSE +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +14 -0
  16. data/exe/ace-review +27 -0
  17. data/exe/ace-review-feedback +17 -0
  18. data/handbook/guides/code-review-process.g.md +234 -0
  19. data/handbook/prompts/base/sections.md +23 -0
  20. data/handbook/prompts/base/system.md +60 -0
  21. data/handbook/prompts/focus/architecture/atom.md +30 -0
  22. data/handbook/prompts/focus/architecture/reflection.md +60 -0
  23. data/handbook/prompts/focus/frameworks/rails.md +40 -0
  24. data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
  25. data/handbook/prompts/focus/languages/ruby.md +50 -0
  26. data/handbook/prompts/focus/phase/correctness.md +51 -0
  27. data/handbook/prompts/focus/phase/polish.md +43 -0
  28. data/handbook/prompts/focus/phase/quality.md +42 -0
  29. data/handbook/prompts/focus/quality/performance.md +48 -0
  30. data/handbook/prompts/focus/quality/security.md +47 -0
  31. data/handbook/prompts/focus/scope/docs.md +38 -0
  32. data/handbook/prompts/focus/scope/spec.md +58 -0
  33. data/handbook/prompts/focus/scope/tests.md +36 -0
  34. data/handbook/prompts/format/compact.md +12 -0
  35. data/handbook/prompts/format/detailed.md +39 -0
  36. data/handbook/prompts/format/standard.md +16 -0
  37. data/handbook/prompts/guidelines/icons.md +19 -0
  38. data/handbook/prompts/guidelines/tone.md +21 -0
  39. data/handbook/prompts/synthesis-review-reports.system.md +318 -0
  40. data/handbook/prompts/synthesize-feedback.system.md +147 -0
  41. data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
  42. data/handbook/skills/as-review-package/SKILL.md +36 -0
  43. data/handbook/skills/as-review-pr/SKILL.md +38 -0
  44. data/handbook/skills/as-review-run/SKILL.md +30 -0
  45. data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
  46. data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
  47. data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
  48. data/handbook/workflow-instructions/review/package.wf.md +16 -0
  49. data/handbook/workflow-instructions/review/pr.wf.md +284 -0
  50. data/handbook/workflow-instructions/review/run.wf.md +262 -0
  51. data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
  52. data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
  53. data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
  54. data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
  55. data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
  56. data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
  57. data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
  58. data/lib/ace/review/atoms/preset_validator.rb +103 -0
  59. data/lib/ace/review/atoms/priority_filter.rb +115 -0
  60. data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
  61. data/lib/ace/review/atoms/slug_generator.rb +50 -0
  62. data/lib/ace/review/atoms/token_estimator.rb +86 -0
  63. data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
  64. data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
  65. data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
  66. data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
  67. data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
  68. data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
  69. data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
  70. data/lib/ace/review/cli/commands/feedback.rb +79 -0
  71. data/lib/ace/review/cli/commands/review.rb +378 -0
  72. data/lib/ace/review/cli/feedback_cli.rb +71 -0
  73. data/lib/ace/review/cli.rb +103 -0
  74. data/lib/ace/review/errors.rb +146 -0
  75. data/lib/ace/review/models/feedback_item.rb +216 -0
  76. data/lib/ace/review/models/review_options.rb +208 -0
  77. data/lib/ace/review/models/reviewer.rb +181 -0
  78. data/lib/ace/review/molecules/context_composer.rb +123 -0
  79. data/lib/ace/review/molecules/context_extractor.rb +159 -0
  80. data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
  81. data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
  82. data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
  83. data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
  84. data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
  85. data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
  86. data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
  87. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
  88. data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
  89. data/lib/ace/review/molecules/llm_executor.rb +142 -0
  90. data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
  91. data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
  92. data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
  93. data/lib/ace/review/molecules/preset_manager.rb +494 -0
  94. data/lib/ace/review/molecules/prompt_composer.rb +76 -0
  95. data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
  96. data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
  97. data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
  98. data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
  99. data/lib/ace/review/molecules/subject_extractor.rb +315 -0
  100. data/lib/ace/review/molecules/subject_filter.rb +199 -0
  101. data/lib/ace/review/molecules/subject_strategy.rb +96 -0
  102. data/lib/ace/review/molecules/task_report_saver.rb +161 -0
  103. data/lib/ace/review/molecules/task_resolver.rb +48 -0
  104. data/lib/ace/review/organisms/feedback_manager.rb +386 -0
  105. data/lib/ace/review/organisms/review_manager.rb +1059 -0
  106. data/lib/ace/review/version.rb +7 -0
  107. data/lib/ace/review.rb +135 -0
  108. 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