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,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure function for resolving model context limits
|
|
7
|
+
#
|
|
8
|
+
# Maps model names to their context window sizes. Handles provider prefixes
|
|
9
|
+
# (google:, anthropic:, openai:) and uses pattern matching for model families.
|
|
10
|
+
#
|
|
11
|
+
# Resolution order:
|
|
12
|
+
# 1. ace-llm provider config (context_limit in providers/*.yml)
|
|
13
|
+
# 2. Hardcoded pattern matching (MODEL_LIMITS)
|
|
14
|
+
# 3. Conservative default for unknown models
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage
|
|
17
|
+
# ContextLimitResolver.resolve("google:gemini-2.5-pro")
|
|
18
|
+
# #=> 1_000_000
|
|
19
|
+
#
|
|
20
|
+
# @example Without provider prefix
|
|
21
|
+
# ContextLimitResolver.resolve("claude-3-sonnet")
|
|
22
|
+
# #=> 200_000
|
|
23
|
+
module ContextLimitResolver
|
|
24
|
+
# Conservative default for unknown models
|
|
25
|
+
DEFAULT_LIMIT = 200_000
|
|
26
|
+
|
|
27
|
+
# Model patterns and their context limits (fallback when ace-llm config unavailable)
|
|
28
|
+
# Order matters - first match wins
|
|
29
|
+
# Patterns use regex for flexible matching
|
|
30
|
+
MODEL_LIMITS = [
|
|
31
|
+
# Gemini models
|
|
32
|
+
{pattern: /gemini-1\.5-pro/i, limit: 2_000_000},
|
|
33
|
+
{pattern: /gemini-1\.5-flash/i, limit: 1_000_000},
|
|
34
|
+
{pattern: /gemini-2\.5-pro/i, limit: 1_000_000},
|
|
35
|
+
{pattern: /gemini-2\.5-flash/i, limit: 1_000_000},
|
|
36
|
+
{pattern: /gemini-2\.0/i, limit: 1_000_000},
|
|
37
|
+
# Fallback for any other gemini model
|
|
38
|
+
{pattern: /gemini/i, limit: 1_000_000},
|
|
39
|
+
|
|
40
|
+
# Claude models (all variants: opus, sonnet, haiku)
|
|
41
|
+
{pattern: /claude.*opus/i, limit: 1_000_000},
|
|
42
|
+
{pattern: /claude.*sonnet/i, limit: 1_000_000},
|
|
43
|
+
{pattern: /claude.*haiku/i, limit: 1_000_000},
|
|
44
|
+
# Fallback for any other claude model
|
|
45
|
+
{pattern: /claude/i, limit: 1_000_000},
|
|
46
|
+
|
|
47
|
+
# OpenAI models
|
|
48
|
+
{pattern: /gpt-5\.\d/i, limit: 1_050_000},
|
|
49
|
+
{pattern: /o4-/i, limit: 1_050_000},
|
|
50
|
+
{pattern: /gpt-4o/i, limit: 128_000},
|
|
51
|
+
{pattern: /gpt-4-turbo/i, limit: 128_000},
|
|
52
|
+
{pattern: /gpt-4-32k/i, limit: 32_768},
|
|
53
|
+
{pattern: /gpt-4-\d+-preview/i, limit: 128_000}, # gpt-4-1106-preview, gpt-4-0125-preview
|
|
54
|
+
{pattern: /gpt-4-\d+$/i, limit: 8_192}, # legacy gpt-4-0613, etc.
|
|
55
|
+
{pattern: /gpt-4$/i, limit: 8_192}, # base gpt-4 model
|
|
56
|
+
{pattern: /o1-/i, limit: 200_000},
|
|
57
|
+
{pattern: /o3-/i, limit: 200_000}
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
# Resolve context limit for a model
|
|
61
|
+
#
|
|
62
|
+
# Resolution order:
|
|
63
|
+
# 1. ace-llm provider config (if available and provider specified)
|
|
64
|
+
# 2. Hardcoded pattern matching
|
|
65
|
+
# 3. Default limit
|
|
66
|
+
#
|
|
67
|
+
# @param model_name [String, nil] Model identifier, optionally with provider prefix
|
|
68
|
+
# @return [Integer] Context limit in tokens
|
|
69
|
+
#
|
|
70
|
+
# @example With provider prefix
|
|
71
|
+
# ContextLimitResolver.resolve("google:gemini-2.5-pro")
|
|
72
|
+
# #=> 1_000_000
|
|
73
|
+
#
|
|
74
|
+
# @example Without provider prefix
|
|
75
|
+
# ContextLimitResolver.resolve("claude-3-opus")
|
|
76
|
+
# #=> 200_000
|
|
77
|
+
#
|
|
78
|
+
# @example Unknown model
|
|
79
|
+
# ContextLimitResolver.resolve("unknown-model")
|
|
80
|
+
# #=> 128_000
|
|
81
|
+
def self.resolve(model_name)
|
|
82
|
+
return DEFAULT_LIMIT if model_name.nil? || model_name.empty?
|
|
83
|
+
|
|
84
|
+
# Try to get limit from ace-llm provider config first
|
|
85
|
+
limit = load_from_ace_llm(model_name)
|
|
86
|
+
return limit if limit
|
|
87
|
+
|
|
88
|
+
# Fall back to hardcoded pattern matching
|
|
89
|
+
normalized = strip_provider_prefix(model_name)
|
|
90
|
+
match = MODEL_LIMITS.find { |entry| normalized.match?(entry[:pattern]) }
|
|
91
|
+
match ? match[:limit] : DEFAULT_LIMIT
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get the default limit for unknown models
|
|
95
|
+
#
|
|
96
|
+
# @return [Integer] Default context limit
|
|
97
|
+
def self.default_limit
|
|
98
|
+
DEFAULT_LIMIT
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Load context limit from ace-llm provider configuration
|
|
102
|
+
#
|
|
103
|
+
# @param model_name [String] Model identifier with provider prefix
|
|
104
|
+
# @return [Integer, nil] Context limit from config, or nil if not found
|
|
105
|
+
def self.load_from_ace_llm(model_name)
|
|
106
|
+
# Extract provider prefix (e.g., "google" from "google:gemini-2.5-pro")
|
|
107
|
+
return nil unless model_name.include?(":")
|
|
108
|
+
|
|
109
|
+
provider = model_name.split(":").first
|
|
110
|
+
return nil if provider.nil? || provider.empty?
|
|
111
|
+
|
|
112
|
+
# Try to load provider config via ace-llm
|
|
113
|
+
config = load_provider_config(provider)
|
|
114
|
+
return nil unless config
|
|
115
|
+
|
|
116
|
+
# Get context_limit from provider config
|
|
117
|
+
limit = config["context_limit"]
|
|
118
|
+
limit.is_a?(Integer) ? limit : nil
|
|
119
|
+
rescue
|
|
120
|
+
nil # Fall back to hardcoded on any error
|
|
121
|
+
end
|
|
122
|
+
private_class_method :load_from_ace_llm
|
|
123
|
+
|
|
124
|
+
# Load provider configuration from ace-llm
|
|
125
|
+
#
|
|
126
|
+
# @param provider [String] Provider name (e.g., "google", "anthropic")
|
|
127
|
+
# @return [Hash, nil] Provider config hash or nil
|
|
128
|
+
def self.load_provider_config(provider)
|
|
129
|
+
# Try to use ace-llm's config loader if available
|
|
130
|
+
return nil unless defined?(Ace::LLM::Molecules::ConfigLoader)
|
|
131
|
+
|
|
132
|
+
resolver = Ace::Support::Config.create(
|
|
133
|
+
config_dir: ".ace",
|
|
134
|
+
defaults_dir: ".ace-defaults",
|
|
135
|
+
gem_path: Ace::LLM::Molecules::ConfigLoader.gem_root
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
config = resolver.resolve_namespace("llm", filename: "providers/#{provider}")
|
|
139
|
+
config.to_h
|
|
140
|
+
rescue
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
private_class_method :load_provider_config
|
|
144
|
+
|
|
145
|
+
# Strip provider prefix from model name
|
|
146
|
+
#
|
|
147
|
+
# @param model_name [String] Model identifier
|
|
148
|
+
# @return [String] Model name without provider prefix
|
|
149
|
+
#
|
|
150
|
+
# @example
|
|
151
|
+
# strip_provider_prefix("google:gemini-2.5-pro")
|
|
152
|
+
# #=> "gemini-2.5-pro"
|
|
153
|
+
def self.strip_provider_prefix(model_name)
|
|
154
|
+
# Common provider prefixes
|
|
155
|
+
model_name.sub(/\A(google|anthropic|openai|codex|cli):/, "")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private_class_method :strip_provider_prefix
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure function for parsing unified diffs into file blocks
|
|
7
|
+
#
|
|
8
|
+
# Parses `diff --git` format diffs and extracts individual file blocks
|
|
9
|
+
# with their paths and content. Used by the chunked strategy to split
|
|
10
|
+
# large diffs at file boundaries.
|
|
11
|
+
#
|
|
12
|
+
# Thread-safe: This module uses only class methods with no mutable
|
|
13
|
+
# instance state. All methods are pure functions that can be safely
|
|
14
|
+
# called concurrently from multiple threads.
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage
|
|
17
|
+
# blocks = DiffBoundaryFinder.parse(diff_text)
|
|
18
|
+
# #=> [
|
|
19
|
+
# # { path: "lib/foo.rb", content: "diff --git...", lines: 45, change_type: :modified },
|
|
20
|
+
# # { path: "test/foo_test.rb", content: "diff --git...", lines: 30, change_type: :modified }
|
|
21
|
+
# # ]
|
|
22
|
+
module DiffBoundaryFinder
|
|
23
|
+
# Pattern to match the start of a file diff block
|
|
24
|
+
# Matches: diff --git a/path/to/file b/path/to/file
|
|
25
|
+
DIFF_HEADER_PATTERN = /^diff --git a\/(.+?) b\/(.+?)$/
|
|
26
|
+
|
|
27
|
+
# Pattern to detect new file mode
|
|
28
|
+
NEW_FILE_PATTERN = /^new file mode/
|
|
29
|
+
|
|
30
|
+
# Pattern to detect deleted file mode
|
|
31
|
+
DELETED_FILE_PATTERN = /^deleted file mode/
|
|
32
|
+
|
|
33
|
+
# Parse a unified diff into individual file blocks
|
|
34
|
+
#
|
|
35
|
+
# @param diff_text [String, nil] The unified diff text to parse
|
|
36
|
+
# @return [Array<Hash>] Array of file blocks, each with:
|
|
37
|
+
# - :path [String] - File path (uses 'b/' side)
|
|
38
|
+
# - :content [String] - Full diff content for this file
|
|
39
|
+
# - :lines [Integer] - Number of lines in the diff block
|
|
40
|
+
# - :change_type [Symbol] - :added, :deleted, or :modified
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# DiffBoundaryFinder.parse(diff)
|
|
44
|
+
# #=> [{ path: "lib/foo.rb", content: "diff --git...", lines: 45, change_type: :modified }]
|
|
45
|
+
def self.parse(diff_text)
|
|
46
|
+
return [] if diff_text.nil? || diff_text.empty?
|
|
47
|
+
|
|
48
|
+
blocks = []
|
|
49
|
+
current_block = nil
|
|
50
|
+
current_lines = []
|
|
51
|
+
|
|
52
|
+
diff_text.each_line do |line|
|
|
53
|
+
if (match = DIFF_HEADER_PATTERN.match(line))
|
|
54
|
+
# Save the previous block if exists
|
|
55
|
+
if current_block
|
|
56
|
+
current_block[:content] = current_lines.join
|
|
57
|
+
current_block[:lines] = current_lines.length
|
|
58
|
+
blocks << current_block
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Start a new block
|
|
62
|
+
current_block = {
|
|
63
|
+
path: match[2], # Use the 'b/' side (destination path)
|
|
64
|
+
content: "",
|
|
65
|
+
lines: 0,
|
|
66
|
+
change_type: :modified # Default, may be updated below
|
|
67
|
+
}
|
|
68
|
+
current_lines = [line]
|
|
69
|
+
elsif current_block
|
|
70
|
+
current_lines << line
|
|
71
|
+
|
|
72
|
+
# Detect change type from mode lines
|
|
73
|
+
if NEW_FILE_PATTERN.match?(line)
|
|
74
|
+
current_block[:change_type] = :added
|
|
75
|
+
elsif DELETED_FILE_PATTERN.match?(line)
|
|
76
|
+
current_block[:change_type] = :deleted
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Don't forget the last block
|
|
82
|
+
if current_block
|
|
83
|
+
current_block[:content] = current_lines.join
|
|
84
|
+
current_block[:lines] = current_lines.length
|
|
85
|
+
blocks << current_block
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
blocks
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Parse and return just the file paths from a diff
|
|
92
|
+
#
|
|
93
|
+
# @param diff_text [String, nil] The unified diff text to parse
|
|
94
|
+
# @return [Array<String>] List of file paths in the diff
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# DiffBoundaryFinder.file_paths(diff)
|
|
98
|
+
# #=> ["lib/foo.rb", "test/foo_test.rb"]
|
|
99
|
+
def self.file_paths(diff_text)
|
|
100
|
+
parse(diff_text).map { |block| block[:path] }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Count the number of files in a diff
|
|
104
|
+
#
|
|
105
|
+
# @param diff_text [String, nil] The unified diff text
|
|
106
|
+
# @return [Integer] Number of files in the diff
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# DiffBoundaryFinder.file_count(diff)
|
|
110
|
+
# #=> 5
|
|
111
|
+
def self.file_count(diff_text)
|
|
112
|
+
return 0 if diff_text.nil? || diff_text.empty?
|
|
113
|
+
|
|
114
|
+
diff_text.scan(DIFF_HEADER_PATTERN).length
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Group file blocks by directory
|
|
118
|
+
#
|
|
119
|
+
# @param blocks [Array<Hash>] Array of file blocks from #parse
|
|
120
|
+
# @return [Hash<String, Array<Hash>>] Files grouped by directory
|
|
121
|
+
#
|
|
122
|
+
# @example
|
|
123
|
+
# DiffBoundaryFinder.group_by_directory(blocks)
|
|
124
|
+
# #=> { "lib/atoms" => [...], "test/atoms" => [...] }
|
|
125
|
+
def self.group_by_directory(blocks)
|
|
126
|
+
blocks.group_by do |block|
|
|
127
|
+
File.dirname(block[:path])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/b36ts"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Review
|
|
7
|
+
module Atoms
|
|
8
|
+
# Generates unique 8-character Base36 IDs for feedback items.
|
|
9
|
+
#
|
|
10
|
+
# Uses ace-b36ts to generate timestamp-based IDs with
|
|
11
|
+
# millisecond precision that sort chronologically. This enables
|
|
12
|
+
# natural ordering of feedback items by creation time.
|
|
13
|
+
#
|
|
14
|
+
# @example Generate a new ID
|
|
15
|
+
# FeedbackIdGenerator.generate
|
|
16
|
+
# #=> "i50jj3ab"
|
|
17
|
+
#
|
|
18
|
+
# @example IDs are chronologically sortable
|
|
19
|
+
# id1 = FeedbackIdGenerator.generate
|
|
20
|
+
# sleep(0.1)
|
|
21
|
+
# id2 = FeedbackIdGenerator.generate
|
|
22
|
+
# id1 < id2 #=> true
|
|
23
|
+
#
|
|
24
|
+
class FeedbackIdGenerator
|
|
25
|
+
# Generate a new 8-character Base36 ID based on current timestamp
|
|
26
|
+
# with millisecond precision
|
|
27
|
+
#
|
|
28
|
+
# @return [String] 8-character Base36 ID (e.g., "i50jj3ab")
|
|
29
|
+
def self.generate
|
|
30
|
+
Ace::B36ts.encode(Time.now.utc, format: :ms)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Generate a new ID for a specific time (useful for testing)
|
|
34
|
+
#
|
|
35
|
+
# @param time [Time] The time to encode
|
|
36
|
+
# @return [String] 8-character Base36 ID
|
|
37
|
+
def self.generate_for(time)
|
|
38
|
+
Ace::B36ts.encode(time.utc, format: :ms)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Generate a sequence of unique, sequential IDs
|
|
42
|
+
#
|
|
43
|
+
# Uses ace-b36ts's encode_sequence to generate IDs that are
|
|
44
|
+
# guaranteed unique even when created in rapid succession. Each ID is
|
|
45
|
+
# strictly greater than the previous (lexicographically).
|
|
46
|
+
#
|
|
47
|
+
# @param count [Integer] Number of IDs to generate
|
|
48
|
+
# @return [Array<String>] Array of unique 8-character Base36 IDs
|
|
49
|
+
# @raise [ArgumentError] If count <= 0
|
|
50
|
+
#
|
|
51
|
+
# @example Generate 5 unique IDs
|
|
52
|
+
# ids = FeedbackIdGenerator.generate_sequence(5)
|
|
53
|
+
# ids.length #=> 5
|
|
54
|
+
# ids.uniq.length #=> 5 # All unique
|
|
55
|
+
# ids == ids.sort #=> true # Already sorted
|
|
56
|
+
def self.generate_sequence(count)
|
|
57
|
+
Ace::B36ts::Atoms::CompactIdEncoder.encode_sequence(
|
|
58
|
+
Time.now.utc,
|
|
59
|
+
count: count,
|
|
60
|
+
format: :ms
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Generates URL-safe slugs from feedback item titles.
|
|
7
|
+
#
|
|
8
|
+
# Creates slugs suitable for use in filenames and URLs, handling
|
|
9
|
+
# edge cases like unicode, special characters, and length limits.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# FeedbackSlugGenerator.generate("Missing error handling")
|
|
13
|
+
# #=> "missing-error-handling"
|
|
14
|
+
#
|
|
15
|
+
# @example Long titles are truncated
|
|
16
|
+
# FeedbackSlugGenerator.generate("A very long title that exceeds forty characters limit")
|
|
17
|
+
# #=> "a-very-long-title-that-exceeds-forty" (truncated to 40 chars)
|
|
18
|
+
#
|
|
19
|
+
# @example Unicode is transliterated or removed
|
|
20
|
+
# FeedbackSlugGenerator.generate("Fix bug in caf\u00e9 module")
|
|
21
|
+
# #=> "fix-bug-in-caf-module"
|
|
22
|
+
#
|
|
23
|
+
class FeedbackSlugGenerator
|
|
24
|
+
DEFAULT_MAX_LENGTH = 40
|
|
25
|
+
|
|
26
|
+
# Generate a URL-safe slug from a title
|
|
27
|
+
#
|
|
28
|
+
# @param title [String] The title to convert to a slug
|
|
29
|
+
# @param max_length [Integer] Maximum slug length (default: 40)
|
|
30
|
+
# @return [String] URL-safe slug
|
|
31
|
+
#
|
|
32
|
+
# @example Basic title
|
|
33
|
+
# FeedbackSlugGenerator.generate("Fix authentication bug")
|
|
34
|
+
# #=> "fix-authentication-bug"
|
|
35
|
+
#
|
|
36
|
+
# @example With special characters
|
|
37
|
+
# FeedbackSlugGenerator.generate("Add try/catch block (urgent!)")
|
|
38
|
+
# #=> "add-try-catch-block-urgent"
|
|
39
|
+
#
|
|
40
|
+
# @example Empty or nil input
|
|
41
|
+
# FeedbackSlugGenerator.generate(nil)
|
|
42
|
+
# #=> ""
|
|
43
|
+
# FeedbackSlugGenerator.generate("")
|
|
44
|
+
# #=> ""
|
|
45
|
+
def self.generate(title, max_length: DEFAULT_MAX_LENGTH)
|
|
46
|
+
return "" if title.nil? || title.empty?
|
|
47
|
+
|
|
48
|
+
title
|
|
49
|
+
.unicode_normalize(:nfkd) # Decompose unicode (e.g., é -> e + combining accent)
|
|
50
|
+
.encode("ASCII", undef: :replace, replace: "") # Strip non-ASCII
|
|
51
|
+
.gsub(/[^a-zA-Z0-9\-_\s]/, "") # Remove special chars (keep spaces for now)
|
|
52
|
+
.gsub(/[\s_]+/, "-").squeeze("-") # Collapse consecutive hyphens
|
|
53
|
+
.gsub(/\A-|-\z/, "") # Remove leading/trailing hyphens
|
|
54
|
+
.downcase
|
|
55
|
+
.slice(0, max_length) # Truncate to max length
|
|
56
|
+
.gsub(/-\z/, "") # Remove trailing hyphen after truncation
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Validates state transitions for feedback items.
|
|
7
|
+
#
|
|
8
|
+
# Implements the feedback item state machine:
|
|
9
|
+
# draft -> (verify valid=true) -> pending -> (resolve) -> done [archived]
|
|
10
|
+
# draft -> (verify valid=false) -> invalid [archived]
|
|
11
|
+
# draft -> (skip) -> skip [archived]
|
|
12
|
+
# pending -> (skip) -> skip [archived]
|
|
13
|
+
#
|
|
14
|
+
# Terminal states (invalid, skip, done) cannot transition further.
|
|
15
|
+
#
|
|
16
|
+
# @example Check if a transition is valid
|
|
17
|
+
# FeedbackStateValidator.valid_transition?("draft", "pending")
|
|
18
|
+
# #=> true
|
|
19
|
+
#
|
|
20
|
+
# FeedbackStateValidator.valid_transition?("done", "pending")
|
|
21
|
+
# #=> false
|
|
22
|
+
#
|
|
23
|
+
# @example Get allowed transitions from a status
|
|
24
|
+
# FeedbackStateValidator.allowed_transitions("draft")
|
|
25
|
+
# #=> ["pending", "invalid", "skip"]
|
|
26
|
+
#
|
|
27
|
+
class FeedbackStateValidator
|
|
28
|
+
# Define the state machine transitions
|
|
29
|
+
# Maps from_status => [allowed target statuses]
|
|
30
|
+
TRANSITIONS = {
|
|
31
|
+
"draft" => %w[pending invalid skip],
|
|
32
|
+
"pending" => %w[done skip],
|
|
33
|
+
"invalid" => [], # Terminal state
|
|
34
|
+
"skip" => [], # Terminal state
|
|
35
|
+
"done" => [] # Terminal state
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Terminal states that require archiving
|
|
39
|
+
TERMINAL_STATES = %w[invalid skip done].freeze
|
|
40
|
+
|
|
41
|
+
# Check if a state transition is valid
|
|
42
|
+
#
|
|
43
|
+
# @param from_status [String] Current status
|
|
44
|
+
# @param to_status [String] Target status
|
|
45
|
+
# @return [Boolean] True if the transition is allowed
|
|
46
|
+
#
|
|
47
|
+
# @example Valid transition
|
|
48
|
+
# FeedbackStateValidator.valid_transition?("draft", "pending")
|
|
49
|
+
# #=> true
|
|
50
|
+
#
|
|
51
|
+
# @example Invalid transition
|
|
52
|
+
# FeedbackStateValidator.valid_transition?("draft", "done")
|
|
53
|
+
# #=> false
|
|
54
|
+
def self.valid_transition?(from_status, to_status)
|
|
55
|
+
allowed = TRANSITIONS[from_status]
|
|
56
|
+
return false if allowed.nil?
|
|
57
|
+
|
|
58
|
+
allowed.include?(to_status)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get the list of allowed target statuses from a given status
|
|
62
|
+
#
|
|
63
|
+
# @param status [String] Current status
|
|
64
|
+
# @return [Array<String>] List of valid target statuses (empty for terminal states)
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# FeedbackStateValidator.allowed_transitions("draft")
|
|
68
|
+
# #=> ["pending", "invalid", "skip"]
|
|
69
|
+
def self.allowed_transitions(status)
|
|
70
|
+
TRANSITIONS.fetch(status, []).dup
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if a status is terminal (requires archiving, no further transitions)
|
|
74
|
+
#
|
|
75
|
+
# @param status [String] Status to check
|
|
76
|
+
# @return [Boolean] True if status is terminal
|
|
77
|
+
#
|
|
78
|
+
# @example
|
|
79
|
+
# FeedbackStateValidator.terminal?("done")
|
|
80
|
+
# #=> true
|
|
81
|
+
#
|
|
82
|
+
# FeedbackStateValidator.terminal?("pending")
|
|
83
|
+
# #=> false
|
|
84
|
+
def self.terminal?(status)
|
|
85
|
+
TERMINAL_STATES.include?(status)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if a status requires archiving
|
|
89
|
+
#
|
|
90
|
+
# @param status [String] Status to check
|
|
91
|
+
# @return [Boolean] True if items with this status should be archived
|
|
92
|
+
def self.should_archive?(status)
|
|
93
|
+
terminal?(status)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|