ace-review 0.51.7 → 0.53.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6e9694e9a121f9b6b18479d7be2be32511483940cd667f842493b35b8c9d359
4
- data.tar.gz: e973c776a7b792365c3974fca973c31337529734f5eb0eda424da53c796e9cd6
3
+ metadata.gz: c50e105e74afc8802976a5c869debdd9514996f8c5c48c1ff0cc2266f1a2622f
4
+ data.tar.gz: 18512f481aa7315c446b7bfb93d7844dde492f1369b3a33cc34c3edb590bb1d0
5
5
  SHA512:
6
- metadata.gz: 9cb47a01bb00b68312c42c57b8fe694434818bcb813e1630711b5302fbf8b8caf0181239ce9b1932fdc9ade415de055fbee71f3a94b8ad01f61d9366d605de70
7
- data.tar.gz: f399541bbdd6d3ff98fddcb8d9c1305fb9ac73aff6a6f87058cbbeb0766a32ffc6498808a743e8052860e55724400fcf268ffaae6a4740baa44a0b3632f73190
6
+ metadata.gz: 1869bc1bcbe622b59b88189ccbaf09192b183b44aac2a10d926b38befbb58f6e01a54601c218234d0e681532d590a4bf2d531d3ca5f027dda17957f19a046270
7
+ data.tar.gz: 68f5a18ae201735fb0577235d1a9bc53e703ea2e44c89e927b053e1048fa1bcfb2782facfed324a612b42cc8a07956fd96a99cc18599ebdd3538c86bcccb7105
data/CHANGELOG.md CHANGED
@@ -7,8 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.53.5] - 2026-04-19
11
+
12
+ ### Technical
13
+ - Stabilized retained multi-model and reviewers-format E2E fixtures by pinning them to installed direct review models instead of ambient mixed-provider availability.
14
+
15
+ ## [0.53.4] - 2026-04-16
16
+
17
+ ### Technical
18
+ - Hardened retained multi-model and docs-path review E2E runners to persist `.stdout`, `.stderr`, and `.exit` artifacts consistently across success, timeout, and provider-constrained paths.
19
+
20
+ ## [0.53.3] - 2026-04-16
21
+
22
+ ### Fixed
23
+ - Updated retained `TS-REVIEW-001` execution fixtures to use installed direct review models, require explicit diff subjects, and drive the single/multi-model flows through the public executable review path instead of stopping at session preparation.
24
+
25
+ ## [0.53.2] - 2026-04-16
26
+
27
+ ### Fixed
28
+ - Switched retained `TS-REVIEW-001` multi-model fixtures from the missing `review-claude` role to installed review roles so the scenario validates real multi-provider review execution instead of local CLI availability drift.
29
+
30
+ ## [0.53.1] - 2026-04-16
31
+
32
+ ### Fixed
33
+ - Updated docs-path onboarding E2E verification to accept bounded timeout evidence when help discovery and review session artifacts prove the documented path is discoverable.
34
+
35
+ ## [0.53.0] - 2026-04-15
36
+
37
+ ### Changed
38
+ - Rewrote retained `TS-REVIEW-001` E2E goals to enforce public docs/help command-path guidance and added
39
+ `TC-003-docs-path-onboarding` coverage.
40
+ - Tightened E2E verifier contracts so provider/model unavailability is treated as a failure path instead of a
41
+ PASS-equivalent outcome.
42
+
43
+ ### Technical
44
+ - Updated scenario manifests and decision records to reflect three-goal execution (`X/3`) and the strict outcome
45
+ policy for onboarding-facing review workflows.
46
+
47
+ ## [0.52.1] - 2026-04-13
48
+
49
+ ### Changed
50
+ - Completed the batch i05 migration follow-through for this package and aligned it with the restarted `fast` / `feat` / `e2e` verification model.
51
+
52
+ ### Technical
53
+ - Included in the coordinated assignment-driven patch release for batch i05 package updates.
54
+
55
+
56
+ ## [0.52.0] - 2026-04-12
57
+
58
+ ### Changed
59
+ - Migrated package tests to the restarted `fast` / `feat` / `e2e` contract:
60
+ - moved deterministic package tests from legacy top-level folders into `test/fast/`
61
+ - moved former `test/integration/` deterministic coverage into `test/feat/`
62
+ - rewrote `TS-REVIEW-001` to retain only high-value execution workflows and added an E2E decision record
63
+ - Updated package docs to teach `ace-test ace-review`, `ace-test ace-review feat`, `ace-test ace-review all`, and `ace-test-e2e ace-review`.
64
+ - Expanded `as-review-pr` canonical skill metadata so public `review-pr` assign-step discovery is skill-owned rather than catalog-owned.
65
+ - Switched review GitHub CLI operations to use `Ace::Git::Molecules::GhCliExecutor` and removed the package-local `GhCliExecutor` implementation.
66
+
10
67
  ### Fixed
