aireview 0.1.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +19 -0
- data/CONTRIBUTORS.md +8 -0
- data/README.md +54 -2
- data/lib/aireview/cli.rb +9 -29
- data/lib/aireview/config.rb +12 -4
- data/lib/aireview/config_limits.rb +85 -0
- data/lib/aireview/context_budget.rb +197 -0
- data/lib/aireview/context_builder.rb +125 -39
- data/lib/aireview/diff_fetcher.rb +79 -16
- data/lib/aireview/dry_run_report.rb +64 -0
- data/lib/aireview/errors.rb +1 -0
- data/lib/aireview/prompts/critique.txt +4 -0
- data/lib/aireview/prompts/generate.txt +4 -0
- data/lib/aireview/review_pipeline.rb +24 -32
- data/lib/aireview/review_renderer.rb +55 -5
- data/lib/aireview/version.rb +1 -1
- data/lib/aireview.rb +1 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b27b5c59888ddb35675082d95d7e5e79dc1935295ecde9a1e13cdcaf38f24dff
|
|
4
|
+
data.tar.gz: 6016c5aae6d57475465c707cc2d89dedf07964d402cc1f5a68a6e01dc5f553d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bc47e9e8d2c7cece68a02a92594c896f52c5b101c8a639cee9f3c702e010906e5280dbda4282465630162c90b2532debcdca05462c8a1b390987288b150850dc
|
|
7
|
+
data.tar.gz: 644ad90865170c254b3b844e606f9c0121a2b82a50d7af62ffd70f4bf838bd0a258db367e3a815d899fb8bb628f183d268eee11722d2364bf700734ba463b7a3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Context budget: `llm.max_prompt_chars` caps the request of each stage,
|
|
6
|
+
`context.max_*_chars` cap the MR description, the Jira description and
|
|
7
|
+
comments and the diff. The diff is cut by whole files and hunks, never in
|
|
8
|
+
the middle of a hunk, and both stages share the same context.
|
|
9
|
+
- Truncation is marked in the prompt and reported in the review: the result
|
|
10
|
+
line gets a `Partial review` suffix and a `Not reviewed` section lists the
|
|
11
|
+
files and sections that were left out. `--dry-run` and `--verbose` show the
|
|
12
|
+
sizes and the coverage.
|
|
13
|
+
- Renames, mode changes and empty new or deleted files are told apart from
|
|
14
|
+
diffs GitLab did not return (too large, binary, empty without a reason); the
|
|
15
|
+
latter are reported as not reviewed.
|
|
16
|
+
- Unparsable limits in the environment (`MAX_DIFF_CHARS=oops`) fail with a
|
|
17
|
+
`ConfigError` instead of silently falling back to the defaults.
|
|
18
|
+
- A run fails with a clear error when not even one hunk fits next to the
|
|
19
|
+
system prompt, or when the candidates push the Critique request over its
|
|
20
|
+
limit.
|
|
21
|
+
|
|
3
22
|
## 0.1.1
|
|
4
23
|
|
|
5
24
|
- The prompts no longer ask the model to check whether dependency and image
|
data/CONTRIBUTORS.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Contributors
|
|
2
|
+
|
|
3
|
+
- [Denis Levenko](https://github.com/DenisDenis9331), author and maintainer.
|
|
4
|
+
- [Sergey Kondrashov](https://github.com/SergoHUH): Ollama support, Jira
|
|
5
|
+
self-hosted configuration, provider cleanup, prompt refactoring.
|
|
6
|
+
|
|
7
|
+
Contributions are welcome: open an issue or a pull request at
|
|
8
|
+
https://github.com/DenisDenis9331/aireview.
|
data/README.md
CHANGED
|
@@ -204,11 +204,18 @@ review_instructions: |
|
|
|
204
204
|
|
|
205
205
|
ollama_api_base: http://localhost:11434/v1
|
|
206
206
|
|
|
207
|
+
context:
|
|
208
|
+
max_diff_chars: 120000
|
|
209
|
+
max_mr_description_chars: 8000
|
|
210
|
+
max_jira_description_chars: 8000
|
|
211
|
+
max_jira_comment_chars: 2000
|
|
212
|
+
|
|
207
213
|
llm:
|
|
208
214
|
provider: gemini
|
|
209
215
|
temperature: 0
|
|
210
216
|
timeout: 60
|
|
211
217
|
http_proxy: http://127.0.0.1:8888
|
|
218
|
+
max_prompt_chars: 400000
|
|
212
219
|
generate:
|
|
213
220
|
provider: gemini
|
|
214
221
|
model: gemini-3.7-flash
|
|
@@ -217,8 +224,46 @@ llm:
|
|
|
217
224
|
provider: ollama
|
|
218
225
|
model: qwen2.5-coder:7b
|
|
219
226
|
temperature: 0
|
|
227
|
+
max_prompt_chars: 60000
|
|
220
228
|
```
|
|
221
229
|
|
|
230
|
+
### Context budget
|
|
231
|
+
|
|
232
|
+
The request to each stage is capped by `llm.max_prompt_chars` (or
|
|
233
|
+
`LLM_MAX_PROMPT_CHARS`; per stage `llm.generate.max_prompt_chars` /
|
|
234
|
+
`LLM_GENERATE_MAX_PROMPT_CHARS` and the same for `critique`). The limits are in
|
|
235
|
+
characters, not tokens: there is no exact tokenizer for the providers locally,
|
|
236
|
+
and the Ollama window is set on the server where the client cannot see it. As
|
|
237
|
+
a rule of thumb one token is three to four characters, so for a local model
|
|
238
|
+
with `OLLAMA_CONTEXT_LENGTH=8192` set the stage limit to about 20 000
|
|
239
|
+
characters to leave room for the answer.
|
|
240
|
+
|
|
241
|
+
The MR and Jira context is assembled once per run and shared by both stages,
|
|
242
|
+
so it is sized for the tighter of the two: the Critique stage also has to fit
|
|
243
|
+
its system prompt and a reserve for the candidates. The sections are cut to
|
|
244
|
+
their own limits first, keeping the beginning: `context.max_diff_chars`,
|
|
245
|
+
`context.max_mr_description_chars`, `context.max_jira_description_chars` and
|
|
246
|
+
`context.max_jira_comment_chars` (`MAX_DIFF_CHARS`, `MAX_MR_DESCRIPTION_CHARS`,
|
|
247
|
+
`MAX_JIRA_DESCRIPTION_CHARS`, `MAX_JIRA_COMMENT_CHARS`). Only then is the diff
|
|
248
|
+
cut, and only by whole files and whole hunks: files in the order GitLab returns
|
|
249
|
+
them, a file that does not fit is shown hunk by hunk, everything after it is
|
|
250
|
+
left out, and a single hunk larger than the whole budget is skipped rather
|
|
251
|
+
than cut in the middle. Renames, mode changes and other files without text
|
|
252
|
+
changes are always listed; files whose diff GitLab did not return (too large,
|
|
253
|
+
binary) are listed as well and reported as not reviewed.
|
|
254
|
+
|
|
255
|
+
Everything that was cut is marked in the prompt, so the model knows that a
|
|
256
|
+
missing requirement or missing code may simply be outside the budget. The
|
|
257
|
+
review reports it too: the result line gets a `Partial review: ...` suffix
|
|
258
|
+
and a `Not reviewed` section lists the files and sections concerned. The
|
|
259
|
+
result itself (`ok` / `needs attention`) is still only about the findings.
|
|
260
|
+
|
|
261
|
+
When even one hunk cannot fit next to the system prompt, or the candidates
|
|
262
|
+
returned by Generate push the Critique request over its limit, the run stops
|
|
263
|
+
with an error instead of silently reviewing less. Raise the limits or extend
|
|
264
|
+
`ignore_paths`. `--dry-run` prints the sizes of every part and the coverage;
|
|
265
|
+
`--verbose` logs them during a real run.
|
|
266
|
+
|
|
222
267
|
## Usage
|
|
223
268
|
|
|
224
269
|
```bash
|
|
@@ -237,7 +282,7 @@ bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project
|
|
|
237
282
|
- `--critique-temperature VALUE` overrides the temperature for the Critique pass only.
|
|
238
283
|
- `--config PATH` points at a specific `.aireview.yml`.
|
|
239
284
|
- `--no-jira` turns off the Jira enrichment even when the MR carries an issue key.
|
|
240
|
-
- `--dry-run` prints the LLM settings and the Generate prompt, plus the Critique prompt unless `--no-critique` is given.
|
|
285
|
+
- `--dry-run` prints the LLM settings, the context sizes and coverage, and the Generate prompt, plus the Critique prompt unless `--no-critique` is given.
|
|
241
286
|
- `--no-critique` skips the second pass and renders the Generate candidates directly.
|
|
242
287
|
- `--review-mode MODE` sets the behaviour when a review has already been published: `update` or `once`.
|
|
243
288
|
- `--force` reviews again even when a review for this state of the MR is already published.
|
|
@@ -362,6 +407,7 @@ bundle _2.3.26_ exec rspec spec/secret_scrubber_spec.rb
|
|
|
362
407
|
|
|
363
408
|
- The reviewer does not check whether the specified versions of dependencies and images exist: the model's knowledge of releases is outdated, and that is what CI is for. Syntax errors and contradictions with the MR/Jira requirements are checked as usual.
|
|
364
409
|
- The CLI looks for `.aireview.yml` and `.env` walking up from the current working directory, so the project config can be kept in the repository root even when the tool is run from `aireview/`.
|
|
410
|
+
- How Ollama behaves when a request is still larger than its context window is up to the server, not to `aireview`: check the `ollama serve` log for truncation messages on your setup and size `max_prompt_chars` so it does not happen.
|
|
365
411
|
|
|
366
412
|
## Releasing
|
|
367
413
|
|
|
@@ -379,12 +425,18 @@ API key is stored anywhere. To cut a release:
|
|
|
379
425
|
```
|
|
380
426
|
|
|
381
427
|
The workflow refuses to run when the tag does not match `Aireview::VERSION`,
|
|
382
|
-
runs the test suite, builds the gem and
|
|
428
|
+
runs the test suite, builds the gem, pushes it and then creates a GitHub
|
|
429
|
+
release for the tag with the matching `CHANGELOG.md` section as its notes and
|
|
430
|
+
the built `.gem` attached.
|
|
383
431
|
|
|
384
432
|
## Changelog
|
|
385
433
|
|
|
386
434
|
See [CHANGELOG.md](CHANGELOG.md).
|
|
387
435
|
|
|
436
|
+
## Contributors
|
|
437
|
+
|
|
438
|
+
See [CONTRIBUTORS.md](CONTRIBUTORS.md).
|
|
439
|
+
|
|
388
440
|
## License
|
|
389
441
|
|
|
390
442
|
[MIT](LICENSE)
|
data/lib/aireview/cli.rb
CHANGED
|
@@ -82,7 +82,7 @@ module Aireview
|
|
|
82
82
|
parser_result: parser_result,
|
|
83
83
|
gitlab_client: gitlab_client,
|
|
84
84
|
merge_request: merge_request,
|
|
85
|
-
|
|
85
|
+
changes: prepare_changes(changes, config),
|
|
86
86
|
jira_issue: maybe_load_jira_issue(config, merge_request, options)
|
|
87
87
|
}
|
|
88
88
|
end
|
|
@@ -102,18 +102,18 @@ module Aireview
|
|
|
102
102
|
[merge_request, changes]
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
# Дифф уходит дальше по файлам, а не одной строкой: бюджет контекста
|
|
106
|
+
# режет его по границам файлов и хунков.
|
|
107
|
+
def prepare_changes(changes, config)
|
|
106
108
|
diff_fetcher = DiffFetcher.new(ignore_paths: config.ignore_paths, logger: @logger)
|
|
107
109
|
filtered_changes = diff_fetcher.filter(changes)
|
|
108
110
|
raise Error, 'No changes left after filtering ignore_paths' if filtered_changes.empty?
|
|
109
111
|
|
|
110
|
-
|
|
112
|
+
SecretScrubber.new(
|
|
111
113
|
secret_patterns: config.secret_patterns,
|
|
112
114
|
secret_files: config.secret_files,
|
|
113
115
|
logger: @logger
|
|
114
116
|
).scrub_changes(filtered_changes)
|
|
115
|
-
|
|
116
|
-
diff_fetcher.render(scrubbed_changes)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
def execute_review(config, context, options)
|
|
@@ -122,7 +122,7 @@ module Aireview
|
|
|
122
122
|
if options[:dry_run]
|
|
123
123
|
dry_run = pipeline.dry_run_prompts(
|
|
124
124
|
merge_request: context[:merge_request],
|
|
125
|
-
|
|
125
|
+
changes: context[:changes],
|
|
126
126
|
jira_issue: context[:jira_issue],
|
|
127
127
|
critique: !options[:no_critique]
|
|
128
128
|
)
|
|
@@ -135,7 +135,7 @@ module Aireview
|
|
|
135
135
|
|
|
136
136
|
review = pipeline.run(
|
|
137
137
|
merge_request: context[:merge_request],
|
|
138
|
-
|
|
138
|
+
changes: context[:changes],
|
|
139
139
|
jira_issue: context[:jira_issue],
|
|
140
140
|
critique: !options[:no_critique]
|
|
141
141
|
)
|
|
@@ -157,7 +157,7 @@ module Aireview
|
|
|
157
157
|
publisher = Publisher.new(gitlab_client: context[:gitlab_client], logger: @logger)
|
|
158
158
|
prompts = pipeline.dry_run_prompts(
|
|
159
159
|
merge_request: context[:merge_request],
|
|
160
|
-
|
|
160
|
+
changes: context[:changes],
|
|
161
161
|
jira_issue: context[:jira_issue],
|
|
162
162
|
critique: !options[:no_critique]
|
|
163
163
|
)
|
|
@@ -321,27 +321,7 @@ module Aireview
|
|
|
321
321
|
end
|
|
322
322
|
|
|
323
323
|
def render_dry_run(dry_run)
|
|
324
|
-
@out.
|
|
325
|
-
@out.puts("Generate: #{dry_run[:generate_model]} temperature=#{dry_run[:generate_temperature]}")
|
|
326
|
-
if dry_run[:critique_prompt]
|
|
327
|
-
@out.puts("Critique: #{dry_run[:critique_model]} temperature=#{dry_run[:critique_temperature]}")
|
|
328
|
-
else
|
|
329
|
-
@out.puts('Critique: disabled')
|
|
330
|
-
end
|
|
331
|
-
@out.puts
|
|
332
|
-
@out.puts('=== GENERATE SYSTEM PROMPT ===')
|
|
333
|
-
@out.puts(dry_run.dig(:generate_prompt, :system_prompt))
|
|
334
|
-
@out.puts
|
|
335
|
-
@out.puts('=== GENERATE USER PROMPT ===')
|
|
336
|
-
@out.puts(dry_run.dig(:generate_prompt, :user_prompt))
|
|
337
|
-
return unless dry_run[:critique_prompt]
|
|
338
|
-
|
|
339
|
-
@out.puts
|
|
340
|
-
@out.puts('=== CRITIQUE SYSTEM PROMPT ===')
|
|
341
|
-
@out.puts(dry_run.dig(:critique_prompt, :system_prompt))
|
|
342
|
-
@out.puts
|
|
343
|
-
@out.puts('=== CRITIQUE USER PROMPT ===')
|
|
344
|
-
@out.puts(dry_run.dig(:critique_prompt, :user_prompt))
|
|
324
|
+
DryRunReport.new(@out).render(dry_run)
|
|
345
325
|
end
|
|
346
326
|
|
|
347
327
|
def help
|
data/lib/aireview/config.rb
CHANGED
|
@@ -4,9 +4,13 @@ require 'pathname'
|
|
|
4
4
|
require 'yaml'
|
|
5
5
|
require_relative 'errors'
|
|
6
6
|
require_relative 'utils'
|
|
7
|
+
require_relative 'config_limits'
|
|
7
8
|
|
|
8
9
|
module Aireview
|
|
9
10
|
class Config
|
|
11
|
+
include ConfigLimits
|
|
12
|
+
extend ConfigLimits::ClassMethods
|
|
13
|
+
|
|
10
14
|
DEFAULT_SECRET_FILES = [
|
|
11
15
|
'.env',
|
|
12
16
|
'.env.*',
|
|
@@ -21,7 +25,6 @@ module Aireview
|
|
|
21
25
|
].freeze
|
|
22
26
|
|
|
23
27
|
REVIEW_MODES = %w[update once].freeze
|
|
24
|
-
|
|
25
28
|
DEFAULTS = {
|
|
26
29
|
'review_language' => 'en',
|
|
27
30
|
'review_mode' => 'update',
|
|
@@ -35,8 +38,10 @@ module Aireview
|
|
|
35
38
|
'llm' => {
|
|
36
39
|
'provider' => 'gemini',
|
|
37
40
|
'temperature' => 0,
|
|
38
|
-
'timeout' => 60
|
|
39
|
-
|
|
41
|
+
'timeout' => 60,
|
|
42
|
+
'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS
|
|
43
|
+
},
|
|
44
|
+
'context' => ConfigLimits::CONTEXT_DEFAULTS
|
|
40
45
|
}.freeze
|
|
41
46
|
|
|
42
47
|
ENV_MAPPING = {
|
|
@@ -97,6 +102,7 @@ module Aireview
|
|
|
97
102
|
def self.env_config(env)
|
|
98
103
|
mapped_env_config(env)
|
|
99
104
|
.merge('llm' => llm_env_config(env))
|
|
105
|
+
.merge(context_env_config(env))
|
|
100
106
|
.merge(provider_key_env_config(env))
|
|
101
107
|
.merge(generic_api_key_env_config(env))
|
|
102
108
|
end
|
|
@@ -113,6 +119,7 @@ module Aireview
|
|
|
113
119
|
'provider' => env['LLM_PROVIDER'],
|
|
114
120
|
'temperature' => parse_float(env['LLM_TEMPERATURE']),
|
|
115
121
|
'timeout' => parse_float(env['LLM_TIMEOUT']),
|
|
122
|
+
'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
|
|
116
123
|
'generate' => llm_stage_env_config(env, 'GENERATE'),
|
|
117
124
|
'critique' => llm_stage_env_config(env, 'CRITIQUE')
|
|
118
125
|
}.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
|
|
@@ -122,7 +129,8 @@ module Aireview
|
|
|
122
129
|
{
|
|
123
130
|
'provider' => env["LLM_#{stage}_PROVIDER"],
|
|
124
131
|
'model' => env["LLM_#{stage}_MODEL"],
|
|
125
|
-
'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"])
|
|
132
|
+
'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
|
|
133
|
+
'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS")
|
|
126
134
|
}.compact
|
|
127
135
|
end
|
|
128
136
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aireview
|
|
4
|
+
# Лимиты контекста в символах: точного токенизатора для провайдеров локально
|
|
5
|
+
# нет, а окно Ollama задаётся на сервере и клиенту не видно. Дефолты щедрые,
|
|
6
|
+
# под конкретную модель их задают в .aireview.yml.
|
|
7
|
+
module ConfigLimits
|
|
8
|
+
LLM_STAGES = %w[generate critique].freeze
|
|
9
|
+
DEFAULT_MAX_PROMPT_CHARS = 400_000
|
|
10
|
+
CONTEXT_DEFAULTS = {
|
|
11
|
+
'max_diff_chars' => 120_000,
|
|
12
|
+
'max_mr_description_chars' => 8_000,
|
|
13
|
+
'max_jira_description_chars' => 8_000,
|
|
14
|
+
'max_jira_comment_chars' => 2_000
|
|
15
|
+
}.freeze
|
|
16
|
+
CONTEXT_ENV = {
|
|
17
|
+
'max_diff_chars' => 'MAX_DIFF_CHARS',
|
|
18
|
+
'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS',
|
|
19
|
+
'max_jira_description_chars' => 'MAX_JIRA_DESCRIPTION_CHARS',
|
|
20
|
+
'max_jira_comment_chars' => 'MAX_JIRA_COMMENT_CHARS'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
module ClassMethods
|
|
24
|
+
def context_env_config(env)
|
|
25
|
+
context = CONTEXT_ENV.each_with_object({}) do |(key, env_key), config|
|
|
26
|
+
value = parse_integer(env[env_key], env_key)
|
|
27
|
+
config[key] = value unless value.nil?
|
|
28
|
+
end
|
|
29
|
+
context.empty? ? {} : {'context' => context}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Лимит, который не разобрался, нельзя молча заменять дефолтом: запрос
|
|
33
|
+
# уйдёт в модель с окном, которого у неё нет.
|
|
34
|
+
def parse_integer(value, name)
|
|
35
|
+
return nil if Aireview::Utils.blank?(value)
|
|
36
|
+
|
|
37
|
+
Integer(value.to_s, 10)
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
raise ConfigError, "#{name} must be an integer, got #{value.inspect}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Лимит всего запроса стадии в символах: системный промпт плюс контекст
|
|
44
|
+
# (для критика ещё и кандидаты). Наследуется из llm как model/temperature.
|
|
45
|
+
def max_prompt_chars(stage)
|
|
46
|
+
stage = stage.to_s
|
|
47
|
+
raise ArgumentError, "unknown LLM stage #{stage.inspect}" unless LLM_STAGES.include?(stage)
|
|
48
|
+
|
|
49
|
+
positive_integer!(
|
|
50
|
+
dig('llm', stage, 'max_prompt_chars') || dig('llm', 'max_prompt_chars') || DEFAULT_MAX_PROMPT_CHARS,
|
|
51
|
+
"llm.#{stage}.max_prompt_chars"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def max_diff_chars
|
|
56
|
+
context_limit('max_diff_chars')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def max_mr_description_chars
|
|
60
|
+
context_limit('max_mr_description_chars')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def max_jira_description_chars
|
|
64
|
+
context_limit('max_jira_description_chars')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def max_jira_comment_chars
|
|
68
|
+
context_limit('max_jira_comment_chars')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def context_limit(key)
|
|
74
|
+
positive_integer!(dig('context', key) || CONTEXT_DEFAULTS.fetch(key), "context.#{key}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def positive_integer!(value, name)
|
|
78
|
+
integer = Integer(value, exception: false) if value.is_a?(Integer) || value.is_a?(String)
|
|
79
|
+
integer = value.to_i if value.is_a?(Float) && value == value.floor
|
|
80
|
+
return integer if integer.is_a?(Integer) && integer.positive?
|
|
81
|
+
|
|
82
|
+
raise ConfigError, "#{name} must be a positive integer, got #{value.inspect}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'errors'
|
|
3
|
+
|
|
4
|
+
module Aireview
|
|
5
|
+
# Укладывает контекст ревью в бюджет символов и запоминает, что при этом не
|
|
6
|
+
# вошло. Секции MR и Jira режутся до своих лимитов с сохранением начала,
|
|
7
|
+
# дифф по целым файлам, затем по целым хункам; внутри хунка не режем.
|
|
8
|
+
module ContextBudget
|
|
9
|
+
# Пути, которые не вошли, перечисляются в конце диффа; список ограничен,
|
|
10
|
+
# чтобы сам не съел бюджет.
|
|
11
|
+
NOT_SHOWN_LIST_LIMIT = 20
|
|
12
|
+
TRAILER_RESERVE_CHARS = 400
|
|
13
|
+
|
|
14
|
+
Coverage = Struct.new(
|
|
15
|
+
:truncated_sections, :files_not_shown, :files_partial, :files_unavailable, :hunks_skipped,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
) do
|
|
18
|
+
def self.empty
|
|
19
|
+
new(truncated_sections: [], files_not_shown: [], files_partial: [], files_unavailable: [], hunks_skipped: [])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def complete?
|
|
23
|
+
to_h.values.all?(&:empty?)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Packed = Struct.new(:text, :shown_hunks, :total_hunks, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# Начало важнее конца: требования и критерии приёмки обычно там.
|
|
30
|
+
def self.truncate_section(text, limit:, label:, coverage:)
|
|
31
|
+
text = text.to_s
|
|
32
|
+
return text if text.length <= limit
|
|
33
|
+
|
|
34
|
+
coverage.truncated_sections << label
|
|
35
|
+
"#{text[0, limit]}\n[#{label} truncated: #{limit} of #{text.length} chars shown]"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.pack_entries(entries, budget:, coverage:)
|
|
39
|
+
Packer.new(entries, budget: budget, coverage: coverage).pack
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Файлы без хунков идут первыми: они дёшевы и всегда полезны для картины
|
|
43
|
+
# MR. Текстовые файлы идут в порядке GitLab, пока влезают; первый файл,
|
|
44
|
+
# который не влезает, показывается частично, всё после него не показывается.
|
|
45
|
+
# Хунк, который не влез бы даже в пустой бюджет, пропускается с пометкой,
|
|
46
|
+
# а не останавливает раскладку.
|
|
47
|
+
class Packer
|
|
48
|
+
def initialize(entries, budget:, coverage:)
|
|
49
|
+
@non_text, @text = entries.partition { |entry| !entry.text? }
|
|
50
|
+
@budget = budget
|
|
51
|
+
@coverage = coverage
|
|
52
|
+
@total_hunks = @text.sum { |entry| entry.hunks.size }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pack
|
|
56
|
+
@non_text.each { |entry| @coverage.files_unavailable << entry.path if entry.unavailable? }
|
|
57
|
+
full = (@non_text + @text).map(&:render).join("\n")
|
|
58
|
+
return Packed.new(text: full, shown_hunks: @total_hunks, total_hunks: @total_hunks) if full.length <= @budget
|
|
59
|
+
|
|
60
|
+
pack_within_limit
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Что-то придётся опустить, значит нужен хвост со списком пропущенного.
|
|
66
|
+
# @used считает весь собранный текст, включая разделители между
|
|
67
|
+
# файлами: результат не должен выйти за бюджет ни на символ.
|
|
68
|
+
def pack_within_limit
|
|
69
|
+
@parts = @non_text.map(&:render)
|
|
70
|
+
@used = joined_length(@parts)
|
|
71
|
+
@limit = @budget - TRAILER_RESERVE_CHARS
|
|
72
|
+
raise_no_room!(:non_text) if @used > @limit
|
|
73
|
+
|
|
74
|
+
shown_hunks = pack_text_entries
|
|
75
|
+
raise_no_room!(:hunks) if shown_hunks.zero? && @total_hunks.positive?
|
|
76
|
+
|
|
77
|
+
@parts << not_shown_trailer unless @coverage.files_not_shown.empty?
|
|
78
|
+
Packed.new(text: @parts.join("\n"), shown_hunks: shown_hunks, total_hunks: @total_hunks)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def pack_text_entries
|
|
82
|
+
shown_hunks = 0
|
|
83
|
+
stopped = false
|
|
84
|
+
@text.each do |entry|
|
|
85
|
+
piece, shown, stopped = stopped ? ['', 0, true] : pack_entry(entry)
|
|
86
|
+
if shown.zero?
|
|
87
|
+
@coverage.files_not_shown << entry.path
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@parts << piece
|
|
92
|
+
@used = joined_length(@parts)
|
|
93
|
+
shown_hunks += shown
|
|
94
|
+
end
|
|
95
|
+
shown_hunks
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Место под следующий кусок с учётом разделителя перед ним.
|
|
99
|
+
def remaining
|
|
100
|
+
@limit - @used - (@parts.empty? ? 0 : 1)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Возвращает [текст, число показанных хунков, остановлена ли раскладка].
|
|
104
|
+
def pack_entry(entry)
|
|
105
|
+
full = entry.render
|
|
106
|
+
return [full, entry.hunks.size, false] if full.length <= remaining
|
|
107
|
+
|
|
108
|
+
body, shown, skipped, stopped = pack_hunks(entry)
|
|
109
|
+
return ['', 0, stopped] if shown.zero?
|
|
110
|
+
|
|
111
|
+
skipped.each { |hunk| @coverage.hunks_skipped << {path: entry.path, hunk: hunk} }
|
|
112
|
+
@coverage.files_partial << {path: entry.path, shown: shown, total: entry.hunks.size}
|
|
113
|
+
[entry.header + body + partial_marker(entry, shown), shown, stopped]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Место сначала отдаётся хункам, которые можно показать, и только на
|
|
117
|
+
# остаток добавляются пометки о слишком больших: иначе пометки могли бы
|
|
118
|
+
# вытеснить единственный подходящий хунк. Факт пропуска в покрытие
|
|
119
|
+
# попадает независимо от того, есть ли для пометки место.
|
|
120
|
+
def pack_hunks(entry)
|
|
121
|
+
base = entry.header.length + partial_marker(entry, 0).length
|
|
122
|
+
shown = []
|
|
123
|
+
skipped = []
|
|
124
|
+
stopped = false
|
|
125
|
+
used = 0
|
|
126
|
+
entry.hunks.each_with_index do |hunk, index|
|
|
127
|
+
if base + hunk.length > @limit
|
|
128
|
+
skipped << index
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
if base + used + hunk.length > remaining
|
|
132
|
+
stopped = true
|
|
133
|
+
break
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
shown << index
|
|
137
|
+
used += hunk.length
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
body = render_hunks(entry, shown: shown, skipped: skipped, room: remaining - base - used)
|
|
141
|
+
[body, shown.size, skipped.map { |index| index + 1 }, stopped]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def render_hunks(entry, shown:, skipped:, room:)
|
|
145
|
+
marked = skipped.select do |index|
|
|
146
|
+
marker = skip_marker(entry, index)
|
|
147
|
+
next false if marker.length > room
|
|
148
|
+
|
|
149
|
+
room -= marker.length
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
entry.hunks.each_with_index.filter_map do |hunk, index|
|
|
153
|
+
next hunk if shown.include?(index)
|
|
154
|
+
|
|
155
|
+
skip_marker(entry, index) if marked.include?(index)
|
|
156
|
+
end.join
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def skip_marker(entry, index)
|
|
160
|
+
"[hunk #{index + 1} of #{entry.hunks.size} skipped: larger than the context budget]\n"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def joined_length(parts)
|
|
164
|
+
parts.sum(&:length) + [parts.size - 1, 0].max
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def partial_marker(entry, shown)
|
|
168
|
+
"[file #{entry.path}: #{shown} of #{entry.hunks.size} hunks shown]\n"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def not_shown_trailer
|
|
172
|
+
paths = @coverage.files_not_shown
|
|
173
|
+
listed = []
|
|
174
|
+
paths.first(NOT_SHOWN_LIST_LIMIT).each do |path|
|
|
175
|
+
break if listed.sum(&:length) + path.length > TRAILER_RESERVE_CHARS / 2
|
|
176
|
+
|
|
177
|
+
listed << path
|
|
178
|
+
end
|
|
179
|
+
rest = paths.size - listed.size
|
|
180
|
+
list = listed.join(', ')
|
|
181
|
+
list += " and #{rest} more" if rest.positive?
|
|
182
|
+
"[#{paths.size} file(s) not shown: #{list}]\n"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def raise_no_room!(reason)
|
|
186
|
+
detail = if reason == :non_text
|
|
187
|
+
'the entries for files without text changes alone exceed it'
|
|
188
|
+
else
|
|
189
|
+
"not a single hunk fits after #{@used} chars of entries without text changes"
|
|
190
|
+
end
|
|
191
|
+
raise ContextBudgetError,
|
|
192
|
+
"Diff does not fit into the context budget of #{@budget} chars: #{detail}. " \
|
|
193
|
+
'Raise llm.max_prompt_chars / context.max_diff_chars, or add paths to ignore_paths.'
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require_relative 'utils'
|
|
3
|
+
require_relative 'errors'
|
|
3
4
|
require_relative 'secret_scrubber'
|
|
5
|
+
require_relative 'diff_fetcher'
|
|
6
|
+
require_relative 'context_budget'
|
|
4
7
|
|
|
5
8
|
module Aireview
|
|
6
9
|
class ContextBuilder
|
|
7
|
-
MAX_DIFF_CHARS = 120_000
|
|
8
10
|
GENERATE_PROMPT_TEMPLATE = File.read(File.expand_path('prompts/generate.txt', __dir__)).strip.freeze
|
|
9
11
|
CRITIQUE_PROMPT_TEMPLATE = File.read(File.expand_path('prompts/critique.txt', __dir__)).strip.freeze
|
|
10
12
|
LANGUAGE_NAMES = {
|
|
11
13
|
'ru' => 'Russian',
|
|
12
14
|
'en' => 'English'
|
|
13
15
|
}.freeze
|
|
16
|
+
CHANGES_HEADER = "Changes:\n"
|
|
17
|
+
CANDIDATES_HEADER = "\n\nCandidates JSON from Generate:\n"
|
|
18
|
+
# Резерв под кандидатов в промпте критика: три кандидата по ~1 500
|
|
19
|
+
# символов. Оценка, не гарантия; фактический размер проверяется перед
|
|
20
|
+
# отправкой.
|
|
21
|
+
CANDIDATES_RESERVE_CHARS = 4_500
|
|
22
|
+
STAGES = %i[generate critique].freeze
|
|
23
|
+
|
|
24
|
+
# Контекст одного прогона: обе стадии получают одинаковые MR, Jira и дифф,
|
|
25
|
+
# усечённые один раз под самую тесную из стадий.
|
|
26
|
+
Context = Struct.new(:user_prompt, :coverage, :sizes, keyword_init: true)
|
|
14
27
|
|
|
15
28
|
def initialize(config:, logger: Logger.new($stderr))
|
|
16
29
|
@config = config
|
|
@@ -20,32 +33,36 @@ module Aireview
|
|
|
20
33
|
secret_files: config.secret_files,
|
|
21
34
|
logger: logger
|
|
22
35
|
)
|
|
36
|
+
@diff_fetcher = DiffFetcher.new(ignore_paths: [], logger: logger)
|
|
23
37
|
end
|
|
24
38
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
def prepare(merge_request:, changes:, jira_issue: nil, critique: true)
|
|
40
|
+
coverage = ContextBudget::Coverage.empty
|
|
41
|
+
budget = context_budget(critique: critique)
|
|
42
|
+
sections = merge_request_sections(merge_request, coverage: coverage)
|
|
43
|
+
sections << jira_section(jira_issue, coverage: coverage) if jira_issue
|
|
44
|
+
fixed = "#{sections.join("\n\n")}\n\n#{CHANGES_HEADER}"
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
user_prompt: user_prompt(merge_request: merge_request, changes_text: changes_text, jira_issue: jira_issue)
|
|
33
|
-
}
|
|
34
|
-
end
|
|
46
|
+
diff_budget = [budget - fixed.length, @config.max_diff_chars].min
|
|
47
|
+
entries = @diff_fetcher.entries(@secret_scrubber.scrub_changes(changes))
|
|
48
|
+
packed = ContextBudget.pack_entries(entries, budget: diff_budget, coverage: coverage)
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
sizes = context_sizes(fixed: fixed, packed: packed, budget: budget, diff_budget: diff_budget, critique: critique)
|
|
51
|
+
log_sizes(sizes)
|
|
52
|
+
Context.new(user_prompt: fixed + packed.text, coverage: coverage, sizes: sizes)
|
|
53
|
+
end
|
|
39
54
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
user_prompt: user
|
|
43
|
-
}
|
|
55
|
+
def build_generate_prompt(context)
|
|
56
|
+
check_stage_size!(:generate, system_prompt(:generate), context.user_prompt)
|
|
44
57
|
end
|
|
45
58
|
|
|
46
|
-
|
|
59
|
+
def build_critique_prompt(context, candidates_json:)
|
|
60
|
+
user = "#{context.user_prompt}#{CANDIDATES_HEADER}#{scrub_text(candidates_json)}"
|
|
61
|
+
check_stage_size!(:critique, system_prompt(:critique), user)
|
|
62
|
+
end
|
|
47
63
|
|
|
48
|
-
def system_prompt(
|
|
64
|
+
def system_prompt(stage)
|
|
65
|
+
template = stage.to_sym == :critique ? CRITIQUE_PROMPT_TEMPLATE : GENERATE_PROMPT_TEMPLATE
|
|
49
66
|
extras = []
|
|
50
67
|
if Aireview::Utils.present?(@config.review_instructions)
|
|
51
68
|
extras << "Additional project instructions:\n#{scrub_text(@config.review_instructions.strip)}"
|
|
@@ -55,44 +72,113 @@ module Aireview
|
|
|
55
72
|
[template, *extras].join("\n\n")
|
|
56
73
|
end
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
# Проверка перед отправкой: если кандидаты вышли за резерв и запрос не
|
|
76
|
+
# помещается, это ошибка, а не повод молча резать контекст, который
|
|
77
|
+
# генератор уже видел.
|
|
78
|
+
def check_stage_size!(stage, system, user)
|
|
79
|
+
limit = @config.max_prompt_chars(stage)
|
|
80
|
+
total = system.length + user.length
|
|
81
|
+
if total > limit
|
|
82
|
+
raise ContextBudgetError,
|
|
83
|
+
"#{stage.capitalize} request is #{total} chars, over llm.#{stage}.max_prompt_chars=#{limit} " \
|
|
84
|
+
"(system prompt #{system.length}, context #{user.length})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
{system_prompt: system, user_prompt: user}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Минимум по стадиям: контекст один на прогон, поэтому он должен
|
|
93
|
+
# помещаться в каждую из них вместе с её системным промптом и резервом.
|
|
94
|
+
def context_budget(critique:)
|
|
95
|
+
stages = critique ? STAGES : [:generate]
|
|
96
|
+
budgets = stages.to_h { |stage| [stage, stage_budget(stage)] }
|
|
97
|
+
stage, budget = budgets.min_by { |_, value| value }
|
|
98
|
+
return budget if budget.positive?
|
|
99
|
+
|
|
100
|
+
raise ContextBudgetError,
|
|
101
|
+
"System prompt of the #{stage} stage (#{system_prompt(stage).length} chars, including " \
|
|
102
|
+
"review_instructions) leaves no room for the merge request within llm.#{stage}.max_prompt_chars=" \
|
|
103
|
+
"#{@config.max_prompt_chars(stage)}"
|
|
104
|
+
end
|
|
60
105
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
sections.join("\n\n")
|
|
106
|
+
def stage_budget(stage)
|
|
107
|
+
reserve = stage == :critique ? CANDIDATES_RESERVE_CHARS + CANDIDATES_HEADER.length : 0
|
|
108
|
+
@config.max_prompt_chars(stage) - system_prompt(stage).length - reserve
|
|
65
109
|
end
|
|
66
110
|
|
|
67
|
-
def merge_request_sections(merge_request)
|
|
111
|
+
def merge_request_sections(merge_request, coverage:)
|
|
68
112
|
source_branch = scrub_text(merge_request['source_branch'])
|
|
69
113
|
target_branch = scrub_text(merge_request['target_branch'])
|
|
114
|
+
description = ContextBudget.truncate_section(
|
|
115
|
+
scrub_optional_text(merge_request['description']),
|
|
116
|
+
limit: @config.max_mr_description_chars,
|
|
117
|
+
label: 'MR description',
|
|
118
|
+
coverage: coverage
|
|
119
|
+
)
|
|
70
120
|
|
|
71
121
|
[
|
|
72
122
|
"MR: #{scrub_text(merge_request['title'])}",
|
|
73
123
|
"Author: #{scrub_text(merge_request.dig('author', 'name'))}",
|
|
74
124
|
"Branch: #{source_branch} -> #{target_branch}",
|
|
75
|
-
"Description:\n#{
|
|
125
|
+
"Description:\n#{description}"
|
|
76
126
|
]
|
|
77
127
|
end
|
|
78
128
|
|
|
79
|
-
def jira_section(jira_issue)
|
|
129
|
+
def jira_section(jira_issue, coverage:)
|
|
130
|
+
description = ContextBudget.truncate_section(
|
|
131
|
+
scrub_optional_text(jira_issue['description']),
|
|
132
|
+
limit: @config.max_jira_description_chars,
|
|
133
|
+
label: 'Jira description',
|
|
134
|
+
coverage: coverage
|
|
135
|
+
)
|
|
80
136
|
section = "Jira task (#{scrub_text(jira_issue['key'])}):\n"
|
|
81
137
|
section << "Summary: #{scrub_text(jira_issue['summary'])}\n"
|
|
82
|
-
section << "Description:\n#{
|
|
83
|
-
|
|
84
|
-
comments = jira_issue['comments']
|
|
85
|
-
return section
|
|
86
|
-
|
|
87
|
-
|
|
138
|
+
section << "Description:\n#{description}"
|
|
139
|
+
|
|
140
|
+
comments = Array(jira_issue['comments'])
|
|
141
|
+
return section if comments.empty?
|
|
142
|
+
|
|
143
|
+
rendered = comments.each_with_index.map do |comment, index|
|
|
144
|
+
ContextBudget.truncate_section(
|
|
145
|
+
scrub_text(comment),
|
|
146
|
+
limit: @config.max_jira_comment_chars,
|
|
147
|
+
label: "Jira comment #{index + 1}",
|
|
148
|
+
coverage: coverage
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
section << "\nRecent comments:\n#{rendered.join("\n")}"
|
|
88
152
|
end
|
|
89
153
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
154
|
+
def context_sizes(fixed:, packed:, budget:, diff_budget:, critique:)
|
|
155
|
+
stages = critique ? STAGES : [:generate]
|
|
156
|
+
{
|
|
157
|
+
context_budget: budget,
|
|
158
|
+
diff_budget: diff_budget,
|
|
159
|
+
sections: fixed.length,
|
|
160
|
+
diff: packed.text.length,
|
|
161
|
+
hunks_shown: packed.shown_hunks,
|
|
162
|
+
hunks_total: packed.total_hunks,
|
|
163
|
+
stages: stages.to_h do |stage|
|
|
164
|
+
system = system_prompt(stage).length
|
|
165
|
+
[stage, {system_prompt: system, request: system + fixed.length + packed.text.length,
|
|
166
|
+
max_prompt_chars: @config.max_prompt_chars(stage)}]
|
|
167
|
+
end
|
|
168
|
+
}
|
|
169
|
+
end
|
|
93
170
|
|
|
94
|
-
|
|
95
|
-
|
|
171
|
+
def log_sizes(sizes)
|
|
172
|
+
@logger.debug(
|
|
173
|
+
"Context: sections #{sizes[:sections]} chars, diff #{sizes[:diff]} chars " \
|
|
174
|
+
"(budget #{sizes[:diff_budget]}, hunks #{sizes[:hunks_shown]}/#{sizes[:hunks_total]})"
|
|
175
|
+
)
|
|
176
|
+
sizes[:stages].each do |stage, stage_sizes|
|
|
177
|
+
@logger.debug(
|
|
178
|
+
"Context #{stage}: request #{stage_sizes[:request]} chars (~#{stage_sizes[:request] / 4} tokens) " \
|
|
179
|
+
"of max #{stage_sizes[:max_prompt_chars]}, system prompt #{stage_sizes[:system_prompt]}"
|
|
180
|
+
)
|
|
181
|
+
end
|
|
96
182
|
end
|
|
97
183
|
|
|
98
184
|
def scrub_optional_text(text)
|
|
@@ -3,7 +3,80 @@ require_relative 'utils'
|
|
|
3
3
|
|
|
4
4
|
module Aireview
|
|
5
5
|
class DiffFetcher
|
|
6
|
-
|
|
6
|
+
NO_TEXT_CHANGES = '[no text changes]'
|
|
7
|
+
DIFF_UNAVAILABLE = '[diff not available]'
|
|
8
|
+
BINARY_DIFF = /\ABinary files .* differ/
|
|
9
|
+
|
|
10
|
+
# Один файл из ответа GitLab: заголовок, хунки и что с ним можно делать.
|
|
11
|
+
# kind:
|
|
12
|
+
# :text есть хунки, код можно проверить;
|
|
13
|
+
# :no_text_changes переименование, смена режима, пустой файл: проверять
|
|
14
|
+
# нечего;
|
|
15
|
+
# :unavailable GitLab не отдал дифф (too_large, бинарник, пустой
|
|
16
|
+
# дифф без причины): код есть, но проверить его не удалось.
|
|
17
|
+
class Entry
|
|
18
|
+
attr_reader :path, :kind, :header, :hunks
|
|
19
|
+
|
|
20
|
+
def initialize(change)
|
|
21
|
+
old_path = change['old_path'] || change['new_path']
|
|
22
|
+
new_path = change['new_path'] || change['old_path']
|
|
23
|
+
@path = new_path
|
|
24
|
+
@header = "diff --git a/#{old_path} b/#{new_path}\n--- a/#{old_path}\n+++ b/#{new_path}\n"
|
|
25
|
+
diff = change['diff'].to_s
|
|
26
|
+
@kind = classify(change, diff)
|
|
27
|
+
@hunks = @kind == :text ? split_hunks(diff) : []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def text?
|
|
31
|
+
kind == :text
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unavailable?
|
|
35
|
+
kind == :unavailable
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def body
|
|
39
|
+
case kind
|
|
40
|
+
when :text then hunks.join
|
|
41
|
+
when :no_text_changes then "#{NO_TEXT_CHANGES}\n"
|
|
42
|
+
else "#{DIFF_UNAVAILABLE}\n"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render
|
|
47
|
+
header + body
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Пустой дифф без объяснимой причины (переименование, смена режима,
|
|
53
|
+
# пустой новый или удалённый файл) считаем недоступным: код есть, но
|
|
54
|
+
# GitLab его не отдал.
|
|
55
|
+
def classify(change, diff)
|
|
56
|
+
return :unavailable if change['too_large']
|
|
57
|
+
return :unavailable if diff.match?(BINARY_DIFF)
|
|
58
|
+
return :text unless diff.strip.empty?
|
|
59
|
+
|
|
60
|
+
empty_diff_explained?(change) ? :no_text_changes : :unavailable
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def empty_diff_explained?(change)
|
|
64
|
+
return true if %w[renamed_file new_file deleted_file].any? { |flag| change[flag] }
|
|
65
|
+
|
|
66
|
+
modes = change.values_at('a_mode', 'b_mode')
|
|
67
|
+
modes.none?(&:nil?) && modes.uniq.size == 2
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Хунки режем по заголовкам @@; текст до первого @@ (или дифф без них,
|
|
71
|
+
# например заглушка про секретный файл) считается одним хунком.
|
|
72
|
+
def split_hunks(diff)
|
|
73
|
+
diff = "#{diff}\n" unless diff.end_with?("\n")
|
|
74
|
+
pieces = diff.split(/^(?=@@ )/)
|
|
75
|
+
return pieces if pieces.size <= 1 || pieces.first.start_with?('@@ ')
|
|
76
|
+
|
|
77
|
+
[pieces[0] + pieces[1], *pieces[2..]]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
7
80
|
|
|
8
81
|
def initialize(ignore_paths:, logger: Logger.new($stderr))
|
|
9
82
|
@ignore_paths = Array(ignore_paths).compact
|
|
@@ -16,8 +89,12 @@ module Aireview
|
|
|
16
89
|
end
|
|
17
90
|
end
|
|
18
91
|
|
|
92
|
+
def entries(changes)
|
|
93
|
+
Array(changes).map { |change| Entry.new(change) }
|
|
94
|
+
end
|
|
95
|
+
|
|
19
96
|
def render(changes)
|
|
20
|
-
|
|
97
|
+
entries(changes).map(&:render).join("\n")
|
|
21
98
|
end
|
|
22
99
|
|
|
23
100
|
private
|
|
@@ -29,19 +106,5 @@ module Aireview
|
|
|
29
106
|
File.fnmatch?(pattern, path, File::FNM_DOTMATCH | File::FNM_EXTGLOB)
|
|
30
107
|
end
|
|
31
108
|
end
|
|
32
|
-
|
|
33
|
-
def render_change(change)
|
|
34
|
-
old_path = change['old_path'] || change['new_path']
|
|
35
|
-
new_path = change['new_path'] || change['old_path']
|
|
36
|
-
diff = change['diff'].to_s
|
|
37
|
-
diff = DIFF_UNAVAILABLE if diff.strip.empty?
|
|
38
|
-
|
|
39
|
-
<<~DIFF
|
|
40
|
-
diff --git a/#{old_path} b/#{new_path}
|
|
41
|
-
--- a/#{old_path}
|
|
42
|
-
+++ b/#{new_path}
|
|
43
|
-
#{diff}
|
|
44
|
-
DIFF
|
|
45
|
-
end
|
|
46
109
|
end
|
|
47
110
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aireview
|
|
4
|
+
# Вывод --dry-run: настройки, сводка контекста и промпты обеих стадий.
|
|
5
|
+
class DryRunReport
|
|
6
|
+
def initialize(out)
|
|
7
|
+
@out = out
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render(dry_run)
|
|
11
|
+
@out.puts('=== LLM SETTINGS ===')
|
|
12
|
+
@out.puts("Generate: #{dry_run[:generate_model]} temperature=#{dry_run[:generate_temperature]}")
|
|
13
|
+
if dry_run[:critique_prompt]
|
|
14
|
+
@out.puts("Critique: #{dry_run[:critique_model]} temperature=#{dry_run[:critique_temperature]}")
|
|
15
|
+
else
|
|
16
|
+
@out.puts('Critique: disabled')
|
|
17
|
+
end
|
|
18
|
+
@out.puts
|
|
19
|
+
@out.puts('=== CONTEXT ===')
|
|
20
|
+
render_context_sizes(dry_run[:sizes])
|
|
21
|
+
render_coverage(dry_run[:coverage])
|
|
22
|
+
@out.puts
|
|
23
|
+
@out.puts('=== GENERATE SYSTEM PROMPT ===')
|
|
24
|
+
@out.puts(dry_run.dig(:generate_prompt, :system_prompt))
|
|
25
|
+
@out.puts
|
|
26
|
+
@out.puts('=== GENERATE USER PROMPT ===')
|
|
27
|
+
@out.puts(dry_run.dig(:generate_prompt, :user_prompt))
|
|
28
|
+
return unless dry_run[:critique_prompt]
|
|
29
|
+
|
|
30
|
+
@out.puts
|
|
31
|
+
@out.puts('=== CRITIQUE SYSTEM PROMPT ===')
|
|
32
|
+
@out.puts(dry_run.dig(:critique_prompt, :system_prompt))
|
|
33
|
+
@out.puts
|
|
34
|
+
@out.puts('=== CRITIQUE USER PROMPT ===')
|
|
35
|
+
@out.puts(dry_run.dig(:critique_prompt, :user_prompt))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def render_context_sizes(sizes)
|
|
41
|
+
@out.puts("Sections: #{sizes[:sections]} chars, diff: #{sizes[:diff]} chars " \
|
|
42
|
+
"(budget #{sizes[:diff_budget]}, hunks #{sizes[:hunks_shown]}/#{sizes[:hunks_total]})")
|
|
43
|
+
sizes[:stages].each do |stage, stage_sizes|
|
|
44
|
+
@out.puts("#{stage.capitalize} request: #{stage_sizes[:request]} chars " \
|
|
45
|
+
"(~#{stage_sizes[:request] / 4} tokens) of max #{stage_sizes[:max_prompt_chars]}, " \
|
|
46
|
+
"system prompt #{stage_sizes[:system_prompt]}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_coverage(coverage)
|
|
51
|
+
return @out.puts('Coverage: complete') if coverage.complete?
|
|
52
|
+
|
|
53
|
+
@out.puts('Coverage: partial')
|
|
54
|
+
list('truncated sections', coverage.truncated_sections)
|
|
55
|
+
list('files not shown', coverage.files_not_shown)
|
|
56
|
+
coverage.files_partial.each { |file| @out.puts(" #{file[:path]}: #{file[:shown]} of #{file[:total]} hunks") }
|
|
57
|
+
list('diff not available', coverage.files_unavailable)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def list(title, items)
|
|
61
|
+
@out.puts(" #{title}: #{items.join(', ')}") unless items.empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/aireview/errors.rb
CHANGED
|
@@ -4,6 +4,10 @@ You are given the diff, the MR description, the Jira context and the candidates
|
|
|
4
4
|
from the first pass. For every candidate return a verdict with decision=keep
|
|
5
5
|
or reject.
|
|
6
6
|
|
|
7
|
+
Parts of the context may be truncated to fit the budget; markers in square
|
|
8
|
+
brackets show where. Missing text in a truncated part does not mean a missing
|
|
9
|
+
requirement or missing code.
|
|
10
|
+
|
|
7
11
|
Be a strict filter:
|
|
8
12
|
- when in doubt, choose reject;
|
|
9
13
|
- keep only the most important and well-supported findings;
|
|
@@ -3,6 +3,10 @@ You are doing the first pass of a merge request review.
|
|
|
3
3
|
Look only at the diff, the MR description and the Jira context, if present.
|
|
4
4
|
Do not draw conclusions about code outside the diff.
|
|
5
5
|
|
|
6
|
+
Parts of the context may be truncated to fit the budget; markers in square
|
|
7
|
+
brackets show where. Missing text in a truncated part does not mean a missing
|
|
8
|
+
requirement or missing code.
|
|
9
|
+
|
|
6
10
|
Do not verify that the specified versions of packages, libraries, tools and
|
|
7
11
|
image tags exist. Do not report that a version does not exist or has not been
|
|
8
12
|
released yet. Check syntax errors and explicit contradictions with the MR/Jira
|
|
@@ -28,12 +28,14 @@ module Aireview
|
|
|
28
28
|
@logger = logger
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def run(merge_request:,
|
|
32
|
-
|
|
31
|
+
def run(merge_request:, changes:, jira_issue: nil, critique: true)
|
|
32
|
+
context = @context_builder.prepare(
|
|
33
33
|
merge_request: merge_request,
|
|
34
|
-
|
|
35
|
-
jira_issue: jira_issue
|
|
34
|
+
changes: changes,
|
|
35
|
+
jira_issue: jira_issue,
|
|
36
|
+
critique: critique
|
|
36
37
|
)
|
|
38
|
+
generate_prompt = @context_builder.build_generate_prompt(context)
|
|
37
39
|
@logger.info("Pipeline generate pass started (model=#{@config.generate_model})")
|
|
38
40
|
candidates_raw = @reviewer.generate(**generate_prompt)
|
|
39
41
|
generate_result = parse_with_repair(
|
|
@@ -48,12 +50,7 @@ module Aireview
|
|
|
48
50
|
|
|
49
51
|
accepted = if critique
|
|
50
52
|
@logger.info("Pipeline critique pass started (model=#{@config.critique_model})")
|
|
51
|
-
critique_candidates(
|
|
52
|
-
merge_request: merge_request,
|
|
53
|
-
changes_text: changes_text,
|
|
54
|
-
jira_issue: jira_issue,
|
|
55
|
-
candidates: candidates
|
|
56
|
-
)
|
|
53
|
+
critique_candidates(context: context, candidates: candidates)
|
|
57
54
|
else
|
|
58
55
|
@logger.info('Pipeline critique pass skipped')
|
|
59
56
|
candidates
|
|
@@ -61,25 +58,22 @@ module Aireview
|
|
|
61
58
|
|
|
62
59
|
@logger.info("Pipeline finished with #{accepted.size} accepted finding(s)")
|
|
63
60
|
|
|
64
|
-
ReviewRenderer.new(language: @config.review_language)
|
|
61
|
+
renderer = ReviewRenderer.new(language: @config.review_language)
|
|
62
|
+
renderer.render(accepted, summary: summary, coverage: context.coverage)
|
|
65
63
|
end
|
|
66
64
|
|
|
67
|
-
def dry_run_prompts(merge_request:,
|
|
65
|
+
def dry_run_prompts(merge_request:, changes:, jira_issue: nil, critique: true)
|
|
68
66
|
@config.require_models!
|
|
69
67
|
|
|
70
|
-
|
|
68
|
+
context = @context_builder.prepare(
|
|
71
69
|
merge_request: merge_request,
|
|
72
|
-
|
|
73
|
-
jira_issue: jira_issue
|
|
70
|
+
changes: changes,
|
|
71
|
+
jira_issue: jira_issue,
|
|
72
|
+
critique: critique
|
|
74
73
|
)
|
|
75
|
-
|
|
74
|
+
generate_prompt = @context_builder.build_generate_prompt(context)
|
|
76
75
|
critique_prompt = if critique
|
|
77
|
-
@context_builder.build_critique_prompt(
|
|
78
|
-
merge_request: merge_request,
|
|
79
|
-
changes_text: changes_text,
|
|
80
|
-
jira_issue: jira_issue,
|
|
81
|
-
candidates_json: DRY_RUN_CANDIDATES_JSON
|
|
82
|
-
)
|
|
76
|
+
@context_builder.build_critique_prompt(context, candidates_json: DRY_RUN_CANDIDATES_JSON)
|
|
83
77
|
end
|
|
84
78
|
|
|
85
79
|
{
|
|
@@ -88,21 +82,18 @@ module Aireview
|
|
|
88
82
|
generate_model: @config.generate_model,
|
|
89
83
|
generate_temperature: @config.generate_temperature,
|
|
90
84
|
critique_model: @config.critique_model,
|
|
91
|
-
critique_temperature: @config.critique_temperature
|
|
85
|
+
critique_temperature: @config.critique_temperature,
|
|
86
|
+
coverage: context.coverage,
|
|
87
|
+
sizes: context.sizes
|
|
92
88
|
}
|
|
93
89
|
end
|
|
94
90
|
|
|
95
91
|
private
|
|
96
92
|
|
|
97
|
-
def critique_candidates(
|
|
93
|
+
def critique_candidates(context:, candidates:)
|
|
98
94
|
candidates_json = JSON.pretty_generate(candidates)
|
|
99
95
|
candidates_by_id = index_candidates_by_id(candidates)
|
|
100
|
-
critique_prompt = @context_builder.build_critique_prompt(
|
|
101
|
-
merge_request: merge_request,
|
|
102
|
-
changes_text: changes_text,
|
|
103
|
-
jira_issue: jira_issue,
|
|
104
|
-
candidates_json: candidates_json
|
|
105
|
-
)
|
|
96
|
+
critique_prompt = @context_builder.build_critique_prompt(context, candidates_json: candidates_json)
|
|
106
97
|
critique_raw = @reviewer.critique(**critique_prompt)
|
|
107
98
|
critique_result = parse_with_repair(
|
|
108
99
|
raw: critique_raw,
|
|
@@ -284,10 +275,11 @@ module Aireview
|
|
|
284
275
|
end
|
|
285
276
|
|
|
286
277
|
@logger.info("Pipeline #{stage} repair started for #{kind}")
|
|
278
|
+
prompt = @context_builder.check_stage_size!(stage, REPAIR_SYSTEM_PROMPT, user_prompt)
|
|
287
279
|
if stage == :critique
|
|
288
|
-
@reviewer.critique(
|
|
280
|
+
@reviewer.critique(**prompt)
|
|
289
281
|
else
|
|
290
|
-
@reviewer.generate(
|
|
282
|
+
@reviewer.generate(**prompt)
|
|
291
283
|
end
|
|
292
284
|
end
|
|
293
285
|
|
|
@@ -35,7 +35,16 @@ module Aireview
|
|
|
35
35
|
where: 'Where',
|
|
36
36
|
problem: 'Problem',
|
|
37
37
|
why: 'Why it matters',
|
|
38
|
-
suggestion: 'Suggestion'
|
|
38
|
+
suggestion: 'Suggestion',
|
|
39
|
+
partial: 'Partial review',
|
|
40
|
+
not_reviewed: 'Not reviewed',
|
|
41
|
+
files_not_shown: 'files not reviewed',
|
|
42
|
+
files_partial: 'files reviewed partially',
|
|
43
|
+
files_unavailable: 'files without an available diff',
|
|
44
|
+
sections_truncated: 'sections truncated',
|
|
45
|
+
hunks_of: 'hunks shown',
|
|
46
|
+
diff_unavailable: 'diff not available',
|
|
47
|
+
section_list: 'Truncated sections'
|
|
39
48
|
},
|
|
40
49
|
'ru' => {
|
|
41
50
|
summary: 'Сводка',
|
|
@@ -50,7 +59,16 @@ module Aireview
|
|
|
50
59
|
where: 'Где',
|
|
51
60
|
problem: 'Проблема',
|
|
52
61
|
why: 'Почему важно',
|
|
53
|
-
suggestion: 'Предложение'
|
|
62
|
+
suggestion: 'Предложение',
|
|
63
|
+
partial: 'Ревью частичное',
|
|
64
|
+
not_reviewed: 'Не вошло в ревью',
|
|
65
|
+
files_not_shown: 'файлов не проверено',
|
|
66
|
+
files_partial: 'файлов проверено частично',
|
|
67
|
+
files_unavailable: 'файлов без доступного диффа',
|
|
68
|
+
sections_truncated: 'секций усечено',
|
|
69
|
+
hunks_of: 'хунков показано',
|
|
70
|
+
diff_unavailable: 'дифф недоступен',
|
|
71
|
+
section_list: 'Усечённые секции'
|
|
54
72
|
}
|
|
55
73
|
}.freeze
|
|
56
74
|
|
|
@@ -58,7 +76,11 @@ module Aireview
|
|
|
58
76
|
@labels = LABELS.fetch(language.to_s) { LABELS.fetch(DEFAULT_LANGUAGE) }
|
|
59
77
|
end
|
|
60
78
|
|
|
61
|
-
|
|
79
|
+
# coverage: факты усечения контекста от пайплайна, не текст модели.
|
|
80
|
+
# result по-прежнему про найденные проблемы; неполнота покрытия
|
|
81
|
+
# дописывается рядом с ним, чтобы строка результата не читалась как
|
|
82
|
+
# «проверено всё».
|
|
83
|
+
def render(accepted, summary:, coverage: nil)
|
|
62
84
|
findings = sorted_findings(Array(accepted)).first(TOTAL_FINDINGS_LIMIT)
|
|
63
85
|
mismatches = findings.select { |finding| category(finding) == 'task_mismatch' }.first(MISMATCH_LIMIT)
|
|
64
86
|
important = findings.select { |finding| important_finding?(finding) }.first(IMPORTANT_LIMIT)
|
|
@@ -79,8 +101,8 @@ module Aireview
|
|
|
79
101
|
|
|
80
102
|
## #{label(:result)}
|
|
81
103
|
|
|
82
|
-
#{result}
|
|
83
|
-
|
|
104
|
+
#{result}#{partial_note(coverage)}
|
|
105
|
+
#{coverage_block(coverage)}
|
|
84
106
|
#{label(:disclaimer)}
|
|
85
107
|
MARKDOWN
|
|
86
108
|
end
|
|
@@ -117,6 +139,34 @@ module Aireview
|
|
|
117
139
|
end.join("\n\n")
|
|
118
140
|
end
|
|
119
141
|
|
|
142
|
+
def partial_note(coverage)
|
|
143
|
+
return '' if coverage.nil? || coverage.complete?
|
|
144
|
+
|
|
145
|
+
counts = {
|
|
146
|
+
files_not_shown: coverage.files_not_shown.size,
|
|
147
|
+
files_partial: coverage.files_partial.size,
|
|
148
|
+
files_unavailable: coverage.files_unavailable.size,
|
|
149
|
+
sections_truncated: coverage.truncated_sections.size
|
|
150
|
+
}.reject { |_, count| count.zero? }.map { |key, count| "#{count} #{label(key)}" }
|
|
151
|
+
|
|
152
|
+
". #{label(:partial)}: #{counts.join(', ')}."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def coverage_block(coverage)
|
|
156
|
+
return '' if coverage.nil? || coverage.complete?
|
|
157
|
+
|
|
158
|
+
lines = coverage.files_not_shown.map { |path| "- #{path}" }
|
|
159
|
+
lines += coverage.files_partial.map do |file|
|
|
160
|
+
"- #{file[:path]}: #{file[:shown]}/#{file[:total]} #{label(:hunks_of)}"
|
|
161
|
+
end
|
|
162
|
+
lines += coverage.files_unavailable.map { |path| "- #{path}: #{label(:diff_unavailable)}" }
|
|
163
|
+
unless coverage.truncated_sections.empty?
|
|
164
|
+
lines << "- #{label(:section_list)}: #{coverage.truncated_sections.join(', ')}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
"\n## #{label(:not_reviewed)}\n\n#{lines.join("\n")}\n"
|
|
168
|
+
end
|
|
169
|
+
|
|
120
170
|
def location(finding)
|
|
121
171
|
file = presence(value(finding, 'file'))
|
|
122
172
|
line = value(finding, 'line')
|
data/lib/aireview/version.rb
CHANGED
data/lib/aireview.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aireview
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Denis Levenko
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dotenv
|
|
@@ -63,6 +63,7 @@ extensions: []
|
|
|
63
63
|
extra_rdoc_files: []
|
|
64
64
|
files:
|
|
65
65
|
- CHANGELOG.md
|
|
66
|
+
- CONTRIBUTORS.md
|
|
66
67
|
- LICENSE
|
|
67
68
|
- README.md
|
|
68
69
|
- bin/aireview
|
|
@@ -70,8 +71,11 @@ files:
|
|
|
70
71
|
- lib/aireview.rb
|
|
71
72
|
- lib/aireview/cli.rb
|
|
72
73
|
- lib/aireview/config.rb
|
|
74
|
+
- lib/aireview/config_limits.rb
|
|
75
|
+
- lib/aireview/context_budget.rb
|
|
73
76
|
- lib/aireview/context_builder.rb
|
|
74
77
|
- lib/aireview/diff_fetcher.rb
|
|
78
|
+
- lib/aireview/dry_run_report.rb
|
|
75
79
|
- lib/aireview/errors.rb
|
|
76
80
|
- lib/aireview/gitlab_client.rb
|
|
77
81
|
- lib/aireview/jira_client.rb
|
|
@@ -96,6 +100,7 @@ metadata:
|
|
|
96
100
|
source_code_uri: https://github.com/DenisDenis9331/aireview
|
|
97
101
|
changelog_uri: https://github.com/DenisDenis9331/aireview/blob/main/CHANGELOG.md
|
|
98
102
|
bug_tracker_uri: https://github.com/DenisDenis9331/aireview/issues
|
|
103
|
+
funding_uri: https://ko-fi.com/denis1011101
|
|
99
104
|
rubygems_mfa_required: 'true'
|
|
100
105
|
post_install_message:
|
|
101
106
|
rdoc_options: []
|