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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +136 -0
  3. data/CHANGELOG.md +398 -0
  4. data/README.md +579 -0
  5. data/TODO.md +48 -0
  6. data/bin/xaa +5 -0
  7. data/lib/x_aeon_agents/agent_defaults.rb +60 -0
  8. data/lib/x_aeon_agents/agent_options.rb +58 -0
  9. data/lib/x_aeon_agents/agents/coder_agent.rb +82 -0
  10. data/lib/x_aeon_agents/agents/committer_agent.rb +71 -0
  11. data/lib/x_aeon_agents/agents/developer_agent.rb +235 -0
  12. data/lib/x_aeon_agents/agents/diff_interpreter_agent.rb +80 -0
  13. data/lib/x_aeon_agents/agents/documenter_agent.rb +44 -0
  14. data/lib/x_aeon_agents/agents/executor_agent.rb +9 -0
  15. data/lib/x_aeon_agents/agents/feedback_analyst_agent.rb +62 -0
  16. data/lib/x_aeon_agents/agents/gh_comments.gql +64 -0
  17. data/lib/x_aeon_agents/agents/git_diff_interpreter_agent.rb +57 -0
  18. data/lib/x_aeon_agents/agents/issue_implementer_agent.rb +88 -0
  19. data/lib/x_aeon_agents/agents/one_line_code_diff_summarizer_agent.rb +57 -0
  20. data/lib/x_aeon_agents/agents/plan_generator_agent.rb +51 -0
  21. data/lib/x_aeon_agents/agents/planner_agent.rb +89 -0
  22. data/lib/x_aeon_agents/agents/pull_request_creator_agent.rb +144 -0
  23. data/lib/x_aeon_agents/agents/readme/about_analyzer_agent.rb +55 -0
  24. data/lib/x_aeon_agents/agents/readme/contributing_agent.rb +47 -0
  25. data/lib/x_aeon_agents/agents/readme/development_agent.rb +54 -0
  26. data/lib/x_aeon_agents/agents/readme/documentation_agent.rb +43 -0
  27. data/lib/x_aeon_agents/agents/readme/features_agent.rb +45 -0
  28. data/lib/x_aeon_agents/agents/readme/how_it_works_agent.rb +47 -0
  29. data/lib/x_aeon_agents/agents/readme/license_agent.rb +43 -0
  30. data/lib/x_aeon_agents/agents/readme/public_api_agent.rb +44 -0
  31. data/lib/x_aeon_agents/agents/readme/quick_start_agent.rb +50 -0
  32. data/lib/x_aeon_agents/agents/readme/requirements_agent.rb +43 -0
  33. data/lib/x_aeon_agents/agents/readme_generator_agent.rb +438 -0
  34. data/lib/x_aeon_agents/agents/review_resolver_agent.rb +236 -0
  35. data/lib/x_aeon_agents/agents/review_responder_agent.rb +64 -0
  36. data/lib/x_aeon_agents/agents/skill_generator_agent.rb +95 -0
  37. data/lib/x_aeon_agents/agents/skill_installer_agent.rb +103 -0
  38. data/lib/x_aeon_agents/agents/task_starter_agent.rb +66 -0
  39. data/lib/x_aeon_agents/agents/tester_agent.rb +44 -0
  40. data/lib/x_aeon_agents/cli.rb +384 -0
  41. data/lib/x_aeon_agents/config.rb +110 -0
  42. data/lib/x_aeon_agents/gen_helpers.rb +270 -0
  43. data/lib/x_aeon_agents/helpers.rb +211 -0
  44. data/lib/x_aeon_agents/logger.rb +21 -0
  45. data/lib/x_aeon_agents/providers/cline.rb +78 -0
  46. data/lib/x_aeon_agents/version.rb +6 -0
  47. data/lib/x_aeon_agents.rb +38 -0
  48. 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
+ "[![Build](https://github.com/#{Helpers.github_repo}/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/#{Helpers.github_repo}/actions/workflows/continuous_integration.yml)",
324
+ "[![Test Coverage](https://img.shields.io/codecov/c/gh/#{Helpers.github_repo})](https://codecov.io/gh/#{Helpers.github_repo})",
325
+ "[![GitHub stars](https://img.shields.io/github/stars/#{Helpers.github_repo})](https://github.com/#{Helpers.github_repo}/stargazers)",
326
+ "[![License](https://img.shields.io/github/license/#{Helpers.github_repo})](LICENSE)"
327
+ )
328
+ end
329
+ if Helpers.gem_name
330
+ badges.push(
331
+ "[![Gem Version](https://img.shields.io/gem/v/#{Helpers.gem_name})](https://rubygems.org/gems/#{Helpers.gem_name})",
332
+ "[![Gem Total Downloads](https://img.shields.io/gem/dt/#{Helpers.gem_name})](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