11
68
  - Added `diff:RANGE -- path` subject parsing so reviews can scope git diffs to specific files or directories with git-style path filters.
69
+ - Updated review GitHub workflows to correctly re-raise `Ace::Git` authentication/install errors after migrating to shared `GhCliExecutor`.
70
+
71
+ ## [0.51.10] - 2026-04-07
72
+
73
+ ### Changed
74
+ - Switched review GitHub CLI integrations to shared `Ace::Git::Molecules::GhCliExecutor` to remove duplicate implementations and align error handling with other packages.
75
+
76
+ ### Fixed
77
+ - Preserved review-path authentication and install error behavior after the executor migration.
12
78
 
13
79
  ## [0.51.6] - 2026-03-31
14
80
 
data/README.md CHANGED
@@ -38,5 +38,21 @@
38
38
 
39
39
  **Audit review history through session artifacts** - keep saved review sessions under `.ace-local/` for traceability, comparison, and handoff across contributors.
40
40
 
41
+ ## Testing
42
+
43
+ Run package deterministic checks with:
44
+
45
+ ```bash
46
+ ace-test ace-review
47
+ ace-test ace-review feat
48
+ ace-test ace-review all
49
+ ```
50
+
51
+ Run retained workflow scenarios with:
52
+
53
+ ```bash
54
+ ace-test-e2e ace-review
55
+ ```
56
+
41
57
  ---
