aircana 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.devcontainer/devcontainer.json +36 -0
- data/.dockerignore +14 -0
- data/.rspec_status +106 -0
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +19 -0
- data/CLAUDE.md +58 -0
- data/Dockerfile +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +251 -0
- data/Rakefile +12 -0
- data/SECURITY.md +15 -0
- data/compose.yml +13 -0
- data/exe/aircana +13 -0
- data/lib/aircana/cli/app.rb +93 -0
- data/lib/aircana/cli/commands/add_directory.rb +148 -0
- data/lib/aircana/cli/commands/add_files.rb +26 -0
- data/lib/aircana/cli/commands/agents.rb +152 -0
- data/lib/aircana/cli/commands/clear_files.rb +16 -0
- data/lib/aircana/cli/commands/doctor.rb +85 -0
- data/lib/aircana/cli/commands/doctor_checks.rb +131 -0
- data/lib/aircana/cli/commands/doctor_helpers.rb +119 -0
- data/lib/aircana/cli/commands/dump_context.rb +23 -0
- data/lib/aircana/cli/commands/generate.rb +34 -0
- data/lib/aircana/cli/commands/install.rb +67 -0
- data/lib/aircana/cli/commands/plan.rb +69 -0
- data/lib/aircana/cli/commands/work.rb +69 -0
- data/lib/aircana/cli/shell_command.rb +13 -0
- data/lib/aircana/cli/subcommand.rb +19 -0
- data/lib/aircana/cli.rb +8 -0
- data/lib/aircana/configuration.rb +41 -0
- data/lib/aircana/contexts/confluence.rb +141 -0
- data/lib/aircana/contexts/confluence_content.rb +36 -0
- data/lib/aircana/contexts/confluence_http.rb +41 -0
- data/lib/aircana/contexts/confluence_logging.rb +71 -0
- data/lib/aircana/contexts/confluence_setup.rb +15 -0
- data/lib/aircana/contexts/local.rb +47 -0
- data/lib/aircana/contexts/relevant_files.rb +78 -0
- data/lib/aircana/fzf_helper.rb +117 -0
- data/lib/aircana/generators/agents_generator.rb +75 -0
- data/lib/aircana/generators/base_generator.rb +61 -0
- data/lib/aircana/generators/helpers.rb +16 -0
- data/lib/aircana/generators/relevant_files_command_generator.rb +36 -0
- data/lib/aircana/generators/relevant_files_verbose_results_generator.rb +34 -0
- data/lib/aircana/generators.rb +10 -0
- data/lib/aircana/human_logger.rb +143 -0
- data/lib/aircana/initializers.rb +8 -0
- data/lib/aircana/llm/claude_client.rb +86 -0
- data/lib/aircana/progress_tracker.rb +55 -0
- data/lib/aircana/system_checker.rb +177 -0
- data/lib/aircana/templates/agents/base_agent.erb +30 -0
- data/lib/aircana/templates/agents/defaults/planner.erb +126 -0
- data/lib/aircana/templates/agents/defaults/worker.erb +185 -0
- data/lib/aircana/templates/commands/add_relevant_files.erb +3 -0
- data/lib/aircana/templates/relevant_files_verbose_results.erb +18 -0
- data/lib/aircana/version.rb +5 -0
- data/lib/aircana.rb +53 -0
- data/sig/aircana.rbs +4 -0
- metadata +189 -0
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aircana
|
4
|
+
module CLI
|
5
|
+
module DoctorHelpers
|
6
|
+
module Logging
|
7
|
+
def log_success(label, message)
|
8
|
+
Aircana.human_logger.success " ✅ #{label.ljust(15)} #{message}"
|
9
|
+
end
|
10
|
+
|
11
|
+
def log_failure(label, message)
|
12
|
+
Aircana.human_logger.error " ❌ #{label.ljust(15)} #{message}"
|
13
|
+
end
|
14
|
+
|
15
|
+
def log_warning(label, message)
|
16
|
+
Aircana.human_logger.warn " ⚠️ #{label.ljust(15)} #{message}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def log_info(label, message)
|
20
|
+
Aircana.human_logger.info " ℹ️ #{label.ljust(15)} #{message}"
|
21
|
+
end
|
22
|
+
|
23
|
+
def log_remedy(message)
|
24
|
+
Aircana.human_logger.info " → #{message}"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
module SystemChecks
|
29
|
+
def command_available?(command)
|
30
|
+
system("which #{command}", out: File::NULL, err: File::NULL)
|
31
|
+
end
|
32
|
+
|
33
|
+
def claude_available?
|
34
|
+
claude_path = find_claude_path
|
35
|
+
!claude_path.nil? && File.executable?(claude_path)
|
36
|
+
end
|
37
|
+
|
38
|
+
def find_claude_path
|
39
|
+
possible_paths = [
|
40
|
+
File.expand_path("~/.claude/local/claude"),
|
41
|
+
`which claude 2>/dev/null`.strip,
|
42
|
+
"/usr/local/bin/claude"
|
43
|
+
]
|
44
|
+
|
45
|
+
possible_paths.each do |path|
|
46
|
+
return path if !path.empty? && File.exist?(path) && File.executable?(path)
|
47
|
+
end
|
48
|
+
|
49
|
+
return "claude" if system("which claude > /dev/null 2>&1")
|
50
|
+
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
|
54
|
+
def mcp_tool_installed?(result)
|
55
|
+
$CHILD_STATUS.success? && !result.include?("not found") && !result.include?("error")
|
56
|
+
end
|
57
|
+
|
58
|
+
def detect_os
|
59
|
+
return "macOS" if RUBY_PLATFORM.match?(/darwin/)
|
60
|
+
return "Ubuntu/Debian" if File.exist?("/etc/debian_version")
|
61
|
+
|
62
|
+
"Other"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
module ConfigurationChecks
|
67
|
+
def confluence_configured?(config)
|
68
|
+
!config.confluence_base_url.to_s.empty? &&
|
69
|
+
!config.confluence_username.to_s.empty? &&
|
70
|
+
!config.confluence_api_token.to_s.empty?
|
71
|
+
end
|
72
|
+
|
73
|
+
def find_available_editors
|
74
|
+
%w[code subl atom nano vim vi].select { |cmd| command_available?(cmd) }
|
75
|
+
end
|
76
|
+
|
77
|
+
def check_directory(path, description)
|
78
|
+
expanded_path = File.expand_path(path)
|
79
|
+
if Dir.exist?(expanded_path)
|
80
|
+
log_success(File.basename(path), "#{description} exists")
|
81
|
+
else
|
82
|
+
log_info(File.basename(path), "#{description} not found")
|
83
|
+
log_remedy("Will be created on first use")
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
module InstallCommands
|
89
|
+
INSTALL_COMMANDS = {
|
90
|
+
"git" => {
|
91
|
+
"macOS" => "brew install git",
|
92
|
+
"Ubuntu/Debian" => "apt install git",
|
93
|
+
"Other" => "https://git-scm.com/downloads"
|
94
|
+
},
|
95
|
+
"fzf" => {
|
96
|
+
"macOS" => "brew install fzf",
|
97
|
+
"Ubuntu/Debian" => "apt install fzf",
|
98
|
+
"Other" => "https://github.com/junegunn/fzf#installation"
|
99
|
+
},
|
100
|
+
"bat" => {
|
101
|
+
"macOS" => "brew install bat",
|
102
|
+
"Ubuntu/Debian" => "apt install bat",
|
103
|
+
"Other" => "https://github.com/sharkdp/bat#installation"
|
104
|
+
},
|
105
|
+
"fd" => {
|
106
|
+
"macOS" => "brew install fd",
|
107
|
+
"Ubuntu/Debian" => "apt install fd-find",
|
108
|
+
"Other" => "https://github.com/sharkdp/fd#installation"
|
109
|
+
}
|
110
|
+
}.freeze
|
111
|
+
|
112
|
+
def install_command(tool)
|
113
|
+
os = detect_os
|
114
|
+
INSTALL_COMMANDS.dig(tool, os) || INSTALL_COMMANDS.dig(tool, "Other") || "Check package manager"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../shell_command"
|
4
|
+
require_relative "../../contexts/relevant_files"
|
5
|
+
|
6
|
+
module Aircana
|
7
|
+
module CLI
|
8
|
+
module DumpContext
|
9
|
+
class << self
|
10
|
+
def run(_agent_name:, verbose: true)
|
11
|
+
Aircana.logger.level = Logger::ERROR
|
12
|
+
Contexts::RelevantFiles.print(verbose:)
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def print(context)
|
18
|
+
Aircana.configuration.stream.puts context
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../generators/relevant_files_command_generator"
|
4
|
+
require_relative "../../generators/relevant_files_verbose_results_generator"
|
5
|
+
require_relative "../../generators/agents_generator"
|
6
|
+
|
7
|
+
module Aircana
|
8
|
+
module CLI
|
9
|
+
module Generate
|
10
|
+
class << self
|
11
|
+
def generators
|
12
|
+
@generators ||= [
|
13
|
+
Aircana::Generators::RelevantFilesVerboseResultsGenerator.new,
|
14
|
+
Aircana::Generators::RelevantFilesCommandGenerator.new
|
15
|
+
]
|
16
|
+
end
|
17
|
+
|
18
|
+
def run
|
19
|
+
generators.each(&:generate)
|
20
|
+
generate_default_agents
|
21
|
+
Aircana.human_logger.success("Re-generated #{Aircana.configuration.output_dir} files.")
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def generate_default_agents
|
27
|
+
Aircana::Generators::AgentsGenerator.available_default_agents.each do |agent_name|
|
28
|
+
Aircana::Generators::AgentsGenerator.create_default_agent(agent_name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "generate"
|
4
|
+
|
5
|
+
module Aircana
|
6
|
+
module CLI
|
7
|
+
module Install
|
8
|
+
class << self
|
9
|
+
def run
|
10
|
+
ensure_output_exists
|
11
|
+
install_commands_to_claude
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def ensure_output_exists
|
17
|
+
return if Dir.exist?(Aircana.configuration.output_dir)
|
18
|
+
|
19
|
+
Aircana.human_logger.warn("No generated output files-auto generating now...")
|
20
|
+
Generate.run
|
21
|
+
end
|
22
|
+
|
23
|
+
def install_commands_to_claude
|
24
|
+
claude_commands_dir = File.join(Aircana.configuration.claude_code_project_config_path, "commands")
|
25
|
+
Aircana.create_dir_if_needed(claude_commands_dir)
|
26
|
+
|
27
|
+
copy_command_files(claude_commands_dir)
|
28
|
+
install_agents_to_claude
|
29
|
+
end
|
30
|
+
|
31
|
+
def copy_command_files(destination_dir)
|
32
|
+
Dir.glob("#{Aircana.configuration.output_dir}/commands/*").each do |file|
|
33
|
+
Aircana.human_logger.success("Installing #{file} to #{destination_dir}")
|
34
|
+
FileUtils.cp(file, destination_dir)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def install_agents_to_claude
|
39
|
+
claude_agents_dir = File.join(Aircana.configuration.claude_code_project_config_path, "agents")
|
40
|
+
Aircana.create_dir_if_needed(claude_agents_dir)
|
41
|
+
|
42
|
+
copy_agent_files(claude_agents_dir)
|
43
|
+
end
|
44
|
+
|
45
|
+
def copy_agent_files(destination_dir)
|
46
|
+
agent_files_pattern = File.join(Aircana.configuration.claude_code_project_config_path, "agents", "*.md")
|
47
|
+
Dir.glob(agent_files_pattern).each do |file|
|
48
|
+
agent_name = File.basename(file, ".md")
|
49
|
+
next unless default_agent?(agent_name)
|
50
|
+
|
51
|
+
destination_file = File.join(destination_dir, File.basename(file))
|
52
|
+
# Skip copying if source and destination are the same
|
53
|
+
next if File.expand_path(file) == File.expand_path(destination_file)
|
54
|
+
|
55
|
+
Aircana.human_logger.success("Installing default agent #{file} to #{destination_dir}")
|
56
|
+
FileUtils.cp(file, destination_dir)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def default_agent?(agent_name)
|
61
|
+
require_relative "../../generators/agents_generator"
|
62
|
+
Aircana::Generators::AgentsGenerator.available_default_agents.include?(agent_name)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "generate"
|
4
|
+
require_relative "install"
|
5
|
+
require_relative "../../generators/agents_generator"
|
6
|
+
|
7
|
+
module Aircana
|
8
|
+
module CLI
|
9
|
+
module Plan
|
10
|
+
class << self
|
11
|
+
def run
|
12
|
+
ensure_planner_agent_installed
|
13
|
+
launch_claude_with_planner
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def ensure_planner_agent_installed
|
19
|
+
planner_agent_path = File.join(
|
20
|
+
Aircana.configuration.claude_code_config_path,
|
21
|
+
"agents",
|
22
|
+
"planner.md"
|
23
|
+
)
|
24
|
+
|
25
|
+
return if File.exist?(planner_agent_path)
|
26
|
+
|
27
|
+
Aircana.human_logger.info("Planner agent not found. Generating and installing...")
|
28
|
+
Generate.run
|
29
|
+
Install.run
|
30
|
+
end
|
31
|
+
|
32
|
+
def launch_claude_with_planner
|
33
|
+
prompt = "Start a planning session with the 'planner' sub-agent"
|
34
|
+
|
35
|
+
Aircana.human_logger.info("Launching Claude Code with planner agent...")
|
36
|
+
|
37
|
+
claude_path = find_claude_path
|
38
|
+
if claude_path
|
39
|
+
system("#{claude_path} \"#{prompt}\"")
|
40
|
+
else
|
41
|
+
handle_claude_not_found(prompt)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_claude_not_found(prompt)
|
46
|
+
error_message = "Claude Code command not found. " \
|
47
|
+
"Please make sure Claude Code is installed and in your PATH."
|
48
|
+
Aircana.human_logger.error(error_message)
|
49
|
+
Aircana.human_logger.info("You can manually start Claude Code and run: #{prompt}")
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_claude_path
|
53
|
+
# Try common locations for Claude Code binary (same as ClaudeClient)
|
54
|
+
possible_paths = [
|
55
|
+
File.expand_path("~/.claude/local/claude"),
|
56
|
+
`/usr/bin/which claude`.strip,
|
57
|
+
"/usr/local/bin/claude"
|
58
|
+
]
|
59
|
+
|
60
|
+
possible_paths.each do |path|
|
61
|
+
return path if !path.empty? && File.executable?(path)
|
62
|
+
end
|
63
|
+
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "generate"
|
4
|
+
require_relative "install"
|
5
|
+
require_relative "../../generators/agents_generator"
|
6
|
+
|
7
|
+
module Aircana
|
8
|
+
module CLI
|
9
|
+
module Work
|
10
|
+
class << self
|
11
|
+
def run
|
12
|
+
ensure_worker_agent_installed
|
13
|
+
launch_claude_with_worker
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def ensure_worker_agent_installed
|
19
|
+
worker_agent_path = File.join(
|
20
|
+
Aircana.configuration.claude_code_config_path,
|
21
|
+
"agents",
|
22
|
+
"worker.md"
|
23
|
+
)
|
24
|
+
|
25
|
+
return if File.exist?(worker_agent_path)
|
26
|
+
|
27
|
+
Aircana.human_logger.info("Worker agent not found. Generating and installing...")
|
28
|
+
Generate.run
|
29
|
+
Install.run
|
30
|
+
end
|
31
|
+
|
32
|
+
def launch_claude_with_worker
|
33
|
+
prompt = "Start a work session with the 'worker' sub-agent"
|
34
|
+
|
35
|
+
Aircana.human_logger.info("Launching Claude Code with worker agent...")
|
36
|
+
|
37
|
+
claude_path = find_claude_path
|
38
|
+
if claude_path
|
39
|
+
system("#{claude_path} \"#{prompt}\"")
|
40
|
+
else
|
41
|
+
handle_claude_not_found(prompt)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_claude_not_found(prompt)
|
46
|
+
error_message = "Claude Code command not found. " \
|
47
|
+
"Please make sure Claude Code is installed and in your PATH."
|
48
|
+
Aircana.human_logger.error(error_message)
|
49
|
+
Aircana.human_logger.info("You can manually start Claude Code and run: #{prompt}")
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_claude_path
|
53
|
+
# Try common locations for Claude Code binary (same as ClaudeClient)
|
54
|
+
possible_paths = [
|
55
|
+
File.expand_path("~/.claude/local/claude"),
|
56
|
+
`/usr/bin/which claude`.strip,
|
57
|
+
"/usr/local/bin/claude"
|
58
|
+
]
|
59
|
+
|
60
|
+
possible_paths.each do |path|
|
61
|
+
return path if !path.empty? && File.executable?(path)
|
62
|
+
end
|
63
|
+
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
|
5
|
+
module Aircana
|
6
|
+
module CLI
|
7
|
+
class Subcommand < Thor
|
8
|
+
def self.banner(command, _namespace = nil, _subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
|
9
|
+
"#{basename} #{subcommand_prefix} #{command.usage}"
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.subcommand_prefix
|
13
|
+
name.gsub(/.*::/, "").gsub(/^[A-Z]/) do |match|
|
14
|
+
match[0].downcase
|
15
|
+
end.gsub(/[A-Z]/) { |match| "-#{match[0].downcase}" } # rubocop:disable Style/MultilineBlockChain
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/lib/aircana/cli.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aircana
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :global_dir, :project_dir, :relevant_project_files_dir, :stream, :output_dir,
|
6
|
+
:claude_code_config_path, :claude_code_project_config_path, :agent_knowledge_dir,
|
7
|
+
:confluence_base_url, :confluence_username, :confluence_api_token
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
setup_directory_paths
|
11
|
+
setup_claude_code_paths
|
12
|
+
setup_stream
|
13
|
+
setup_confluence_config
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def setup_directory_paths
|
19
|
+
@global_dir = File.join(Dir.home, ".aircana")
|
20
|
+
@project_dir = Dir.pwd
|
21
|
+
@relevant_project_files_dir = File.join(@project_dir, ".aircana", "relevant_files")
|
22
|
+
@output_dir = File.join(@global_dir, "aircana.out")
|
23
|
+
@agent_knowledge_dir = File.join(@project_dir, ".aircana", "agents")
|
24
|
+
end
|
25
|
+
|
26
|
+
def setup_claude_code_paths
|
27
|
+
@claude_code_config_path = File.join(Dir.home, ".claude")
|
28
|
+
@claude_code_project_config_path = File.join(Dir.pwd, ".claude")
|
29
|
+
end
|
30
|
+
|
31
|
+
def setup_stream
|
32
|
+
@stream = $stdout
|
33
|
+
end
|
34
|
+
|
35
|
+
def setup_confluence_config
|
36
|
+
@confluence_base_url = nil
|
37
|
+
@confluence_username = nil
|
38
|
+
@confluence_api_token = nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "httparty"
|
4
|
+
require "reverse_markdown"
|
5
|
+
require_relative "local"
|
6
|
+
require_relative "confluence_logging"
|
7
|
+
require_relative "confluence_http"
|
8
|
+
require_relative "confluence_content"
|
9
|
+
require_relative "confluence_setup"
|
10
|
+
|
11
|
+
module Aircana
|
12
|
+
module Contexts
|
13
|
+
class Confluence
|
14
|
+
include HTTParty
|
15
|
+
include ConfluenceLogging
|
16
|
+
include ConfluenceHttp
|
17
|
+
include ConfluenceContent
|
18
|
+
include ConfluenceSetup
|
19
|
+
|
20
|
+
LABEL_PREFIX = "global"
|
21
|
+
|
22
|
+
def initialize
|
23
|
+
@local_storage = Local.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def fetch_pages_for(agent:)
|
27
|
+
validate_configuration!
|
28
|
+
setup_httparty
|
29
|
+
|
30
|
+
pages = search_and_log_pages(agent)
|
31
|
+
return 0 if pages.empty?
|
32
|
+
|
33
|
+
process_pages(pages, agent)
|
34
|
+
pages.size
|
35
|
+
end
|
36
|
+
|
37
|
+
def search_and_log_pages(agent)
|
38
|
+
pages = ProgressTracker.with_spinner("Searching for pages labeled '#{agent}'") do
|
39
|
+
fetch_pages_by_label(agent)
|
40
|
+
end
|
41
|
+
log_pages_found(pages.size, agent)
|
42
|
+
pages
|
43
|
+
end
|
44
|
+
|
45
|
+
def process_pages(pages, agent)
|
46
|
+
ProgressTracker.with_batch_progress(pages, "Processing pages") do |page, _index|
|
47
|
+
store_page_as_markdown(page, agent)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def validate_configuration!
|
54
|
+
config = Aircana.configuration
|
55
|
+
|
56
|
+
validate_base_url(config)
|
57
|
+
validate_username(config)
|
58
|
+
validate_api_token(config)
|
59
|
+
end
|
60
|
+
|
61
|
+
def validate_base_url(config)
|
62
|
+
return unless config.confluence_base_url.nil? || config.confluence_base_url.empty?
|
63
|
+
|
64
|
+
raise Error, "Confluence base URL not configured"
|
65
|
+
end
|
66
|
+
|
67
|
+
def validate_username(config)
|
68
|
+
return unless config.confluence_username.nil? || config.confluence_username.empty?
|
69
|
+
|
70
|
+
raise Error, "Confluence username not configured"
|
71
|
+
end
|
72
|
+
|
73
|
+
def validate_api_token(config)
|
74
|
+
return unless config.confluence_api_token.nil? || config.confluence_api_token.empty?
|
75
|
+
|
76
|
+
raise Error, "Confluence API token not configured"
|
77
|
+
end
|
78
|
+
|
79
|
+
def fetch_pages_by_label(agent)
|
80
|
+
label_id = find_label_id(agent)
|
81
|
+
return [] if label_id.nil?
|
82
|
+
|
83
|
+
response = get_pages_for_label(label_id)
|
84
|
+
response["results"] || []
|
85
|
+
rescue HTTParty::Error, StandardError => e
|
86
|
+
handle_api_error("fetch pages for agent '#{agent}'", e, "Failed to fetch pages from Confluence")
|
87
|
+
end
|
88
|
+
|
89
|
+
def find_label_id(agent_name)
|
90
|
+
path = "/wiki/api/v2/labels"
|
91
|
+
query_params = { limit: 250, prefix: LABEL_PREFIX }
|
92
|
+
page_number = 1
|
93
|
+
|
94
|
+
label_id = search_labels_pagination(path, query_params, agent_name, page_number)
|
95
|
+
clear_pagination_line
|
96
|
+
label_id
|
97
|
+
end
|
98
|
+
|
99
|
+
def search_labels_pagination(path, query_params, agent_name, page_number)
|
100
|
+
loop do
|
101
|
+
response = fetch_labels_page(path, query_params, page_number)
|
102
|
+
label_id = find_matching_label_id(response, agent_name)
|
103
|
+
return label_id if label_id
|
104
|
+
|
105
|
+
next_cursor = extract_next_cursor(response)
|
106
|
+
break unless next_cursor
|
107
|
+
|
108
|
+
query_params[:cursor] = next_cursor
|
109
|
+
page_number += 1
|
110
|
+
end
|
111
|
+
|
112
|
+
nil
|
113
|
+
end
|
114
|
+
|
115
|
+
def fetch_labels_page(path, query_params, page_number)
|
116
|
+
log_request("GET", path, query_params.merge("Page" => page_number), pagination: true)
|
117
|
+
response = self.class.get(path, { query: query_params })
|
118
|
+
log_response(response, "Labels lookup (page #{page_number})", pagination: true)
|
119
|
+
validate_response(response)
|
120
|
+
response
|
121
|
+
end
|
122
|
+
|
123
|
+
def find_matching_label_id(response, agent_name)
|
124
|
+
labels = response["results"] || []
|
125
|
+
matching_label = labels.find { |label| label["name"] == agent_name }
|
126
|
+
matching_label&.[]("id")
|
127
|
+
end
|
128
|
+
|
129
|
+
def extract_next_cursor(response)
|
130
|
+
next_url = get_next_page_url(response)
|
131
|
+
return nil unless next_url&.include?("cursor=")
|
132
|
+
|
133
|
+
next_url.match(/cursor=([^&]+)/)[1]
|
134
|
+
end
|
135
|
+
|
136
|
+
def clear_pagination_line
|
137
|
+
print "\r\e[K"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aircana
|
4
|
+
module Contexts
|
5
|
+
module ConfluenceContent
|
6
|
+
def fetch_page_content(page_id)
|
7
|
+
Aircana.human_logger.info("Looking for page with ID `#{page_id}`")
|
8
|
+
response = get_page_content(page_id)
|
9
|
+
response.dig("body", "storage", "value") || ""
|
10
|
+
rescue HTTParty::Error, StandardError => e
|
11
|
+
handle_api_error("fetch content for page #{page_id}", e, "Failed to fetch page content")
|
12
|
+
end
|
13
|
+
|
14
|
+
def convert_to_markdown(html_content)
|
15
|
+
return "" if html_content.nil? || html_content.empty?
|
16
|
+
|
17
|
+
ReverseMarkdown.convert(html_content, github_flavored: true)
|
18
|
+
end
|
19
|
+
|
20
|
+
def log_pages_found(count, agent)
|
21
|
+
Aircana.human_logger.info "Found #{count} pages for agent '#{agent}'"
|
22
|
+
end
|
23
|
+
|
24
|
+
def store_page_as_markdown(page, agent)
|
25
|
+
content = page&.dig("body", "storage", "value") || fetch_page_content(page&.[]("id"))
|
26
|
+
markdown_content = convert_to_markdown(content)
|
27
|
+
|
28
|
+
@local_storage.store_content(
|
29
|
+
title: page&.[]("title"),
|
30
|
+
content: markdown_content,
|
31
|
+
agent: agent
|
32
|
+
)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aircana
|
4
|
+
module Contexts
|
5
|
+
module ConfluenceHttp
|
6
|
+
def get_pages_for_label(label_id)
|
7
|
+
path = "/wiki/api/v2/labels/#{label_id}/pages"
|
8
|
+
query_params = { "body-format" => "storage", limit: 100 }
|
9
|
+
|
10
|
+
log_request("GET", path, query_params)
|
11
|
+
|
12
|
+
response = self.class.get(path, { query: query_params })
|
13
|
+
log_response(response, "Pages for label")
|
14
|
+
validate_response(response)
|
15
|
+
response
|
16
|
+
end
|
17
|
+
|
18
|
+
def get_page_content(page_id)
|
19
|
+
path = "/rest/api/content/#{page_id}"
|
20
|
+
query_params = { expand: "body.storage" }
|
21
|
+
|
22
|
+
log_request("GET", path, query_params)
|
23
|
+
|
24
|
+
response = self.class.get(path, { query: query_params })
|
25
|
+
log_response(response, "Page content")
|
26
|
+
validate_response(response)
|
27
|
+
response
|
28
|
+
end
|
29
|
+
|
30
|
+
def validate_response(response)
|
31
|
+
return if response.success?
|
32
|
+
|
33
|
+
raise Error, "HTTP #{response.code}: #{response.message}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_next_page_url(response)
|
37
|
+
response.dig("_links", "next")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|