x_aeon_agents 1.0.28
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/AGENTS.md +136 -0
- data/CHANGELOG.md +398 -0
- data/README.md +579 -0
- data/TODO.md +48 -0
- data/bin/xaa +5 -0
- data/lib/x_aeon_agents/agent_defaults.rb +60 -0
- data/lib/x_aeon_agents/agent_options.rb +58 -0
- data/lib/x_aeon_agents/agents/coder_agent.rb +82 -0
- data/lib/x_aeon_agents/agents/committer_agent.rb +71 -0
- data/lib/x_aeon_agents/agents/developer_agent.rb +235 -0
- data/lib/x_aeon_agents/agents/diff_interpreter_agent.rb +80 -0
- data/lib/x_aeon_agents/agents/documenter_agent.rb +44 -0
- data/lib/x_aeon_agents/agents/executor_agent.rb +9 -0
- data/lib/x_aeon_agents/agents/feedback_analyst_agent.rb +62 -0
- data/lib/x_aeon_agents/agents/gh_comments.gql +64 -0
- data/lib/x_aeon_agents/agents/git_diff_interpreter_agent.rb +57 -0
- data/lib/x_aeon_agents/agents/issue_implementer_agent.rb +88 -0
- data/lib/x_aeon_agents/agents/one_line_code_diff_summarizer_agent.rb +57 -0
- data/lib/x_aeon_agents/agents/plan_generator_agent.rb +51 -0
- data/lib/x_aeon_agents/agents/planner_agent.rb +89 -0
- data/lib/x_aeon_agents/agents/pull_request_creator_agent.rb +144 -0
- data/lib/x_aeon_agents/agents/readme/about_analyzer_agent.rb +55 -0
- data/lib/x_aeon_agents/agents/readme/contributing_agent.rb +47 -0
- data/lib/x_aeon_agents/agents/readme/development_agent.rb +54 -0
- data/lib/x_aeon_agents/agents/readme/documentation_agent.rb +43 -0
- data/lib/x_aeon_agents/agents/readme/features_agent.rb +45 -0
- data/lib/x_aeon_agents/agents/readme/how_it_works_agent.rb +47 -0
- data/lib/x_aeon_agents/agents/readme/license_agent.rb +43 -0
- data/lib/x_aeon_agents/agents/readme/public_api_agent.rb +44 -0
- data/lib/x_aeon_agents/agents/readme/quick_start_agent.rb +50 -0
- data/lib/x_aeon_agents/agents/readme/requirements_agent.rb +43 -0
- data/lib/x_aeon_agents/agents/readme_generator_agent.rb +438 -0
- data/lib/x_aeon_agents/agents/review_resolver_agent.rb +236 -0
- data/lib/x_aeon_agents/agents/review_responder_agent.rb +64 -0
- data/lib/x_aeon_agents/agents/skill_generator_agent.rb +95 -0
- data/lib/x_aeon_agents/agents/skill_installer_agent.rb +103 -0
- data/lib/x_aeon_agents/agents/task_starter_agent.rb +66 -0
- data/lib/x_aeon_agents/agents/tester_agent.rb +44 -0
- data/lib/x_aeon_agents/cli.rb +384 -0
- data/lib/x_aeon_agents/config.rb +110 -0
- data/lib/x_aeon_agents/gen_helpers.rb +270 -0
- data/lib/x_aeon_agents/helpers.rb +211 -0
- data/lib/x_aeon_agents/logger.rb +21 -0
- data/lib/x_aeon_agents/providers/cline.rb +78 -0
- data/lib/x_aeon_agents/version.rb +6 -0
- data/lib/x_aeon_agents.rb +38 -0
- metadata +296 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
require 'commonmarker'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module XAeonAgents
|
|
5
|
+
module Agents
|
|
6
|
+
# Agent responsible for writing the README.md file of a project.
|
|
7
|
+
class ReadmeGeneratorAgent < ComposableAgents::Agent
|
|
8
|
+
prepend AgentDefaults
|
|
9
|
+
|
|
10
|
+
# Define input artifacts contracts
|
|
11
|
+
#
|
|
12
|
+
# @return [Hash{Symbol => Object}] Set of input artifacts description, per artifact name
|
|
13
|
+
def input_artifacts_contracts
|
|
14
|
+
{
|
|
15
|
+
readme_file_path: {
|
|
16
|
+
description: 'The full path to the README file',
|
|
17
|
+
optional: true
|
|
18
|
+
},
|
|
19
|
+
gen_about: 'The option to generate the about/header section (name, description, badges, TOC)',
|
|
20
|
+
gen_quick_start: 'The option to generate the "Quick start" section',
|
|
21
|
+
gen_requirements: 'The option to generate the "Requirements" section',
|
|
22
|
+
gen_features: 'The option to generate the "Features" section',
|
|
23
|
+
gen_public_api: 'The option to generate the "Public API" section',
|
|
24
|
+
gen_documentation: 'The option to generate the "Documentation" section',
|
|
25
|
+
gen_how_it_works: 'The option to generate the "How it works" section',
|
|
26
|
+
gen_development: 'The option to generate the "Development" section',
|
|
27
|
+
gen_contributing: 'The option to generate the "Contributing" section',
|
|
28
|
+
gen_license: 'The option to generate the "License" section'
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Execute the agent to generate some output artifacts based on some input artifacts.
|
|
33
|
+
#
|
|
34
|
+
# @param readme_file_path [String] Path to the README file.
|
|
35
|
+
# @param gen_about [Boolean] Generate the about/header section (name, description, badges, TOC).
|
|
36
|
+
# Setting this to false will keep the existing header untouched in the README while allowing
|
|
37
|
+
# other sections to be updated.
|
|
38
|
+
# @param gen_quick_start [Boolean] Generate the "Quick start" section
|
|
39
|
+
# @param gen_requirements [Boolean] Generate the "Requirements" section
|
|
40
|
+
# @param gen_features [Boolean] Generate the "Features" section
|
|
41
|
+
# @param gen_public_api [Boolean] Generate the "Public API" section
|
|
42
|
+
# @param gen_documentation [Boolean] Generate the "Documentation" section
|
|
43
|
+
# @param gen_how_it_works [Boolean] Generate the "How it works" section
|
|
44
|
+
# @param gen_development [Boolean] Generate the "Development" section
|
|
45
|
+
# @param gen_contributing [Boolean] Generate the "Contributing" section
|
|
46
|
+
# @param gen_license [Boolean] Generate the "License" section
|
|
47
|
+
# @return [Hash{Symbol => Object}] Output artifacts content
|
|
48
|
+
def run(
|
|
49
|
+
readme_file_path: File.expand_path('README.md'),
|
|
50
|
+
gen_about: true,
|
|
51
|
+
gen_quick_start: true,
|
|
52
|
+
gen_requirements: true,
|
|
53
|
+
gen_features: true,
|
|
54
|
+
gen_public_api: true,
|
|
55
|
+
gen_documentation: true,
|
|
56
|
+
gen_how_it_works: true,
|
|
57
|
+
gen_development: true,
|
|
58
|
+
gen_contributing: true,
|
|
59
|
+
gen_license: true
|
|
60
|
+
)
|
|
61
|
+
# Each section of the README has a dedicated agent who generates its content in an artifact.
|
|
62
|
+
if gen_about
|
|
63
|
+
about_analyzer_agent = new_agent(Readme::AboutAnalyzerAgent, **Config.agent_options['free_complex_planning'])
|
|
64
|
+
step_agent(
|
|
65
|
+
about_analyzer_agent,
|
|
66
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
67
|
+
Analyze this project's code, features and layout.
|
|
68
|
+
Devise the goal of this project, what problem it solves, and using which interface (CLI, library, web app...).
|
|
69
|
+
Create an artifact named `#{about_analyzer_agent.artifact_ref(:name)}` with the name of this project.
|
|
70
|
+
Create an artifact named `#{about_analyzer_agent.artifact_ref(:description)}` with a 1-line high-level description of this project
|
|
71
|
+
using Markdown format, compatible with Github flavor.
|
|
72
|
+
Create an artifact named `#{about_analyzer_agent.artifact_ref(:about)}` with an engaging high-level description/overview of this project
|
|
73
|
+
using Markdown format, compatible with Github flavor.
|
|
74
|
+
The description should be concise, in 1 small section and will serve as a the first paragraph of a README file.
|
|
75
|
+
The description is intended to be for end-users and should be simple and easy to understand.
|
|
76
|
+
Use emphasis, bullet points and small emojis to illustrate in a readable way the description.
|
|
77
|
+
EO_INSTRUCTIONS
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if gen_quick_start
|
|
82
|
+
quick_start_agent = new_agent(Readme::QuickStartAgent, **Config.agent_options['free_complex_planning'])
|
|
83
|
+
step_agent(
|
|
84
|
+
quick_start_agent,
|
|
85
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
86
|
+
Analyze this project's installation and usage patterns.
|
|
87
|
+
Create an artifact named `#{quick_start_agent.artifact_ref(:quick_start)}` with quick installation and usage instructions
|
|
88
|
+
using Markdown format, compatible with Github flavor.
|
|
89
|
+
These instructions should cover the installation and basic usage of this project, with simple examples.
|
|
90
|
+
EO_INSTRUCTIONS
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if gen_requirements
|
|
95
|
+
requirements_agent = new_agent(Readme::RequirementsAgent, **Config.agent_options['free_complex_planning'])
|
|
96
|
+
step_agent(
|
|
97
|
+
requirements_agent,
|
|
98
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
99
|
+
Analyze this project's dependencies, runtime environment, and prerequisites.
|
|
100
|
+
Create an artifact named `#{requirements_agent.artifact_ref(:requirements)}` listing all prerequisites needed to use or run the project
|
|
101
|
+
using Markdown format, compatible with Github flavor.
|
|
102
|
+
Those prerequisites should be given as a short list of technical points (for example OS, languages and versions, needed libraries...).
|
|
103
|
+
Only list prerequisites that are needed by the users of the project, and not provided by the installation steps.
|
|
104
|
+
EO_INSTRUCTIONS
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if gen_features
|
|
109
|
+
features_agent = new_agent(Readme::FeaturesAgent, **Config.agent_options['free_complex_planning'])
|
|
110
|
+
step_agent(
|
|
111
|
+
features_agent,
|
|
112
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
113
|
+
Analyze this project's codebase and capabilities to identify all key features.
|
|
114
|
+
Create an artifact named `#{features_agent.artifact_ref(:features)}` listing the main features of the project
|
|
115
|
+
using Markdown format, compatible with Github flavor.
|
|
116
|
+
Those features should be summarized in a simple paragraph.
|
|
117
|
+
EO_INSTRUCTIONS
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if gen_public_api
|
|
122
|
+
public_api_agent = new_agent(Readme::PublicApiAgent, **Config.agent_options['free_complex_planning'])
|
|
123
|
+
step_agent(
|
|
124
|
+
public_api_agent,
|
|
125
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
126
|
+
Analyze this project's codebase to identify all public APIs, classes, methods, and interfaces exposed to users.
|
|
127
|
+
Create an artifact named `#{public_api_agent.artifact_ref(:public_api)}` documenting the public API surface
|
|
128
|
+
using Markdown format, compatible with Github flavor.
|
|
129
|
+
The public API description should only contain public entry points of the project. Those entry points can be:
|
|
130
|
+
- Executables from the `bin` directory, if any.
|
|
131
|
+
- Any public method (part of yard's group `Public API` only).
|
|
132
|
+
Try to document 1 simple usecase with an example for each public API you find, without getting into details.
|
|
133
|
+
Provide links to more complete documentation next to the usecase example so that users can refer to more details about this specific public API.
|
|
134
|
+
Links can be (by order of preference):
|
|
135
|
+
- Links to the RubyDoc.info documentation for a Ruby public method (for example `https://www.rubydoc.info/gems/x-aeon_agents/XAeonAgents/Agents#config-class_method`).
|
|
136
|
+
- Links to the Github pages (for example `https://github.com/Muriel-Salvan/x_aeon_agents/blob/main/bin/xaa`).
|
|
137
|
+
EO_INSTRUCTIONS
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if gen_documentation
|
|
142
|
+
documentation_agent = new_agent(Readme::DocumentationAgent, **Config.agent_options['free_complex_planning'])
|
|
143
|
+
step_agent(
|
|
144
|
+
documentation_agent,
|
|
145
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
146
|
+
Explore this project's documentation files and resources to identify all available documentation.
|
|
147
|
+
Create an artifact named `#{documentation_agent.artifact_ref(:documentation)}` providing links to documentation resources
|
|
148
|
+
using Markdown format, compatible with Github flavor.
|
|
149
|
+
Links can be:
|
|
150
|
+
- Link to the RubyDoc.info documentation for a Ruby public method (for example `https://www.rubydoc.info/gems/x_aeon_agents`).
|
|
151
|
+
- Link to the main Github page (for example `https://github.com/Muriel-Salvan/x_aeon_agents`).
|
|
152
|
+
EO_INSTRUCTIONS
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if gen_how_it_works
|
|
157
|
+
how_it_works_agent = new_agent(Readme::HowItWorksAgent, **Config.agent_options['free_complex_planning'])
|
|
158
|
+
step_agent(
|
|
159
|
+
how_it_works_agent,
|
|
160
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
161
|
+
Analyze this project's architecture, design patterns, and internal workings.
|
|
162
|
+
Create an artifact named `#{how_it_works_agent.artifact_ref(:how_it_works)}` explaining the internal architecture and working principles
|
|
163
|
+
using Markdown format, compatible with Github flavor.
|
|
164
|
+
This should be summarized as a small section, not an architecture document.
|
|
165
|
+
EO_INSTRUCTIONS
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if gen_development
|
|
170
|
+
development_agent = new_agent(Readme::DevelopmentAgent, **Config.agent_options['free_complex_planning'])
|
|
171
|
+
step_agent(
|
|
172
|
+
development_agent,
|
|
173
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
174
|
+
Analyze this project's development setup, build system, testing framework, and development workflows.
|
|
175
|
+
Create an artifact named `#{development_agent.artifact_ref(:development)}` explaining how to set up a development environment to code for this project
|
|
176
|
+
using Markdown format, compatible with Github flavor.
|
|
177
|
+
EO_INSTRUCTIONS
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if gen_contributing
|
|
182
|
+
contributing_agent = new_agent(Readme::ContributingAgent, **Config.agent_options['free_complex_planning'])
|
|
183
|
+
step_agent(
|
|
184
|
+
contributing_agent,
|
|
185
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
186
|
+
Analyze this project's CONTRIBUTING guidelines, issue templates, pull request templates, and any community guidelines.
|
|
187
|
+
Create an artifact named `#{contributing_agent.artifact_ref(:contributing)}` explaining how users can contribute to the project
|
|
188
|
+
using Markdown format, compatible with Github flavor.
|
|
189
|
+
This should be done in 1 paragraph showing how to install or setup test dependencies and how to run tests, as a whole or individually.
|
|
190
|
+
Use simple examples to illustrate it.
|
|
191
|
+
EO_INSTRUCTIONS
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if gen_license
|
|
196
|
+
license_agent = new_agent(Readme::LicenseAgent, **Config.agent_options['free_complex_planning'])
|
|
197
|
+
step_agent(
|
|
198
|
+
license_agent,
|
|
199
|
+
user_instructions: <<~EO_INSTRUCTIONS
|
|
200
|
+
Analyze this project's LICENSE file to identify the license type and terms.
|
|
201
|
+
Create an artifact named `#{license_agent.artifact_ref(:license)}` describing the project license
|
|
202
|
+
using Markdown format, compatible with Github flavor.
|
|
203
|
+
If there is already a LICENSE file in this project, just provide a link to this license file.
|
|
204
|
+
EO_INSTRUCTIONS
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Assemble README.md from all section artifacts
|
|
209
|
+
step(:assemble_readme) do
|
|
210
|
+
sections = File.exist?(readme_file_path) ? parse_sections(File.read(readme_file_path)) : []
|
|
211
|
+
header_content = nil
|
|
212
|
+
|
|
213
|
+
# We expect the first 2 sections to be of level 0 or 1, and they both correspond to the about top section.
|
|
214
|
+
# Treat them as a header if that's the case.
|
|
215
|
+
2.times do
|
|
216
|
+
if !sections.empty? && sections.first[:level] < 2
|
|
217
|
+
section = sections.shift
|
|
218
|
+
header_content = ([header_content] + [section[:body]]).compact.join("\n\n")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Remove the eventual Table of contents section as we will always regenerate it.
|
|
223
|
+
sections.delete_if { |section| section[:title] == 'Table of contents' }
|
|
224
|
+
|
|
225
|
+
# Replace or insert sections in the order they are expected
|
|
226
|
+
|
|
227
|
+
if gen_about
|
|
228
|
+
header_content = <<~EO_HEADER.strip
|
|
229
|
+
<div align="center">
|
|
230
|
+
|
|
231
|
+
# #{@artifacts[:name]}
|
|
232
|
+
|
|
233
|
+
#{@artifacts[:description]}
|
|
234
|
+
|
|
235
|
+
#{build_badges.join("\n")}
|
|
236
|
+
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
#{ComposableAgents::Utils::Markdown.align_markdown_headers(@artifacts[:about], level: 3).strip}
|
|
240
|
+
EO_HEADER
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
ordered_sections = [
|
|
244
|
+
['Quick start', :quick_start],
|
|
245
|
+
['Requirements', :requirements],
|
|
246
|
+
['Features', :features],
|
|
247
|
+
['Public API', :public_api],
|
|
248
|
+
['Documentation', :documentation],
|
|
249
|
+
['How it works', :how_it_works],
|
|
250
|
+
['Development', :development],
|
|
251
|
+
['Contributing', :contributing],
|
|
252
|
+
['License', :license]
|
|
253
|
+
]
|
|
254
|
+
ordered_sections.each.with_index do |(section_title, art_name), idx_section|
|
|
255
|
+
next unless @artifacts[art_name]
|
|
256
|
+
|
|
257
|
+
content = <<~EO_CONTENT.strip
|
|
258
|
+
## #{section_title}
|
|
259
|
+
|
|
260
|
+
#{ComposableAgents::Utils::Markdown.align_markdown_headers(strip_grouping_header(@artifacts[art_name]), level: 3).strip}
|
|
261
|
+
EO_CONTENT
|
|
262
|
+
# Find the section of this title if any
|
|
263
|
+
existing_idx = sections.index { |section| section[:title] == section_title }
|
|
264
|
+
if existing_idx
|
|
265
|
+
sections[existing_idx][:body] = content
|
|
266
|
+
else
|
|
267
|
+
# Look for the first previous section.
|
|
268
|
+
# The we insert our section just after.
|
|
269
|
+
found_previous_section_index = nil
|
|
270
|
+
previous_section_index = idx_section - 1
|
|
271
|
+
while found_previous_section_index.nil?
|
|
272
|
+
if previous_section_index == -1
|
|
273
|
+
# We didn't find any previous section.
|
|
274
|
+
# Insert it at the top.
|
|
275
|
+
found_previous_section_index = -1
|
|
276
|
+
else
|
|
277
|
+
previous_section_title = ordered_sections[previous_section_index][0]
|
|
278
|
+
found_previous_section_index = sections.index { |section| section[:title] == previous_section_title }
|
|
279
|
+
end
|
|
280
|
+
previous_section_index -= 1
|
|
281
|
+
end
|
|
282
|
+
# Insert just after found_previous_section_index
|
|
283
|
+
sections.insert(
|
|
284
|
+
found_previous_section_index + 1,
|
|
285
|
+
{
|
|
286
|
+
level: 2,
|
|
287
|
+
title: section_title,
|
|
288
|
+
body: content
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
sections_content = sections.map { |section| section[:body] }.join("\n\n").strip
|
|
295
|
+
File.write(
|
|
296
|
+
readme_file_path,
|
|
297
|
+
<<~EO_README
|
|
298
|
+
#{header_content}
|
|
299
|
+
|
|
300
|
+
## Table of contents
|
|
301
|
+
|
|
302
|
+
#{generate_table_of_contents(sections_content).strip}
|
|
303
|
+
|
|
304
|
+
#{sections_content}
|
|
305
|
+
EO_README
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
puts "#{readme_file_path} has been generated successfully."
|
|
310
|
+
|
|
311
|
+
@artifacts
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
# Build the badges array for the README header.
|
|
317
|
+
#
|
|
318
|
+
# @return [Array<String>]
|
|
319
|
+
def build_badges
|
|
320
|
+
badges = []
|
|
321
|
+
if Helpers.github_repo
|
|
322
|
+
badges.push(
|
|
323
|
+
"[](https://github.com/#{Helpers.github_repo}/actions/workflows/continuous_integration.yml)",
|
|
324
|
+
"[](https://codecov.io/gh/#{Helpers.github_repo})",
|
|
325
|
+
"[](https://github.com/#{Helpers.github_repo}/stargazers)",
|
|
326
|
+
"[](LICENSE)"
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
if Helpers.gem_name
|
|
330
|
+
badges.push(
|
|
331
|
+
"[](https://rubygems.org/gems/#{Helpers.gem_name})",
|
|
332
|
+
"[](https://rubygems.org/gems/#{Helpers.gem_name})"
|
|
333
|
+
)
|
|
334
|
+
end
|
|
335
|
+
badges
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Parse an existing README content into an array of section hashes using CommonMarker.
|
|
339
|
+
#
|
|
340
|
+
# Each section hash has:
|
|
341
|
+
# :level - heading level (1 or 2, or 0 for the leading header block before any heading)
|
|
342
|
+
# :title - heading text (nil for the leading header block)
|
|
343
|
+
# :body - raw markdown body (includes the heading line itself for level>=1 sections)
|
|
344
|
+
#
|
|
345
|
+
# @param readme_content [String] Full README markdown content
|
|
346
|
+
# @return [Array<Hash{Symbol => Object}>]
|
|
347
|
+
def parse_sections(readme_content)
|
|
348
|
+
sections = []
|
|
349
|
+
# Track (1-indexed) source line of the last heading we encountered
|
|
350
|
+
last_heading_line = nil
|
|
351
|
+
lines = readme_content.lines.map(&:chomp)
|
|
352
|
+
Commonmarker.parse(readme_content, options: { sourcepos_chars: true }).walk do |node|
|
|
353
|
+
next unless node.type == :heading && node.header_level <= 2
|
|
354
|
+
|
|
355
|
+
heading_line = node.source_position[:start_line] # 1-indexed
|
|
356
|
+
if last_heading_line
|
|
357
|
+
# Body is lines from last heading up to (not including) this heading
|
|
358
|
+
sections.last[:body] = lines[(last_heading_line - 1)...(heading_line - 1)].join("\n").strip
|
|
359
|
+
elsif heading_line > 1
|
|
360
|
+
# Capture the leading header (lines before the first heading)
|
|
361
|
+
sections << {
|
|
362
|
+
level: 0,
|
|
363
|
+
title: nil,
|
|
364
|
+
body: lines[0...(heading_line - 1)].join("\n").strip
|
|
365
|
+
}
|
|
366
|
+
end
|
|
367
|
+
text_node = node.each.find { |c| c.type == :text }
|
|
368
|
+
sections << {
|
|
369
|
+
level: node.header_level,
|
|
370
|
+
title: text_node ? text_node.string_content : '',
|
|
371
|
+
body: nil # placeholder, will be filled by the next heading
|
|
372
|
+
}
|
|
373
|
+
last_heading_line = heading_line
|
|
374
|
+
end
|
|
375
|
+
# Capture the body of the last section (from its heading to end of file)
|
|
376
|
+
if last_heading_line
|
|
377
|
+
sections.last[:body] = lines[(last_heading_line - 1)..].join("\n").strip
|
|
378
|
+
else
|
|
379
|
+
# No headings at all — entire content is one header block
|
|
380
|
+
sections << {
|
|
381
|
+
level: 0,
|
|
382
|
+
title: nil,
|
|
383
|
+
body: readme_content.strip
|
|
384
|
+
}
|
|
385
|
+
end
|
|
386
|
+
sections
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# If the markdown content has exactly 1 heading at the smallest level present,
|
|
390
|
+
# remove it. This strips the "grouping" header that AI-generated sections
|
|
391
|
+
# sometimes produce (e.g. a single `#` or `##` wrapping the rest of the content).
|
|
392
|
+
#
|
|
393
|
+
# @param markdown [String] The markdown content to process.
|
|
394
|
+
# @return [String] The markdown content with the grouping header removed,
|
|
395
|
+
# or the original if there are 0 or 2+ headings at the smallest level.
|
|
396
|
+
def strip_grouping_header(markdown)
|
|
397
|
+
# Collect all heading nodes with their levels
|
|
398
|
+
heading_nodes = []
|
|
399
|
+
min_level = nil
|
|
400
|
+
Commonmarker.parse(markdown).walk do |node|
|
|
401
|
+
next unless node.type == :heading
|
|
402
|
+
|
|
403
|
+
heading_nodes << node
|
|
404
|
+
min_level = node.header_level if min_level.nil? || node.header_level < min_level
|
|
405
|
+
end
|
|
406
|
+
# No headings at all — nothing to strip
|
|
407
|
+
return markdown if heading_nodes.empty? || min_level.nil?
|
|
408
|
+
|
|
409
|
+
# Find headings at the minimum level
|
|
410
|
+
min_level_nodes = heading_nodes.select { |node| node.header_level == min_level }
|
|
411
|
+
# Only strip when there is exactly 1 heading at the minimum level
|
|
412
|
+
return markdown unless min_level_nodes.size == 1
|
|
413
|
+
|
|
414
|
+
heading_node = min_level_nodes.first
|
|
415
|
+
start_line = heading_node.source_position[:start_line] # 1-indexed
|
|
416
|
+
# Only strip if it is on the top of the document
|
|
417
|
+
return markdown unless start_line == 1
|
|
418
|
+
|
|
419
|
+
end_line = heading_node.source_position[:end_line] # 1-indexed
|
|
420
|
+
lines = markdown.lines.map(&:chomp)
|
|
421
|
+
# Remove the heading lines (only the heading, not trailing blank lines that belong to content)
|
|
422
|
+
(lines[0...(start_line - 1)] + lines[end_line..]).join("\n").strip
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Generate a Table of Contents (String) from a Markdown document string.
|
|
426
|
+
#
|
|
427
|
+
# @param markdown [String] The full Markdown document content.
|
|
428
|
+
# @return [String] The generated Table of Contents as a Markdown list.
|
|
429
|
+
def generate_table_of_contents(markdown)
|
|
430
|
+
temp_file = "#{@session_dir}/tmp/content_to_be_toced.md"
|
|
431
|
+
FileUtils.mkdir_p File.dirname(temp_file)
|
|
432
|
+
File.write(temp_file, markdown)
|
|
433
|
+
log_debug 'Generating Table of Contents using doctoc...'
|
|
434
|
+
Helpers.run_cmd("npx doctoc --github --notitle --stdout #{temp_file}")[:stdout].gsub(/==================\n.+$/m, '').strip
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
module XAeonAgents
|
|
2
|
+
module Agents
|
|
3
|
+
# Agent responsible for addressing Pull Request review comments directed at X-Aeon Agents
|
|
4
|
+
class ReviewResolverAgent < ComposableAgents::Agent
|
|
5
|
+
prepend AgentDefaults
|
|
6
|
+
|
|
7
|
+
# Define input artifacts contracts
|
|
8
|
+
#
|
|
9
|
+
# @return [Hash{Symbol => Object}] Set of input artifacts description, per artifact name
|
|
10
|
+
def input_artifacts_contracts
|
|
11
|
+
{ pull_request_number: 'The Pull Request number to address comments for' }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Constructor
|
|
15
|
+
#
|
|
16
|
+
# @param agent_params [Hash{Symbol => Object}] Extra agent parameters
|
|
17
|
+
def initialize(**agent_params)
|
|
18
|
+
super(name: 'ReviewResolver', **agent_params)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute the agent to address Pull Request review comments
|
|
22
|
+
#
|
|
23
|
+
# @param pull_request_number [Integer, nil] The Pull Request number to address comments for.
|
|
24
|
+
# When nil, the Pull Request is auto-detected from the current git branch.
|
|
25
|
+
# @return [Hash{Symbol => Object}] Output artifacts content
|
|
26
|
+
def run(pull_request_number: nil)
|
|
27
|
+
raise 'Unable to find the Github repository' unless Helpers.github_repo
|
|
28
|
+
|
|
29
|
+
pull_request_number = resolve_pull_request_number(pull_request_number)
|
|
30
|
+
|
|
31
|
+
# Gather comments is always executed (never cached) so that new comments are detected on
|
|
32
|
+
# every run, even when resuming a session where development/replies are cached.
|
|
33
|
+
@artifacts[:conversations] = gather_comments(pull_request_number)
|
|
34
|
+
|
|
35
|
+
if @artifacts[:conversations].empty?
|
|
36
|
+
log_debug "No PR reviews conversations found that need X-Aeon Agents input for PR ##{pull_request_number}"
|
|
37
|
+
else
|
|
38
|
+
log_debug "Found #{@artifacts[:conversations].size} PR reviews conversations that need X-Aeon Agents input for PR ##{pull_request_number}"
|
|
39
|
+
@artifacts[:open_comments_to_agents] = @artifacts[:conversations].map do |conversation|
|
|
40
|
+
conversation.select { |comment| comment['need_ai_reply'] }
|
|
41
|
+
end.flatten(1)
|
|
42
|
+
log_debug "Found #{@artifacts[:open_comments_to_agents].size} PR review comments that need X-Aeon Agents to reply for PR ##{pull_request_number}"
|
|
43
|
+
|
|
44
|
+
step(:extract_requirements) do
|
|
45
|
+
pr = Helpers.github.pull_request(Helpers.github_repo, pull_request_number)
|
|
46
|
+
feedback_analyst_agent = new_agent(FeedbackAnalystAgent, **Config.agent_options['free_complex_planning'])
|
|
47
|
+
step_agent(
|
|
48
|
+
feedback_analyst_agent,
|
|
49
|
+
pr_description: <<~EO_DESCRIPTION.strip,
|
|
50
|
+
# #{pr.title}
|
|
51
|
+
|
|
52
|
+
#{ComposableAgents::Utils::Markdown.align_markdown_headers(pr.body, level: 2)}
|
|
53
|
+
EO_DESCRIPTION
|
|
54
|
+
pr_files_diffs: Helpers.git.diff("#{pr.base.sha}...#{pr.head.sha}").to_s,
|
|
55
|
+
user_instructions: {
|
|
56
|
+
ordered_list: [
|
|
57
|
+
<<~EO_INSTRUCTION,
|
|
58
|
+
Read the `#{feedback_analyst_agent.artifact_ref(:description)}` artifact to understand the purpose of this Pull Request
|
|
59
|
+
|
|
60
|
+
- This gives you context on what is the purpose of this Pull Request.
|
|
61
|
+
EO_INSTRUCTION
|
|
62
|
+
<<~EO_INSTRUCTION,
|
|
63
|
+
Read the `#{feedback_analyst_agent.artifact_ref(:pr_files_diffs)}` artifact to understand all changes made by this Pull Request
|
|
64
|
+
|
|
65
|
+
- This gives you context on how has this Pull Request been implemented.
|
|
66
|
+
EO_INSTRUCTION
|
|
67
|
+
<<~EO_INSTRUCTION,
|
|
68
|
+
Read the `#{feedback_analyst_agent.artifact_ref(:conversations)}` artifact to understand the full context of the PR conversations
|
|
69
|
+
|
|
70
|
+
- This gives you context on the discussions around this Pull Request.
|
|
71
|
+
EO_INSTRUCTION
|
|
72
|
+
<<~EO_INSTRUCTION,
|
|
73
|
+
Read the `#{feedback_analyst_agent.artifact_ref(:open_comments_to_agents)}` artifact to focus on agent-directed comments
|
|
74
|
+
|
|
75
|
+
- You must devise requirements that will address those exact comments.
|
|
76
|
+
EO_INSTRUCTION
|
|
77
|
+
<<~EO_INSTRUCTION,
|
|
78
|
+
Analyze agent-directed comments to identify specific requirements or tasks that need implementation
|
|
79
|
+
EO_INSTRUCTION
|
|
80
|
+
<<~EO_INSTRUCTION
|
|
81
|
+
Extract clear, actionable requirements from the comments in an artifact named `#{feedback_analyst_agent.artifact_ref(:requirements)}`
|
|
82
|
+
|
|
83
|
+
- If no implementation is required (e.g., comments are just questions), output "No requirements"
|
|
84
|
+
EO_INSTRUCTION
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
@artifacts[:requirements] = 'No requirements' if @artifacts[:requirements].strip.downcase == 'no requirements'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if @artifacts[:requirements] == 'No requirements'
|
|
92
|
+
@artifacts[:plan] = 'No implementation plan'
|
|
93
|
+
@artifacts[:files_diffs] = 'No changes'
|
|
94
|
+
else
|
|
95
|
+
step_agent(new_agent(DeveloperAgent, commit: true, pull_request: true))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@artifacts[:open_comments_to_agents].each.with_index do |comment, comment_idx|
|
|
99
|
+
step(:"reply_to_comment_#{comment_idx}") do
|
|
100
|
+
review_responder_agent = new_agent(ReviewResponderAgent, **Config.agent_options['free_complex_planning'])
|
|
101
|
+
step_agent(
|
|
102
|
+
review_responder_agent,
|
|
103
|
+
open_comment_for_reply: comment,
|
|
104
|
+
user_instructions: {
|
|
105
|
+
ordered_list: [
|
|
106
|
+
<<~EO_INSTRUCTION,
|
|
107
|
+
Read the `#{review_responder_agent.artifact_ref(:conversations)}` artifact to understand the full context of the PR conversations
|
|
108
|
+
|
|
109
|
+
- This gives you context on the discussions around this Pull Request.
|
|
110
|
+
EO_INSTRUCTION
|
|
111
|
+
<<~EO_INSTRUCTION,
|
|
112
|
+
Read the `#{review_responder_agent.artifact_ref(:requirements)}` artifact to understand what was implemented
|
|
113
|
+
|
|
114
|
+
- This gives you context on what has been implemented by other agents.
|
|
115
|
+
EO_INSTRUCTION
|
|
116
|
+
<<~EO_INSTRUCTION,
|
|
117
|
+
Read the `#{review_responder_agent.artifact_ref(:plan)}` artifact to understand the implementation approach
|
|
118
|
+
|
|
119
|
+
- This gives you context on how other agents implemented the requirements.
|
|
120
|
+
EO_INSTRUCTION
|
|
121
|
+
<<~EO_INSTRUCTION,
|
|
122
|
+
Read the `#{review_responder_agent.artifact_ref(:files_diffs)}` artifact to understand the specific code changes made
|
|
123
|
+
|
|
124
|
+
- This gives you context on what files have been modified.
|
|
125
|
+
EO_INSTRUCTION
|
|
126
|
+
<<~EO_INSTRUCTION,
|
|
127
|
+
Read the `#{review_responder_agent.artifact_ref(:open_comment_for_reply)}` artifact to understand the specific comment to respond to
|
|
128
|
+
|
|
129
|
+
- This is the EXACT comment that you should reply to.
|
|
130
|
+
EO_INSTRUCTION
|
|
131
|
+
<<~EO_INSTRUCTION
|
|
132
|
+
Generate a professional, helpful reply that addresses the comment appropriately, in the artifact named `#{review_responder_agent.artifact_ref(:reply)}`
|
|
133
|
+
|
|
134
|
+
- If requirements were implemented, explain what was done and how it addresses the comment.
|
|
135
|
+
- If no requirements existed, provide a helpful response explaining the situation.
|
|
136
|
+
EO_INSTRUCTION
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
full_reply = "[X-Aeon Agent #{review_responder_agent.full_name}] - #{@artifacts[:reply]}"
|
|
141
|
+
@artifacts[:replies] ||= []
|
|
142
|
+
@artifacts[:replies] << { 'comment_id' => comment['comment_id'], 'reply' => full_reply }
|
|
143
|
+
Helpers.github.create_pull_request_comment_reply(Helpers.github_repo, pull_request_number, full_reply, comment['comment_id'])
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
@artifacts
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Resolve the Pull Request number to process.
|
|
154
|
+
# When no number is given, auto-detects the Pull Request matching the current git branch.
|
|
155
|
+
#
|
|
156
|
+
# @param pull_request_number [Integer, nil] The explicit Pull Request number, or nil to auto-detect
|
|
157
|
+
# @return [Integer] The resolved Pull Request number
|
|
158
|
+
def resolve_pull_request_number(pull_request_number)
|
|
159
|
+
return pull_request_number if pull_request_number
|
|
160
|
+
|
|
161
|
+
current_branch = Helpers.git.current_branch
|
|
162
|
+
# TODO: Move this logic in a helper that is also used by PullRequestCreatorAgent, and remove this method.
|
|
163
|
+
pr = Helpers.github.pull_requests(Helpers.github_repo).find { |candidate| candidate.head.ref == current_branch }
|
|
164
|
+
raise "Unable to find a Pull Request for the current branch #{current_branch}" unless pr
|
|
165
|
+
|
|
166
|
+
pr.number
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Fetch and filter the Pull Request review comments that need an X-Aeon Agent reply.
|
|
170
|
+
#
|
|
171
|
+
# @param pull_request_number [Integer] The Pull Request number to address comments for
|
|
172
|
+
# @return [Array<Array<Hash{String => Object}>>] List of conversations (each a list of comments)
|
|
173
|
+
def gather_comments(pull_request_number)
|
|
174
|
+
owner, repo = Helpers.github_repo.split('/')
|
|
175
|
+
pr_json = Helpers.github.post(
|
|
176
|
+
'/graphql',
|
|
177
|
+
JSON.dump(
|
|
178
|
+
{
|
|
179
|
+
query: File.read("#{__dir__}/gh_comments.gql"),
|
|
180
|
+
variables: {
|
|
181
|
+
owner:,
|
|
182
|
+
repo:,
|
|
183
|
+
pr: pull_request_number
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
)[:data][:repository][:pullRequest]
|
|
188
|
+
pr_json[:reviewThreads][:edges].select do |review_thread|
|
|
189
|
+
!review_thread[:node][:isResolved] &&
|
|
190
|
+
review_thread[:node][:comments][:nodes].any? do |comment|
|
|
191
|
+
comment[:needAIReply] = comment[:body].start_with?('/agent') &&
|
|
192
|
+
comment_replies(review_thread[:node][:comments][:nodes], comment).none? { |reply| reply[:body].match(/^\[X-Aeon Agent [^\]]+?\]/) }
|
|
193
|
+
comment[:needAIReply]
|
|
194
|
+
end
|
|
195
|
+
end.map do |review_thread|
|
|
196
|
+
review_thread[:node][:comments][:nodes].sort_by { |comment| comment[:createdAt] }.map do |comment|
|
|
197
|
+
{
|
|
198
|
+
'comment_id' => comment[:databaseId],
|
|
199
|
+
'created_at' => comment[:createdAt],
|
|
200
|
+
'reply_to_comment_id' => comment.dig(:replyTo, :databaseId),
|
|
201
|
+
'author' => comment[:author][:login],
|
|
202
|
+
'body' => comment[:body],
|
|
203
|
+
'path' => comment[:path],
|
|
204
|
+
'need_ai_reply' => comment[:needAIReply]
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Get all the replies of a given comment.
|
|
211
|
+
# Replies are:
|
|
212
|
+
# - all the comments that have this comment as a direct reply,
|
|
213
|
+
# - plus the next (closest next creation date) comment that replied to the parent of the given comment,
|
|
214
|
+
# - plus all the replies of those replies (recursively).
|
|
215
|
+
#
|
|
216
|
+
# @param comments [Array<Hash>] All comments in the thread
|
|
217
|
+
# @param comment [Hash] The comment to check for replies
|
|
218
|
+
# @return [Array<Hash>] List of all the replies
|
|
219
|
+
def comment_replies(comments, comment)
|
|
220
|
+
comment_id = comment[:databaseId]
|
|
221
|
+
# All direct replies
|
|
222
|
+
replies = comments.select { |c| c.dig(:replyTo, :databaseId) == comment_id }
|
|
223
|
+
# All replies to the same parent, sorted by creation date
|
|
224
|
+
parent_comment_id = comment.dig(:replyTo, :databaseId)
|
|
225
|
+
unless parent_comment_id.nil?
|
|
226
|
+
created_at = Time.parse(comment[:createdAt])
|
|
227
|
+
next_parent_reply = comments
|
|
228
|
+
.select { |c| c.dig(:replyTo, :databaseId) == parent_comment_id && Time.parse(c[:createdAt]) > created_at }
|
|
229
|
+
.min_by { |c| c[:created_at] }
|
|
230
|
+
replies << next_parent_reply unless next_parent_reply.nil?
|
|
231
|
+
end
|
|
232
|
+
replies.map { |c| [c] + comment_replies(comments, c) }.flatten(1)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|