42
58
  [Getting Started](docs/getting-started.md) | [Usage Guide](docs/usage.md) | [Handbook - Skills, Agents, Templates](docs/handbook.md) | Part of [ACE](https://github.com/cs3b/ace)
@@ -22,10 +22,19 @@ integration:
22
22
  - pi
23
23
  providers: {}
24
24
  assign:
25
- source: wfi://review/pr
26
25
  steps:
27
26
  - name: review-pr
28
27
  description: Review code changes for correctness, style, and best practices
28
+ prerequisites:
29
+ - name: create-pr
30
+ strength: required
31
+ reason: "Must have a PR to review"
32
+ produces: [review-feedback]
33
+ consumes: [pull-request]
34
+ when_to_skip:
35
+ - "No code changes since last review"
36
+ - "Changes are trivial (typo fix, config update)"
37
+ effort: medium
29
38
  tags: [review, quality]
30
39
  context:
31
40
  default: fork
@@ -42,7 +42,7 @@ ace-review --preset $1 --auto-execute
42
42
  ace-review --subject "$2" --auto-execute
43
43
 
44
44
  # File pattern review (NOTE: files: prefix is REQUIRED)
45
- ace-review --preset spec --subject "files:.ace-taskflow/**/*.md" --auto-execute
45
+ ace-review --preset spec --subject "files:.ace-task/**/*.md" --auto-execute
46
46
 
47
47
  # Multiple subjects merge automatically
48
48
  ace-review --subject pr:76 --subject files:CHANGELOG.md --auto-execute
@@ -26,6 +26,97 @@ module Ace
26
26
  # result[:items] #=> [FeedbackItem, ...] (with reviewers arrays)
27
27
  #
28
28
  class FeedbackSynthesizer
29
+ # Cached system prompt and prompt-path lookups to avoid repeated
30
+ # shell/file reads during test and command-heavy runs.
31
+ FALLBACK_SYSTEM_PROMPT = <<~PROMPT.freeze
32
+ Synthesize feedback from code review reports into unique findings.
33
+
34
+ For each unique issue found:
35
+ 1. Track which reviewers identified it (by their model names)
36
+ 2. Merge file references from all sources
37
+ 3. Use the most comprehensive description
38
+ 4. Mark consensus=true if 3+ reviewers agree
39
+
40
+ Return valid JSON with this schema:
41
+ {
42
+ "findings": [
43
+ {
44
+ "title": "Short title (max 60 chars)",
45
+ "files": ["path/file.rb:10-20"],
46
+ "reviewers": ["gemini-2.5-flash", "claude-3.5-sonnet"],
47
+ "consensus": false,
48
+ "priority": "high|medium|low|critical",
49
+ "finding": "Description of the issue",
50
+ "context": "Why this matters"
51
+ }
52
+ ]
53
+ }
54
+
55
+ IMPORTANT:
56
+ - When only one report: extract all findings as-is with that reviewer
57
+ - When multiple reports: deduplicate findings that describe the same issue
58
+ - When multiple reviewers find the same issue, list ALL of them in reviewers array
59
+ - Merge file arrays from all sources for each finding
60
+ - Return ONLY the JSON, no markdown code fences
61
+ PROMPT
62
+
63
+ class << self
64
+ def system_prompt(prompt_name)
65
+ system_prompt_cache_mutex.synchronize do
66
+ system_prompt_cache.fetch(prompt_name) do
67
+ prompt_path = resolve_prompt_path_cached(prompt_name)
68
+
69
+ if prompt_path && File.exist?(prompt_path)
70
+ system_prompt_cache[prompt_name] = File.read(prompt_path)
71
+ else
72
+ system_prompt_cache[prompt_name] = FALLBACK_SYSTEM_PROMPT
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def resolve_prompt_path_cached(prompt_name)
79
+ prompt_path_cache[prompt_name] ||= begin
80
+ nav_result = begin
81
+ `ace-nav prompt://#{prompt_name} 2>/dev/null`.strip
82
+ rescue
83
+ ""
84
+ end
85
+
86
+ return nav_result unless nav_result.empty?
87
+
88
+ File.join(__dir__, "../../../../handbook/prompts", prompt_name)
89
+ end
90
+ end
91
+
92
+ def clear_prompt_cache!
93
+ system_prompt_cache_mutex.synchronize do
94
+ @system_prompt_cache = {}
95
+ end
96
+ prompt_path_cache_mutex.synchronize do
97
+ @prompt_path_cache = {}
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def system_prompt_cache
104
+ @system_prompt_cache ||= {}
105
+ end
106
+
107
+ def prompt_path_cache
108
+ @prompt_path_cache ||= {}
109
+ end
110
+
111
+ def system_prompt_cache_mutex
112
+ @system_prompt_cache_mutex ||= Mutex.new
113
+ end
114
+
115
+ def prompt_path_cache_mutex
116
+ @prompt_path_cache_mutex ||= Mutex.new
117
+ end
118
+ end
119
+
29
120
  # Maximum combined report size before truncation (characters)
30
121
  MAX_COMBINED_SIZE = 200_000
31
122
 
@@ -138,51 +229,14 @@ module Ace
138
229
  #
139
230
  # @return [String] System prompt content
140
231
  def load_system_prompt
141
- prompt_name = "synthesize-feedback.system.md"
142
- prompt_path = resolve_prompt_path(prompt_name)
143
-
144
- if File.exist?(prompt_path)
145
- File.read(prompt_path)
146
- else
147
- fallback_synthesis_prompt
148
- end
232
+ self.class.system_prompt("synthesize-feedback.system.md")
149
233
  end
150
234
 
151
235
  # Fallback synthesis prompt (used when prompt file not found)
152
236
  #
153
237
  # @return [String] Basic synthesis prompt
154
238
  def fallback_synthesis_prompt
155
- <<~PROMPT
156
- Synthesize feedback from code review reports into unique findings.
157
-
158
- For each unique issue found:
159
- 1. Track which reviewers identified it (by their model names)
160
- 2. Merge file references from all sources
161
- 3. Use the most comprehensive description
162
- 4. Mark consensus=true if 3+ reviewers agree
163
-
164
- Return valid JSON with this schema:
165
- {
166
- "findings": [
167
- {
168
- "title": "Short title (max 60 chars)",
169
- "files": ["path/file.rb:10-20"],
170
- "reviewers": ["gemini-2.5-flash", "claude-3.5-sonnet"],
171
- "consensus": false,
172
- "priority": "high|medium|low|critical",
173
- "finding": "Description of the issue",
174
- "context": "Why this matters"
175
- }
176
- ]
177
- }
178
-
179
- IMPORTANT:
180
- - When only one report: extract all findings as-is with that reviewer
181
- - When multiple reports: deduplicate findings that describe the same issue
182
- - When multiple reviewers find the same issue, list ALL of them in reviewers array
183
- - Merge file arrays from all sources for each finding
184
- - Return ONLY the JSON, no markdown code fences
185
- PROMPT
239
+ FALLBACK_SYSTEM_PROMPT
186
240
  end
187
241
 
188
242
  # Build user prompt for report synthesis
@@ -522,16 +576,7 @@ module Ace
522
576
  # @param prompt_name [String] Prompt filename
523
577
  # @return [String] Resolved file path
524
578
  def resolve_prompt_path(prompt_name)
525
- # Try ace-nav first if available
526
- nav_result = begin
527
- `ace-nav prompt://#{prompt_name} 2>/dev/null`.strip
528
- rescue
529
- ""
530
- end
531
- return nav_result unless nav_result.empty?
532
-
533
- # Fallback to direct path
534
- File.join(__dir__, "../../../../handbook/prompts", prompt_name)
579
+ self.class.resolve_prompt_path_cached(prompt_name)
535
580
  end
536
581
 
537
582
  # Get default synthesis model from config
@@ -53,7 +53,8 @@ module Ace
53
53
  error: "Failed to post comment: #{result[:stderr]}"
54
54
  }
