docit 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/CONTRIBUTING.md +1 -1
- data/README.md +102 -9
- data/app/controllers/docit/ui_controller.rb +16 -4
- data/lib/docit/ai/anthropic_client.rb +63 -0
- data/lib/docit/ai/autodoc_runner.rb +184 -0
- data/lib/docit/ai/client.rb +32 -0
- data/lib/docit/ai/configuration.rb +66 -0
- data/lib/docit/ai/doc_writer.rb +120 -0
- data/lib/docit/ai/gap_detector.rb +54 -0
- data/lib/docit/ai/groq_client.rb +79 -0
- data/lib/docit/ai/openai_client.rb +61 -0
- data/lib/docit/ai/prompt_builder.rb +124 -0
- data/lib/docit/ai/scaffold_generator.rb +140 -0
- data/lib/docit/ai/tag_injector.rb +52 -0
- data/lib/docit/ai.rb +13 -0
- data/lib/docit/version.rb +1 -1
- data/lib/docit.rb +3 -0
- data/lib/generators/docit/ai_setup/ai_setup_generator.rb +106 -0
- data/lib/generators/docit/install/install_generator.rb +178 -3
- data/lib/tasks/docit.rake +19 -0
- metadata +25 -10
- data/.github/workflows/ci.yml +0 -30
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Docit
|
|
7
|
+
module Ai
|
|
8
|
+
class DocWriter
|
|
9
|
+
def initialize(controller_name:)
|
|
10
|
+
@controller_name = controller_name
|
|
11
|
+
@module_parts = build_module_parts
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def doc_file_path
|
|
15
|
+
parts = @controller_name.underscore.delete_suffix("_controller").split("/")
|
|
16
|
+
filename = "#{parts.last}_docs.rb"
|
|
17
|
+
dir_parts = parts[0..-2]
|
|
18
|
+
|
|
19
|
+
File.join(Rails.root, "app", "docs", *dir_parts, filename)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def doc_module_name
|
|
23
|
+
@controller_name.delete_suffix("Controller").gsub("::", "::") + "Docs"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def file_exists?
|
|
27
|
+
File.exist?(doc_file_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def controller_has_use_docs?
|
|
31
|
+
path = controller_file_path
|
|
32
|
+
return false if path && File.exist?(path) == false
|
|
33
|
+
|
|
34
|
+
File.read(path).include?("use_docs")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inject_use_docs
|
|
38
|
+
path = controller_file_path
|
|
39
|
+
return false if path && File.exist?(path) == false
|
|
40
|
+
return false if controller_has_use_docs?
|
|
41
|
+
|
|
42
|
+
content = File.read(path)
|
|
43
|
+
class_pattern = /^(\s*class\s+\S+.*$)/
|
|
44
|
+
match = content.match(class_pattern)
|
|
45
|
+
return false if match.nil?
|
|
46
|
+
|
|
47
|
+
indent = match[1][/^\s*/] + " "
|
|
48
|
+
use_docs_line = "#{indent}use_docs #{doc_module_name}\n"
|
|
49
|
+
content = content.sub(class_pattern, "\\1\n#{use_docs_line}")
|
|
50
|
+
|
|
51
|
+
File.write(path, content)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def write(doc_blocks)
|
|
56
|
+
if file_exists?
|
|
57
|
+
append_to_existing(doc_blocks)
|
|
58
|
+
else
|
|
59
|
+
create_new_file(doc_blocks)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def create_new_file(doc_blocks)
|
|
66
|
+
FileUtils.mkdir_p(File.dirname(doc_file_path))
|
|
67
|
+
|
|
68
|
+
content = build_new_file_content(doc_blocks)
|
|
69
|
+
File.write(doc_file_path, content)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def append_to_existing(doc_blocks)
|
|
73
|
+
content = File.read(doc_file_path)
|
|
74
|
+
insertion = doc_blocks.map { |block| indent_block(block, @module_parts.length + 1) }.join("\n\n")
|
|
75
|
+
closing_ends = "end\n" * @module_parts.length
|
|
76
|
+
|
|
77
|
+
content = content.rstrip
|
|
78
|
+
content = content.delete_suffix(closing_ends.rstrip)
|
|
79
|
+
content = "#{content.rstrip}\n\n#{insertion}\n#{closing_ends}"
|
|
80
|
+
|
|
81
|
+
File.write(doc_file_path, content)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_new_file_content(doc_blocks)
|
|
85
|
+
lines = ["# frozen_string_literal: true", ""]
|
|
86
|
+
|
|
87
|
+
@module_parts.each_with_index do |part, i|
|
|
88
|
+
lines << "#{" " * i}module #{part}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
depth = @module_parts.length
|
|
92
|
+
lines << "#{" " * depth}extend Docit::DocFile"
|
|
93
|
+
|
|
94
|
+
doc_blocks.each do |block|
|
|
95
|
+
lines << ""
|
|
96
|
+
lines << indent_block(block, depth)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@module_parts.length.times do |i|
|
|
100
|
+
lines << "#{" " * (@module_parts.length - 1 - i)}end"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
lines.join("\n") + "\n"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def indent_block(block, depth)
|
|
107
|
+
prefix = " " * depth
|
|
108
|
+
block.strip.lines.map { |line| line.rstrip.empty? ? "" : "#{prefix}#{line.rstrip}" }.join("\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_module_parts
|
|
112
|
+
doc_module_name.split("::")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def controller_file_path
|
|
116
|
+
Rails.root.join("app", "controllers", "#{@controller_name.underscore}.rb").to_s
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class GapDetector
|
|
6
|
+
SKIP_PREFIXES = %w[docit/ rails/ active_storage/ action_mailbox/].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(controller_filter: nil)
|
|
9
|
+
@controller_filter = controller_filter
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def detect
|
|
13
|
+
RouteInspector.eager_load_controllers!
|
|
14
|
+
|
|
15
|
+
all_routes.each_with_object([]) do |route_info, gaps|
|
|
16
|
+
controller = route_info[:controller]
|
|
17
|
+
action = route_info[:action]
|
|
18
|
+
|
|
19
|
+
next if Registry.find(controller: controller, action: action)
|
|
20
|
+
|
|
21
|
+
routes = RouteInspector.routes_for(controller, action)
|
|
22
|
+
next if routes.empty?
|
|
23
|
+
|
|
24
|
+
gaps << {
|
|
25
|
+
controller: controller,
|
|
26
|
+
action: action,
|
|
27
|
+
path: routes.first[:path],
|
|
28
|
+
method: routes.first[:method]
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def all_routes
|
|
36
|
+
Rails.application.routes.routes.filter_map do |route|
|
|
37
|
+
controller_path = route.defaults[:controller]
|
|
38
|
+
action = route.defaults[:action]
|
|
39
|
+
next if controller_path.nil? || action.nil?
|
|
40
|
+
next if skip_route?(controller_path)
|
|
41
|
+
|
|
42
|
+
controller_class = "#{controller_path}_controller".camelize
|
|
43
|
+
next if @controller_filter && controller_class != @controller_filter
|
|
44
|
+
|
|
45
|
+
{ controller: controller_class, action: action }
|
|
46
|
+
end.uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def skip_route?(controller_path)
|
|
50
|
+
SKIP_PREFIXES.any? { |prefix| controller_path.start_with?(prefix) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Docit
|
|
8
|
+
module Ai
|
|
9
|
+
class GroqClient
|
|
10
|
+
API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, model:)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@model = model
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(prompt)
|
|
18
|
+
uri = URI(API_URL)
|
|
19
|
+
request = Net::HTTP::Post.new(uri)
|
|
20
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
21
|
+
request["Content-Type"] = "application/json"
|
|
22
|
+
request.body = {
|
|
23
|
+
model: @model,
|
|
24
|
+
messages: [{ role: "user", content: prompt }],
|
|
25
|
+
temperature: 0.2
|
|
26
|
+
}.to_json
|
|
27
|
+
|
|
28
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
|
|
29
|
+
http.request(request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
handle_response(response)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def handle_response(response)
|
|
38
|
+
body = parse_json(response, "Groq")
|
|
39
|
+
|
|
40
|
+
if response.is_a?(Net::HTTPSuccess) == false
|
|
41
|
+
message = body.dig("error", "message") || "Unknown API error"
|
|
42
|
+
|
|
43
|
+
if response.code == "429"
|
|
44
|
+
retry_after = parse_retry_after(response, message)
|
|
45
|
+
raise RateLimitError.new("Groq rate limit exceeded", retry_after: retry_after)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise Error, "Groq API error (#{response.code}): #{message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from Groq")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_json(response, provider_name)
|
|
55
|
+
JSON.parse(response.body)
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_retry_after(response, message)
|
|
61
|
+
# Check Retry-After header first (seconds)
|
|
62
|
+
if (header = response["Retry-After"])
|
|
63
|
+
return header.to_f if header.to_f > 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Parse "try again in XmY.Zs" from error message
|
|
67
|
+
if message =~ /(\d+)m([\d.]+)s/
|
|
68
|
+
return (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_f
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if message =~ /([\d.]+)s/
|
|
72
|
+
return Regexp.last_match(1).to_f
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Docit
|
|
8
|
+
module Ai
|
|
9
|
+
class OpenaiClient
|
|
10
|
+
API_URL = "https://api.openai.com/v1/chat/completions"
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, model:)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@model = model
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(prompt)
|
|
18
|
+
uri = URI(API_URL)
|
|
19
|
+
request = Net::HTTP::Post.new(uri)
|
|
20
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
21
|
+
request["Content-Type"] = "application/json"
|
|
22
|
+
request.body = {
|
|
23
|
+
model: @model,
|
|
24
|
+
messages: [{ role: "user", content: prompt }],
|
|
25
|
+
temperature: 0.2
|
|
26
|
+
}.to_json
|
|
27
|
+
|
|
28
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
|
|
29
|
+
http.request(request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
handle_response(response)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def handle_response(response)
|
|
38
|
+
body = parse_json(response, "OpenAI")
|
|
39
|
+
|
|
40
|
+
if response.is_a?(Net::HTTPSuccess) == false
|
|
41
|
+
message = body.dig("error", "message") || "Unknown API error"
|
|
42
|
+
|
|
43
|
+
if response.code == "429"
|
|
44
|
+
retry_after = response["Retry-After"]&.to_f
|
|
45
|
+
raise RateLimitError.new("OpenAI rate limit exceeded", retry_after: retry_after)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise Error, "OpenAI API error (#{response.code}): #{message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from OpenAI")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_json(response, provider_name)
|
|
55
|
+
JSON.parse(response.body)
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class PromptBuilder
|
|
6
|
+
DSL_REFERENCE = <<~DSL
|
|
7
|
+
Available DSL methods inside a `doc :action_name do ... end` block:
|
|
8
|
+
summary "Short description"
|
|
9
|
+
description "Detailed description"
|
|
10
|
+
tags "TagName"
|
|
11
|
+
deprecated value: true
|
|
12
|
+
security :bearer_auth
|
|
13
|
+
|
|
14
|
+
parameter :name, location: :query|:path|:header, type: :string|:integer|:boolean, required: true/false, description: "..."
|
|
15
|
+
parameter :id, location: :path, type: :string, required: true
|
|
16
|
+
|
|
17
|
+
request_body required: true do
|
|
18
|
+
property :field, type: :string|:integer|:boolean|:array|:object|:file, required: true/false, example: "value"
|
|
19
|
+
property :field, type: :string, enum: %w[option1 option2]
|
|
20
|
+
property :nested, type: :object do
|
|
21
|
+
property :child, type: :string
|
|
22
|
+
end
|
|
23
|
+
property :items, type: :array do
|
|
24
|
+
property :id, type: :string
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
response 200, "Description" do
|
|
29
|
+
property :field, type: :string, example: "value"
|
|
30
|
+
property :nested, type: :object do
|
|
31
|
+
property :child, type: :string
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
response 404, "Not found" do
|
|
35
|
+
property :error, type: :string, example: "Not found"
|
|
36
|
+
end
|
|
37
|
+
DSL
|
|
38
|
+
|
|
39
|
+
EXAMPLE_DOC = <<~EXAMPLE
|
|
40
|
+
doc :register do
|
|
41
|
+
summary "Register a new user"
|
|
42
|
+
description "Creates a customer account and sends verification email"
|
|
43
|
+
tags "Authentication"
|
|
44
|
+
|
|
45
|
+
request_body required: true do
|
|
46
|
+
property :email, type: :string, required: true, example: "user@example.com"
|
|
47
|
+
property :password, type: :string, required: true, format: :password
|
|
48
|
+
property :full_name, type: :string, required: true, example: "John Doe"
|
|
49
|
+
property :role, type: :string, enum: %w[customer provider]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
response 201, "Account created successfully" do
|
|
53
|
+
property :user_id, type: :string, example: "123e4567-e89b-12d3-a456-426614174000"
|
|
54
|
+
property :email, type: :string, example: "user@example.com"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
response 422, "Validation error" do
|
|
58
|
+
property :errors, type: :object do
|
|
59
|
+
property :email, type: :array, items: :string
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
EXAMPLE
|
|
64
|
+
|
|
65
|
+
def initialize(gap:)
|
|
66
|
+
@gap = gap
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build
|
|
70
|
+
<<~PROMPT
|
|
71
|
+
You are generating Docit DSL documentation for a Ruby on Rails API endpoint.
|
|
72
|
+
|
|
73
|
+
#{DSL_REFERENCE}
|
|
74
|
+
|
|
75
|
+
Here is a complete example of a well-documented endpoint:
|
|
76
|
+
#{EXAMPLE_DOC}
|
|
77
|
+
|
|
78
|
+
Now generate documentation for:
|
|
79
|
+
- Controller: #{@gap[:controller]}
|
|
80
|
+
- Action: #{@gap[:action]}
|
|
81
|
+
- HTTP method: #{@gap[:method].upcase}
|
|
82
|
+
- Path: #{@gap[:path]}
|
|
83
|
+
|
|
84
|
+
Controller source code:
|
|
85
|
+
```ruby
|
|
86
|
+
#{controller_source}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Rules:
|
|
90
|
+
- Output ONLY the `doc :#{@gap[:action]} do ... end` block
|
|
91
|
+
- No module wrapper, no explanation, no markdown fences
|
|
92
|
+
- Infer parameters from the path (e.g., {id} → path parameter)
|
|
93
|
+
- Infer request body from params usage in the controller
|
|
94
|
+
- Infer response structure from render calls
|
|
95
|
+
- Use realistic examples
|
|
96
|
+
- Include appropriate error responses
|
|
97
|
+
- Use the controller name to determine appropriate tags
|
|
98
|
+
PROMPT
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def source_available?
|
|
102
|
+
path = controller_file_path
|
|
103
|
+
path && File.exist?(path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def controller_source
|
|
109
|
+
path = controller_file_path
|
|
110
|
+
return "# Source not available" if path && File.exist?(path) == false
|
|
111
|
+
return "# Source not available" if path.nil?
|
|
112
|
+
|
|
113
|
+
File.read(path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def controller_file_path
|
|
117
|
+
return nil if defined?(Rails) == false || Rails.respond_to?(:root) == false || Rails.root.nil?
|
|
118
|
+
|
|
119
|
+
relative = @gap[:controller].underscore
|
|
120
|
+
Rails.root.join("app", "controllers", "#{relative}.rb").to_s
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Docit
|
|
7
|
+
module Ai
|
|
8
|
+
class ScaffoldGenerator
|
|
9
|
+
def initialize(output: $stdout)
|
|
10
|
+
@output = output
|
|
11
|
+
@files_written = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
check_base_setup!
|
|
16
|
+
gaps = detect_gaps
|
|
17
|
+
|
|
18
|
+
if gaps.empty?
|
|
19
|
+
@output.puts "No undocumented endpoints found."
|
|
20
|
+
return @files_written
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@output.puts "Found #{gaps.length} endpoint#{"s" if gaps.length > 1} to scaffold:"
|
|
24
|
+
gaps.each { |g| @output.puts " #{g[:method].upcase} #{g[:path]} (#{g[:controller]}##{g[:action]})" }
|
|
25
|
+
@output.puts ""
|
|
26
|
+
|
|
27
|
+
grouped = gaps.group_by { |g| g[:controller] }
|
|
28
|
+
|
|
29
|
+
grouped.each do |controller, controller_gaps|
|
|
30
|
+
writer = DocWriter.new(controller_name: controller)
|
|
31
|
+
|
|
32
|
+
if writer.file_exists?
|
|
33
|
+
existing_actions = existing_doc_actions(writer.doc_file_path)
|
|
34
|
+
controller_gaps = controller_gaps.reject { |g| existing_actions.include?(g[:action]) }
|
|
35
|
+
next if controller_gaps.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
blocks = controller_gaps.map { |gap| build_placeholder(gap, controller) }
|
|
39
|
+
writer.write(blocks)
|
|
40
|
+
@files_written << writer.doc_file_path
|
|
41
|
+
|
|
42
|
+
relative = writer.doc_file_path.sub("#{Rails.root}/", "")
|
|
43
|
+
@output.puts " Created: #{relative}"
|
|
44
|
+
|
|
45
|
+
if writer.inject_use_docs
|
|
46
|
+
controller_relative = File.join("app", "controllers", "#{controller.underscore}.rb")
|
|
47
|
+
@output.puts " Added use_docs to #{controller_relative}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
inject_tags(grouped)
|
|
52
|
+
|
|
53
|
+
@output.puts ""
|
|
54
|
+
@output.puts "Scaffolded #{gaps.length} endpoint#{"s" if gaps.length > 1} in #{@files_written.length} file#{"s" if @files_written.length > 1}."
|
|
55
|
+
@output.puts "Fill in the TODO placeholders in your doc files."
|
|
56
|
+
@files_written
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def detect_gaps
|
|
62
|
+
RouteInspector.eager_load_controllers!
|
|
63
|
+
|
|
64
|
+
detector = GapDetector.new
|
|
65
|
+
detector.detect
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_base_setup!
|
|
69
|
+
unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
70
|
+
raise Docit::Error, "Docit requires a Rails application. Run this command from your app root."
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
initializer = Rails.root.join("config", "initializers", "docit.rb")
|
|
74
|
+
return unless File.exist?(initializer) == false
|
|
75
|
+
|
|
76
|
+
raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_placeholder(gap, controller)
|
|
80
|
+
tag = derive_tag(controller)
|
|
81
|
+
method = gap[:method].upcase
|
|
82
|
+
path = gap[:path]
|
|
83
|
+
|
|
84
|
+
lines = []
|
|
85
|
+
lines << "doc :#{gap[:action]} do"
|
|
86
|
+
lines << " summary \"TODO: #{method} #{path}\""
|
|
87
|
+
lines << " tags \"#{tag}\""
|
|
88
|
+
|
|
89
|
+
if gap[:path].include?("{")
|
|
90
|
+
path_params = gap[:path].scan(/\{(\w+)\}/).flatten
|
|
91
|
+
path_params.each do |param|
|
|
92
|
+
lines << " parameter :#{param}, location: :path, type: :string, required: true"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if %w[POST PUT PATCH].include?(method)
|
|
97
|
+
lines << ""
|
|
98
|
+
lines << " request_body required: true do"
|
|
99
|
+
lines << " # TODO: Add request properties"
|
|
100
|
+
lines << " # property :name, type: :string, required: true"
|
|
101
|
+
lines << " end"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
lines << ""
|
|
105
|
+
lines << " response #{default_status(method)}, \"TODO: Add description\" do"
|
|
106
|
+
lines << " # TODO: Add response properties"
|
|
107
|
+
lines << " # property :id, type: :integer"
|
|
108
|
+
lines << " end"
|
|
109
|
+
lines << "end"
|
|
110
|
+
|
|
111
|
+
lines.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def derive_tag(controller)
|
|
115
|
+
controller.delete_suffix("Controller").split("::").last
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def default_status(method)
|
|
119
|
+
case method
|
|
120
|
+
when "POST" then 201
|
|
121
|
+
when "DELETE" then 204
|
|
122
|
+
else 200
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def existing_doc_actions(path)
|
|
127
|
+
content = File.read(path)
|
|
128
|
+
content.scan(/doc\s+:(\w+)/).flatten
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def inject_tags(grouped)
|
|
132
|
+
tags = grouped.keys.map { |c| derive_tag(c) }.uniq
|
|
133
|
+
return if tags.empty?
|
|
134
|
+
|
|
135
|
+
injected = TagInjector.new(tags: tags).inject
|
|
136
|
+
injected.each { |tag| @output.puts " Added tag \"#{tag}\" to config/initializers/docit.rb" }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class TagInjector
|
|
6
|
+
def initialize(tags:)
|
|
7
|
+
@tags = tags.uniq
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def inject
|
|
11
|
+
return [] if initializer_path && File.exist?(initializer_path) == false
|
|
12
|
+
|
|
13
|
+
content = File.read(initializer_path)
|
|
14
|
+
existing_tags = content.scan(/config\.tag\s+["']([^"']+)["']/).flatten
|
|
15
|
+
|
|
16
|
+
new_tags = @tags - existing_tags
|
|
17
|
+
return [] if new_tags.empty?
|
|
18
|
+
|
|
19
|
+
lines = new_tags.map do |tag|
|
|
20
|
+
desc = "#{tag} management endpoints"
|
|
21
|
+
" config.tag \"#{tag}\", description: \"#{desc}\""
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
insertion_point = find_insertion_point(content)
|
|
25
|
+
return [] if insertion_point.nil?
|
|
26
|
+
|
|
27
|
+
content = content.insert(insertion_point, "\n#{lines.join("\n")}")
|
|
28
|
+
File.write(initializer_path, content)
|
|
29
|
+
|
|
30
|
+
new_tags
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def initializer_path
|
|
36
|
+
return nil if defined?(Rails) == false
|
|
37
|
+
|
|
38
|
+
Rails.root.join("config", "initializers", "docit.rb").to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_insertion_point(content)
|
|
42
|
+
last_tag = content.rindex(/config\.tag\s+/)
|
|
43
|
+
if last_tag
|
|
44
|
+
content.index("\n", last_tag)
|
|
45
|
+
else
|
|
46
|
+
last_config = content.rindex(/config\.\w+/)
|
|
47
|
+
content.index("\n", last_config) if last_config
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/docit/ai.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ai/configuration"
|
|
4
|
+
require_relative "ai/client"
|
|
5
|
+
require_relative "ai/openai_client"
|
|
6
|
+
require_relative "ai/anthropic_client"
|
|
7
|
+
require_relative "ai/groq_client"
|
|
8
|
+
require_relative "ai/gap_detector"
|
|
9
|
+
require_relative "ai/prompt_builder"
|
|
10
|
+
require_relative "ai/doc_writer"
|
|
11
|
+
require_relative "ai/tag_injector"
|
|
12
|
+
require_relative "ai/autodoc_runner"
|
|
13
|
+
require_relative "ai/scaffold_generator"
|
data/lib/docit/version.rb
CHANGED