docit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/CONTRIBUTING.md +1 -1
- data/README.md +61 -6
- data/app/controllers/docit/ui_controller.rb +16 -4
- data/lib/docit/ai/anthropic_client.rb +57 -0
- data/lib/docit/ai/autodoc_runner.rb +168 -0
- data/lib/docit/ai/client.rb +23 -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 +55 -0
- data/lib/docit/ai/openai_client.rb +55 -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 +2 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb189284c9d9b8ad90f9d39fccaea0265223c3be58009b95b51b0dfbc12a3d78
|
|
4
|
+
data.tar.gz: 5bd6b3277e7686470f1c24bc2824b2cd9ec97ebc785d9e19fefe9fafe7a2be1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f921f704eb18446ecc475377d7fa8f0604c34349182139fa79ac26dd76260e04028b0b4443ca300e877298c96423ac5d8cc04b5a0887c6fa379a2752de8d6c21
|
|
7
|
+
data.tar.gz: 1b6fbfc6aae77f31e74700243535e12be9c6caae790e87614903e7538e769746d774e4d31899fc3617fee01896f0f2a7e2a489fa0026d35e08cc5afd602e7c13
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-04-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Unified install generator: `rails g docit:install` now offers AI auto-docs, manual scaffolding, or skip
|
|
7
|
+
- Manual scaffold mode: creates doc file placeholders with TODO markers, injects `use_docs` into controllers
|
|
8
|
+
- `AutodocRunner` class: reusable orchestrator for AI doc generation (used by both install generator and rake task)
|
|
9
|
+
- `ScaffoldGenerator` class: creates placeholder doc files for manual documentation
|
|
10
|
+
- Groq provider support (free tier) alongside OpenAI and Anthropic
|
|
11
|
+
- AI configuration generator: `rails g docit:ai_setup`
|
|
12
|
+
- Tasks: `rails docit:autodoc` with preview support via `DRY_RUN=1`
|
|
13
|
+
- Auto-injection of `use_docs` into controllers after doc generation
|
|
14
|
+
- Auto-injection of `config.tag` entries into initializer
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- AI setup flows now retry invalid menu input instead of exiting immediately
|
|
18
|
+
- AI setup flows now hide API key input in interactive terminals
|
|
19
|
+
- `rails docit:autodoc` now supports `DRY_RUN=1` for preview mode
|
|
20
|
+
- AI autodoc now warns before sending controller source to external providers
|
|
21
|
+
- Swagger UI assets are pinned to a specific `swagger-ui-dist` version
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- `.docit_ai.yml` is now written with restricted file permissions
|
|
25
|
+
- AI provider clients now return a clean Docit error when an upstream service responds with invalid JSON
|
|
26
|
+
- Swagger UI now escapes the generated spec URL before embedding it in JavaScript
|
|
27
|
+
- Manual scaffolds now use `200` for `PUT` and `PATCH` responses by default
|
|
28
|
+
|
|
3
29
|
## [0.1.0] - 2026-04-08
|
|
4
30
|
|
|
5
31
|
- Initial release
|
data/CONTRIBUTING.md
CHANGED
|
@@ -33,7 +33,7 @@ Docit aims for high test coverage and an extensive test suite. Tests enable us t
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
# Fork the repo on GitHub, then:
|
|
36
|
-
git clone https://github.com/
|
|
36
|
+
git clone https://github.com/S13G/docit.git
|
|
37
37
|
cd docit
|
|
38
38
|
|
|
39
39
|
# Install dependencies
|
data/README.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# Docit
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/docit)
|
|
4
|
-
[](https://github.com/S13G/docket/actions/workflows/ci.yml)
|
|
5
3
|
[](https://www.ruby-lang.org)
|
|
6
4
|
[](https://opensource.org/licenses/MIT)
|
|
7
5
|
|
|
8
|
-
Decorator-style API documentation for Ruby on Rails. Write OpenAPI 3.0 docs
|
|
6
|
+
Decorator-style API documentation for Ruby on Rails. Write OpenAPI 3.0.3 docs with clean controller DSL macros, separate doc modules, or AI-assisted scaffolding for undocumented endpoints.
|
|
9
7
|
|
|
10
8
|
Inspired by [drf-spectacular](https://github.com/tfranzel/drf-spectacular) for Django REST Framework.
|
|
11
9
|
|
|
@@ -24,13 +22,19 @@ bundle install
|
|
|
24
22
|
rails generate docit:install
|
|
25
23
|
```
|
|
26
24
|
|
|
27
|
-
The generator does
|
|
25
|
+
The install generator does everything in one step:
|
|
28
26
|
|
|
29
27
|
1. Creates `config/initializers/docit.rb` with default settings
|
|
30
28
|
2. Mounts the Swagger UI engine at `/api-docs` in your routes
|
|
29
|
+
3. Asks how you'd like to set up your docs:
|
|
30
|
+
- **AI automatic docs** — configure an AI provider, then Docit scans your routes and generates complete documentation for every endpoint
|
|
31
|
+
- **Manual docs** — Docit scans your routes and creates scaffolded doc files with TODO placeholders, injects `use_docs` into controllers, and lets you fill in the details
|
|
32
|
+
- **Skip** — just install the base config and set up docs later
|
|
31
33
|
|
|
32
34
|
Visit `/api-docs` to see your interactive API documentation.
|
|
33
35
|
|
|
36
|
+
If you choose AI setup, Docit stores your provider config in `.docit_ai.yml` with restricted file permissions and adds that file to `.gitignore` when possible.
|
|
37
|
+
|
|
34
38
|
## Configuration
|
|
35
39
|
|
|
36
40
|
Edit `config/initializers/docit.rb`:
|
|
@@ -374,6 +378,57 @@ end
|
|
|
374
378
|
|
|
375
379
|
`type: :file` maps to `{ type: "string", format: "binary" }` in the OpenAPI spec.
|
|
376
380
|
|
|
381
|
+
## AI Automatic Documentation
|
|
382
|
+
|
|
383
|
+
Docit can generate complete API documentation using AI. This works with OpenAI, Anthropic, or Groq (free tier available).
|
|
384
|
+
|
|
385
|
+
### Quick start (included in install)
|
|
386
|
+
|
|
387
|
+
When you run `rails generate docit:install` and choose option 1 (AI automatic docs), everything is set up automatically — provider configuration, doc generation, controller wiring, and tag injection.
|
|
388
|
+
|
|
389
|
+
Before the first AI request, Docit warns that your controller source code will be sent to the selected provider and asks for confirmation in interactive terminals.
|
|
390
|
+
|
|
391
|
+
### Standalone commands
|
|
392
|
+
|
|
393
|
+
You can also set up AI docs separately:
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
# Configure your AI provider (one-time setup)
|
|
397
|
+
rails generate docit:ai_setup
|
|
398
|
+
|
|
399
|
+
# Generate docs for all undocumented endpoints
|
|
400
|
+
rails docit:autodoc
|
|
401
|
+
|
|
402
|
+
# Generate docs for a specific controller
|
|
403
|
+
rails docit:autodoc[Api::V1::UsersController]
|
|
404
|
+
|
|
405
|
+
# Preview what would be generated without writing files
|
|
406
|
+
DRY_RUN=1 rails docit:autodoc
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Supported providers
|
|
410
|
+
|
|
411
|
+
| Provider | Notes |
|
|
412
|
+
|------------|-------|
|
|
413
|
+
| OpenAI | Requires API key from platform.openai.com |
|
|
414
|
+
| Anthropic | Requires API key from console.anthropic.com |
|
|
415
|
+
| Groq | Free tier at console.groq.com |
|
|
416
|
+
|
|
417
|
+
AI configuration is stored in `.docit_ai.yml`.
|
|
418
|
+
If your app does not have a `.gitignore`, add `.docit_ai.yml` manually.
|
|
419
|
+
|
|
420
|
+
### What the AI generates
|
|
421
|
+
|
|
422
|
+
For each undocumented endpoint, Docit:
|
|
423
|
+
|
|
424
|
+
1. Reads the controller source code
|
|
425
|
+
2. Inspects the route (HTTP method, path, parameters)
|
|
426
|
+
3. Writes the generated doc block to `app/docs/`
|
|
427
|
+
4. Injects `use_docs` into the controller
|
|
428
|
+
5. Adds tag descriptions to the initializer
|
|
429
|
+
|
|
430
|
+
Do not use AI autodoc on controllers that contain secrets, proprietary business rules, or internal comments you do not want sent to an external provider.
|
|
431
|
+
|
|
377
432
|
## How it works
|
|
378
433
|
|
|
379
434
|
1. `swagger_doc` registers an **Operation** for each controller action in a global **Registry**
|
|
@@ -401,7 +456,7 @@ GET /api-docs/spec
|
|
|
401
456
|
## Development
|
|
402
457
|
|
|
403
458
|
```bash
|
|
404
|
-
git clone https://github.com/S13G/
|
|
459
|
+
git clone https://github.com/S13G/docit.git
|
|
405
460
|
cd docit
|
|
406
461
|
bundle install
|
|
407
462
|
bundle exec rspec # run all tests
|
|
@@ -409,7 +464,7 @@ bundle exec rspec # run all tests
|
|
|
409
464
|
|
|
410
465
|
## Contributing
|
|
411
466
|
|
|
412
|
-
Bug reports and pull requests are welcome on [GitHub](https://github.com/S13G/
|
|
467
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/S13G/docit).
|
|
413
468
|
|
|
414
469
|
## License
|
|
415
470
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Docit
|
|
4
6
|
class UiController < ActionController::Base
|
|
7
|
+
SWAGGER_UI_VERSION = "5.32.2"
|
|
8
|
+
|
|
5
9
|
def index
|
|
6
10
|
render html: swagger_ui_html.html_safe, layout: false
|
|
7
11
|
end
|
|
@@ -15,6 +19,7 @@ module Docit
|
|
|
15
19
|
|
|
16
20
|
def swagger_ui_html
|
|
17
21
|
spec_url = "#{request.base_url}#{Docit::Engine.routes.url_helpers.spec_path}"
|
|
22
|
+
spec_url_json = JSON.generate(spec_url)
|
|
18
23
|
escaped_title = ERB::Util.html_escape(Docit.configuration.title)
|
|
19
24
|
|
|
20
25
|
<<~HTML
|
|
@@ -24,7 +29,7 @@ module Docit
|
|
|
24
29
|
<meta charset="UTF-8">
|
|
25
30
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
26
31
|
<title>#{escaped_title}</title>
|
|
27
|
-
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist
|
|
32
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@#{SWAGGER_UI_VERSION}/swagger-ui.css" />
|
|
28
33
|
<style>
|
|
29
34
|
html { box-sizing: border-box; overflow-y: scroll; }
|
|
30
35
|
*, *:before, *:after { box-sizing: inherit; }
|
|
@@ -33,10 +38,10 @@ module Docit
|
|
|
33
38
|
</head>
|
|
34
39
|
<body>
|
|
35
40
|
<div id="swagger-ui"></div>
|
|
36
|
-
<script src="https://unpkg.com/swagger-ui-dist
|
|
41
|
+
<script src="https://unpkg.com/swagger-ui-dist@#{SWAGGER_UI_VERSION}/swagger-ui-bundle.js"></script>
|
|
37
42
|
<script>
|
|
38
43
|
SwaggerUIBundle({
|
|
39
|
-
url:
|
|
44
|
+
url: #{spec_url_json},
|
|
40
45
|
dom_id: '#swagger-ui',
|
|
41
46
|
presets: [
|
|
42
47
|
SwaggerUIBundle.presets.apis,
|
|
@@ -45,7 +50,14 @@ module Docit
|
|
|
45
50
|
layout: "BaseLayout",
|
|
46
51
|
deepLinking: true,
|
|
47
52
|
showExtensions: true,
|
|
48
|
-
showCommonExtensions: true
|
|
53
|
+
showCommonExtensions: true,
|
|
54
|
+
requestInterceptor: function(req) {
|
|
55
|
+
var url = new URL(req.url);
|
|
56
|
+
url.protocol = window.location.protocol;
|
|
57
|
+
url.host = window.location.host;
|
|
58
|
+
req.url = url.toString();
|
|
59
|
+
return req;
|
|
60
|
+
}
|
|
49
61
|
})
|
|
50
62
|
</script>
|
|
51
63
|
</body>
|
|
@@ -0,0 +1,57 @@
|
|
|
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 AnthropicClient
|
|
10
|
+
API_URL = "https://api.anthropic.com/v1/messages"
|
|
11
|
+
API_VERSION = "2023-06-01"
|
|
12
|
+
|
|
13
|
+
def initialize(api_key:, model:)
|
|
14
|
+
@api_key = api_key
|
|
15
|
+
@model = model
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate(prompt)
|
|
19
|
+
uri = URI(API_URL)
|
|
20
|
+
request = Net::HTTP::Post.new(uri)
|
|
21
|
+
request["x-api-key"] = @api_key
|
|
22
|
+
request["anthropic-version"] = API_VERSION
|
|
23
|
+
request["Content-Type"] = "application/json"
|
|
24
|
+
request.body = {
|
|
25
|
+
model: @model,
|
|
26
|
+
max_tokens: 4096,
|
|
27
|
+
messages: [{ role: "user", content: prompt }]
|
|
28
|
+
}.to_json
|
|
29
|
+
|
|
30
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
|
|
31
|
+
http.request(request)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
handle_response(response)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def handle_response(response)
|
|
40
|
+
body = parse_json(response, "Anthropic")
|
|
41
|
+
|
|
42
|
+
if response.is_a?(Net::HTTPSuccess) == false
|
|
43
|
+
message = body.dig("error", "message") || "Unknown API error"
|
|
44
|
+
raise Error, "Anthropic API error (#{response.code}): #{message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
body.dig("content", 0, "text") || raise(Error, "Empty response from Anthropic")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_json(response, provider_name)
|
|
51
|
+
JSON.parse(response.body)
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class AutodocRunner
|
|
6
|
+
attr_reader :results
|
|
7
|
+
|
|
8
|
+
def initialize(controller_filter: nil, dry_run: false, input: $stdin, output: $stdout)
|
|
9
|
+
@controller_filter = controller_filter
|
|
10
|
+
@dry_run = dry_run
|
|
11
|
+
@input = input
|
|
12
|
+
@output = output
|
|
13
|
+
@results = { gaps: [], generated: 0, files: [], tags: [] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
check_base_setup!
|
|
18
|
+
config = load_config
|
|
19
|
+
@output.puts "Using #{config.provider}"
|
|
20
|
+
@output.puts ""
|
|
21
|
+
|
|
22
|
+
gaps = detect_gaps
|
|
23
|
+
@results[:gaps] = gaps
|
|
24
|
+
|
|
25
|
+
if gaps.empty?
|
|
26
|
+
@output.puts "All endpoints are documented!"
|
|
27
|
+
return @results
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
print_gaps(gaps)
|
|
31
|
+
|
|
32
|
+
if @dry_run
|
|
33
|
+
@output.puts "[dry-run] No files written."
|
|
34
|
+
return @results
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
confirm_source_upload!(config)
|
|
38
|
+
generated = generate_docs(gaps, config)
|
|
39
|
+
write_docs(generated)
|
|
40
|
+
inject_tags(generated)
|
|
41
|
+
|
|
42
|
+
@output.puts "Review generated files and edit as needed."
|
|
43
|
+
@results
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def load_config
|
|
49
|
+
Docit::Ai::Configuration.load
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_base_setup!
|
|
53
|
+
if !(defined?(Rails) && Rails.respond_to?(:root) && Rails.root)
|
|
54
|
+
raise Docit::Error, "Docit requires a Rails application. Run this command from your app root."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
initializer = Rails.root.join("config", "initializers", "docit.rb")
|
|
58
|
+
if File.exist?(initializer) == false
|
|
59
|
+
raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
routes_file = Rails.root.join("config", "routes.rb")
|
|
63
|
+
if File.exist?(routes_file) && !File.read(routes_file).include?("Docit::Engine")
|
|
64
|
+
@output.puts "Warning: Docit engine is not mounted in config/routes.rb"
|
|
65
|
+
@output.puts " Run: rails generate docit:install (or add: mount Docit::Engine => \"/api-docs\")"
|
|
66
|
+
@output.puts ""
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def detect_gaps
|
|
71
|
+
detector = GapDetector.new(controller_filter: @controller_filter)
|
|
72
|
+
detector.detect
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_gaps(gaps)
|
|
76
|
+
@output.puts "Found #{gaps.length} undocumented endpoint#{"s" if gaps.length > 1}:"
|
|
77
|
+
gaps.each { |g| @output.puts " #{g[:method].upcase} #{g[:path]} (#{g[:controller]}##{g[:action]})" }
|
|
78
|
+
@output.puts ""
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def confirm_source_upload!(config)
|
|
82
|
+
@output.puts "Docit will send controller source code to #{config.provider.capitalize} to generate documentation."
|
|
83
|
+
@output.puts "Review the endpoints first if they contain secrets or proprietary logic."
|
|
84
|
+
|
|
85
|
+
return if !(@input.respond_to?(:tty?) && @input.tty?)
|
|
86
|
+
|
|
87
|
+
loop do
|
|
88
|
+
@output.print "Continue? (y/n): "
|
|
89
|
+
choice = @input.gets.to_s.strip.downcase
|
|
90
|
+
|
|
91
|
+
case choice
|
|
92
|
+
when "y", "yes"
|
|
93
|
+
@output.puts ""
|
|
94
|
+
return
|
|
95
|
+
when "n", "no"
|
|
96
|
+
raise Docit::Error, "Autodoc cancelled."
|
|
97
|
+
else
|
|
98
|
+
@output.puts "Please enter y or n."
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def generate_docs(gaps, config)
|
|
104
|
+
client = Client.for(config)
|
|
105
|
+
generated = Hash.new { |h, k| h[k] = [] }
|
|
106
|
+
|
|
107
|
+
@output.puts "Generating documentation............."
|
|
108
|
+
|
|
109
|
+
gaps.each_with_index do |gap, index|
|
|
110
|
+
@output.print "[#{index + 1}/#{gaps.length}] Generating #{gap[:controller]}##{gap[:action]}..."
|
|
111
|
+
|
|
112
|
+
builder = PromptBuilder.new(gap: gap)
|
|
113
|
+
if builder.source_available? == false
|
|
114
|
+
@output.puts " skipped (controller source file not found)"
|
|
115
|
+
next
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
prompt = builder.build
|
|
119
|
+
doc_block = client.generate(prompt).strip
|
|
120
|
+
doc_block = strip_markdown_fences(doc_block)
|
|
121
|
+
|
|
122
|
+
generated[gap[:controller]] << doc_block
|
|
123
|
+
@results[:generated] += 1
|
|
124
|
+
@output.puts " done"
|
|
125
|
+
rescue Docit::Ai::Error => e
|
|
126
|
+
@output.puts " failed (#{e.message})"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
generated
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_docs(generated)
|
|
133
|
+
generated.each do |controller, blocks|
|
|
134
|
+
next if blocks.empty?
|
|
135
|
+
|
|
136
|
+
writer = DocWriter.new(controller_name: controller)
|
|
137
|
+
writer.write(blocks)
|
|
138
|
+
@results[:files] << writer.doc_file_path
|
|
139
|
+
|
|
140
|
+
relative = writer.doc_file_path.sub("#{Rails.root}/", "")
|
|
141
|
+
@output.puts " Wrote: #{relative}"
|
|
142
|
+
|
|
143
|
+
if writer.inject_use_docs
|
|
144
|
+
controller_relative = File.join("app", "controllers", "#{controller.underscore}.rb")
|
|
145
|
+
@output.puts " Added use_docs to #{controller_relative}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
@output.puts ""
|
|
150
|
+
@output.puts "Generated docs for #{@results[:generated]} endpoint#{"s" if @results[:generated] > 1} in #{@results[:files].length} file#{"s" if @results[:files].length > 1}."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def inject_tags(generated)
|
|
154
|
+
all_tags = generated.values.flatten.join("\n").scan(/tags\s+["']([^"']+)["']/).flatten
|
|
155
|
+
return unless all_tags.any?
|
|
156
|
+
|
|
157
|
+
injected = TagInjector.new(tags: all_tags).inject
|
|
158
|
+
injected.each { |tag| @output.puts " Added tag \"#{tag}\" to config/initializers/docit.rb" }
|
|
159
|
+
@results[:tags] = injected
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def strip_markdown_fences(text)
|
|
163
|
+
text = text.sub(/\A```\w*\n/, "")
|
|
164
|
+
text.sub(/\n```\z/, "")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module Ai
|
|
5
|
+
class Error < Docit::Error; end
|
|
6
|
+
|
|
7
|
+
# Factory for AI provider clients.
|
|
8
|
+
module Client
|
|
9
|
+
def self.for(config)
|
|
10
|
+
case config.provider
|
|
11
|
+
when "openai"
|
|
12
|
+
OpenaiClient.new(api_key: config.api_key, model: config.model)
|
|
13
|
+
when "anthropic"
|
|
14
|
+
AnthropicClient.new(api_key: config.api_key, model: config.model)
|
|
15
|
+
when "groq"
|
|
16
|
+
GroqClient.new(api_key: config.api_key, model: config.model)
|
|
17
|
+
else
|
|
18
|
+
raise Error, "Unsupported provider: #{config.provider}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Docit
|
|
6
|
+
module Ai
|
|
7
|
+
class Configuration
|
|
8
|
+
CONFIG_FILE = ".docit_ai.yml"
|
|
9
|
+
|
|
10
|
+
PROVIDERS = %w[openai anthropic groq].freeze
|
|
11
|
+
|
|
12
|
+
DEFAULT_MODELS = {
|
|
13
|
+
"openai" => "gpt-4o-mini",
|
|
14
|
+
"anthropic" => "claude-haiku-4-5-20251001",
|
|
15
|
+
"groq" => "llama-3.3-70b-versatile"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :provider, :model, :api_key
|
|
19
|
+
|
|
20
|
+
def initialize(provider:, model:, api_key:)
|
|
21
|
+
@provider = provider.to_s
|
|
22
|
+
@model = model.to_s
|
|
23
|
+
@api_key = api_key.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def valid?
|
|
27
|
+
PROVIDERS.include?(provider) && !model.empty? && !api_key.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def config_path
|
|
32
|
+
Rails.root.join(CONFIG_FILE)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configured?
|
|
36
|
+
File.exist?(config_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def load
|
|
40
|
+
raise Error, "AI not configured. Run: rails generate docit:ai_setup" if configured? == false
|
|
41
|
+
|
|
42
|
+
data = YAML.safe_load_file(config_path, permitted_classes: [Symbol])
|
|
43
|
+
new(
|
|
44
|
+
provider: data["provider"],
|
|
45
|
+
model: data["model"],
|
|
46
|
+
api_key: data["api_key"]
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def save(provider:, model:, api_key:)
|
|
51
|
+
config = new(provider: provider, model: model, api_key: api_key)
|
|
52
|
+
raise Error, "Invalid configuration" if config.valid? == false
|
|
53
|
+
|
|
54
|
+
File.write(config_path, {
|
|
55
|
+
"provider" => config.provider,
|
|
56
|
+
"model" => config.model,
|
|
57
|
+
"api_key" => config.api_key
|
|
58
|
+
}.to_yaml)
|
|
59
|
+
File.chmod(0o600, config_path)
|
|
60
|
+
|
|
61
|
+
config
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -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
|