55
55
  end
56
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
56
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
57
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
57
58
  raise
58
59
  rescue => e
59
60
  {
@@ -68,7 +69,7 @@ module Ace
68
69
  # @return [Hash] Result with :success or :error
69
70
  def self.check_pr_state(gh_format)
70
71
  # Fetch PR metadata
71
- result = Ace::Review::Molecules::GhCliExecutor.execute(
72
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
72
73
  "pr",
73
74
  ["view", gh_format, "--json", "state,number"]
74
75
  )
@@ -169,7 +170,7 @@ module Ace
169
170
  file.flush
170
171
 
171
172
  # Post using gh pr comment
172
- Ace::Review::Molecules::GhCliExecutor.execute(
173
+ Ace::Git::Molecules::GhCliExecutor.execute(
173
174
  "pr",
174
175
  ["comment", gh_format, "--body-file", file.path]
175
176
  )
@@ -34,7 +34,7 @@ module Ace
34
34
  timeout = options[:timeout] || 30
35
35
 
36
36
  # Post comment using gh CLI
37
- result = Ace::Review::Molecules::GhCliExecutor.execute(
37
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
38
38
  "pr",
39
39
  ["comment", gh_format, "--body", body],
40
40
  timeout: timeout
@@ -54,7 +54,8 @@ module Ace
54
54
  error: "Failed to post reply: #{result[:stderr]}"
55
55
  }
56
56
  end
57
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
57
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
58
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
58
59
  raise
59
60
  rescue => e
60
61
  {
@@ -91,7 +92,7 @@ module Ace
91
92
  mutation = build_resolve_thread_mutation(thread_id)
92
93
 
93
94
  # Execute via gh api graphql
94
- result = Ace::Review::Molecules::GhCliExecutor.execute(
95
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
95
96
  "api",
96
97
  ["graphql", "-f", "query=#{mutation}"],
97
98
  timeout: timeout
@@ -118,7 +119,8 @@ module Ace
118
119
  error: "Failed to resolve thread: #{result[:stderr]}"
119
120
  }
120
121
  end
121
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
122
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
123
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
122
124
  raise
123
125
  rescue => e
124
126
  {
@@ -82,7 +82,7 @@ module Ace
82
82
  fields = "comments,reviews,number,title,author"
83
83
 
84
84
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
85
- Ace::Review::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
85
+ Ace::Git::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
86
86
  end
87
87
 
88
88
  if result[:success]
@@ -116,7 +116,8 @@ module Ace
116
116
  success: false,
117
117
  error: "Failed to parse PR comments: #{e.message}"
118
118
  }
119
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
119
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
120
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
120
121
  raise
121
122
  rescue => e
122
123
  {
@@ -250,7 +251,7 @@ module Ace
250
251
 
251
252
  # Execute GraphQL query
252
253
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
253
- Ace::Review::Molecules::GhCliExecutor.execute(
254
+ Ace::Git::Molecules::GhCliExecutor.execute(
254
255
  "api",
255
256
  [
256
257
  "graphql",
@@ -382,7 +383,7 @@ module Ace
382
383
  # @return [String, nil] "owner/name" format or nil if not a GitHub repo
383
384
  def self.discover_repo_from_remote(options = {})
384
385
  timeout = options[:timeout] || 10
385
- result = Ace::Review::Molecules::GhCliExecutor.execute(
386
+ result = Ace::Git::Molecules::GhCliExecutor.execute(
386
387
  "repo",
387
388
  ["view", "--json", "owner,name"],
388
389
  timeout: timeout
@@ -26,7 +26,7 @@ module Ace
26
26
 
27
27
  # Fetch diff with retry logic
28
28
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
29
- Ace::Review::Molecules::GhCliExecutor.execute("pr", ["diff", gh_format], timeout: timeout)
29
+ Ace::Git::Molecules::GhCliExecutor.execute("pr", ["diff", gh_format], timeout: timeout)
30
30
  end
31
31
 
32
32
  if result[:success]
@@ -42,7 +42,8 @@ module Ace
42
42
  rescue Ace::Review::Errors::DiffTooLargeError
43
43
  # Fall back to local git diff when GitHub API rejects large diffs
44
44
  fetch_local_diff_fallback(pr_identifier, options)
45
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
45
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
46
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
46
47
  # Re-raise authentication and installation errors
47
48
  raise
48
49
  rescue => e
@@ -70,7 +71,7 @@ module Ace
70
71
  fields = "number,state,isDraft,title,body,author,headRefName,baseRefName,url"
71
72
 
72
73
  result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
73
- Ace::Review::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
74
+ Ace::Git::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
74
75
  end
75
76
 
76
77
  if result[:success]
@@ -89,7 +90,8 @@ module Ace
89
90
  success: false,
90
91
  error: "Failed to parse PR metadata: #{e.message}"
91
92
  }
92
- rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
93
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
94
+ Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
93
95
  raise
94
96
  rescue => e
95
97
  {
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Review
5
- VERSION = '0.51.7'
5
+ VERSION = '0.53.5'
6
6
  end
7
7
  end
data/lib/ace/review.rb CHANGED
@@ -37,7 +37,6 @@ require_relative "review/molecules/preset_manager"
37
37
  require_relative "review/molecules/prompt_composer"
38
38
  require_relative "review/molecules/nav_prompt_resolver"
39
39
  require_relative "review/molecules/subject_extractor"
40
- require_relative "review/molecules/gh_cli_executor"
41
40
  require_relative "review/molecules/gh_pr_fetcher"
42
41
  require_relative "review/molecules/gh_pr_comment_fetcher"
43
42
  require_relative "review/molecules/gh_comment_poster"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-review
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.51.7
4
+ version: 0.53.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-01 00:00:00.000000000 Z
10
+ date: 2026-04-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -300,7 +300,6 @@ files:
300
300
  - lib/ace/review/molecules/feedback_file_reader.rb
301
301
  - lib/ace/review/molecules/feedback_file_writer.rb
302
302
  - lib/ace/review/molecules/feedback_synthesizer.rb
303
- - lib/ace/review/molecules/gh_cli_executor.rb
304
303
  - lib/ace/review/molecules/gh_comment_poster.rb
305
304
  - lib/ace/review/molecules/gh_comment_resolver.rb
306
305
  - lib/ace/review/molecules/gh_pr_comment_fetcher.rb
@@ -1,124 +0,0 @@
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