woods 1.0.0 → 1.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.
@@ -61,6 +61,7 @@ module Woods
61
61
  render_key: :dependents)
62
62
  define_structure_tool(server, reader, respond, renderer)
63
63
  define_graph_analysis_tool(server, reader, respond, renderer)
64
+ define_domain_clusters_tool(server, reader, respond, renderer)
64
65
  define_pagerank_tool(server, reader, respond, renderer)
65
66
  define_framework_tool(server, reader, respond, renderer)
66
67
  define_recent_changes_tool(server, reader, respond, renderer)
@@ -306,6 +307,39 @@ module Woods
306
307
  end
307
308
  end
308
309
 
310
+ def define_domain_clusters_tool(server, reader, respond, renderer)
311
+ coerce = method(:coerce_array)
312
+ coerce_int = method(:coerce_integer)
313
+ server.define_tool(
314
+ name: 'domain_clusters',
315
+ description: 'Group code units into semantic domains by namespace and graph connectivity. ' \
316
+ 'Returns clusters with hub nodes, entry points, boundary edges, and type breakdowns. ' \
317
+ 'Useful for understanding architectural domains and blast radius.',
318
+ input_schema: {
319
+ properties: {
320
+ min_size: {
321
+ type: 'integer',
322
+ description: 'Minimum units per cluster before merging into neighbors (default: 3)'
323
+ },
324
+ types: {
325
+ type: 'array', items: { type: 'string' },
326
+ description: 'Filter to these unit types (default: all). Example: ["model", "service", "job"]'
327
+ }
328
+ }
329
+ }
330
+ ) do |server_context:, min_size: nil, types: nil|
331
+ min_size = coerce_int.call(min_size) || 3
332
+ types = coerce.call(types)
333
+
334
+ graph = reader.dependency_graph
335
+ analyzer = Woods::GraphAnalyzer.new(graph)
336
+
337
+ clusters = analyzer.domain_clusters(min_size: min_size, types: types)
338
+
339
+ respond.call(renderer.render(:domain_clusters, { clusters: clusters, total: clusters.size }))
340
+ end
341
+ end
342
+
309
343
  def define_pagerank_tool(server, reader, respond, renderer)
310
344
  coerce = method(:coerce_array)
311
345
  coerce_int = method(:coerce_integer)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require_relative 'rate_limiter'
7
+
8
+ module Woods
9
+ module Unblocked
10
+ # REST client for the Unblocked API v1.
11
+ #
12
+ # Handles document and collection CRUD with rate limiting, retries,
13
+ # and error handling. Uses Net::HTTP for zero external dependencies.
14
+ #
15
+ # @example
16
+ # client = Client.new(api_token: "ubk_...")
17
+ # client.put_document(
18
+ # collection_id: "uuid",
19
+ # title: "Order (model)",
20
+ # body: "# Order\n...",
21
+ # uri: "https://github.com/org/repo/blob/main/app/models/order.rb"
22
+ # )
23
+ #
24
+ class Client
25
+ BASE_URL = 'https://getunblocked.com/api/v1'
26
+ MAX_RETRIES = 3
27
+ DEFAULT_TIMEOUT = 30
28
+
29
+ # @param api_token [String] Unblocked API token (Personal or Team)
30
+ # @param rate_limiter [RateLimiter] Rate limiter instance
31
+ # @raise [ArgumentError] if api_token is nil or empty
32
+ def initialize(api_token:, rate_limiter: RateLimiter.new)
33
+ raise ArgumentError, 'api_token is required' if api_token.nil? || api_token.to_s.strip.empty?
34
+
35
+ @api_token = api_token
36
+ @rate_limiter = rate_limiter
37
+ end
38
+
39
+ # Create or update a document (upsert by URI).
40
+ #
41
+ # Documents are unique by `uri` across the organization. If a document
42
+ # with the given URI exists, it is updated; otherwise it is created.
43
+ # Documents become available for queries within ~1 minute.
44
+ #
45
+ # @param collection_id [String] Target collection UUID
46
+ # @param title [String] Document title (plain text)
47
+ # @param body [String] Document body (Markdown preferred)
48
+ # @param uri [String] Source URL (used as unique identifier and citation link)
49
+ # @return [Hash] { "id" => "document-uuid" }
50
+ def put_document(collection_id:, title:, body:, uri:)
51
+ request(:put, 'documents', {
52
+ collectionId: collection_id,
53
+ title: title,
54
+ body: body,
55
+ uri: uri
56
+ })
57
+ end
58
+
59
+ # Create a new collection.
60
+ #
61
+ # @param name [String] Collection name (1-32 chars)
62
+ # @param description [String] Collection description (1-4096 chars)
63
+ # @param icon_url [String, nil] Optional icon URL
64
+ # @return [Hash] { "id" => "collection-uuid", "name" => "...", ... }
65
+ def create_collection(name:, description:, icon_url: nil)
66
+ body = { name: name, description: description }
67
+ body[:iconUrl] = icon_url if icon_url
68
+ request(:post, 'collections', body)
69
+ end
70
+
71
+ # List all collections.
72
+ #
73
+ # @return [Array<Hash>] Collection objects
74
+ def list_collections
75
+ result = request(:get, 'collections')
76
+ result['items'] || result['data'] || [result].flatten.compact
77
+ end
78
+
79
+ # Delete a document by ID.
80
+ #
81
+ # @param document_id [String] Document UUID
82
+ # @return [Hash] API response
83
+ def delete_document(document_id:)
84
+ request(:delete, "documents/#{document_id}")
85
+ end
86
+
87
+ private
88
+
89
+ def request(method, path, body = nil)
90
+ retries = 0
91
+
92
+ loop do
93
+ response = @rate_limiter.track { execute_http(method, path, body) }
94
+
95
+ return parse_response(response) if response.is_a?(Net::HTTPSuccess)
96
+
97
+ if response.code == '429' && retries < MAX_RETRIES
98
+ retries += 1
99
+ wait_time = (response['Retry-After'] || (retries * 2)).to_f
100
+ sleep(wait_time)
101
+ next
102
+ end
103
+
104
+ raise_api_error(response)
105
+ end
106
+ end
107
+
108
+ def execute_http(method, path, body)
109
+ attempts = 0
110
+ begin
111
+ uri = URI("#{BASE_URL}/#{path}")
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = true
114
+ http.open_timeout = DEFAULT_TIMEOUT
115
+ http.read_timeout = DEFAULT_TIMEOUT
116
+
117
+ req = build_request(method, uri, body)
118
+ http.request(req)
119
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED => e
120
+ attempts += 1
121
+ raise Woods::Error, "Network error after #{attempts} retries: #{e.message}" if attempts >= MAX_RETRIES
122
+
123
+ sleep(2**attempts)
124
+ retry
125
+ end
126
+ end
127
+
128
+ def build_request(method, uri, body)
129
+ req = case method
130
+ when :put then Net::HTTP::Put.new(uri)
131
+ when :post then Net::HTTP::Post.new(uri)
132
+ when :get then Net::HTTP::Get.new(uri)
133
+ when :delete then Net::HTTP::Delete.new(uri)
134
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
135
+ end
136
+
137
+ req['Authorization'] = "Bearer #{@api_token}"
138
+ req['Content-Type'] = 'application/json'
139
+ req.body = JSON.generate(body) if body
140
+
141
+ req
142
+ end
143
+
144
+ def parse_response(response)
145
+ return {} if response.body.nil? || response.body.strip.empty?
146
+
147
+ JSON.parse(response.body)
148
+ rescue JSON::ParserError
149
+ {}
150
+ end
151
+
152
+ def raise_api_error(response)
153
+ parsed = begin
154
+ JSON.parse(response.body)
155
+ rescue JSON::ParserError, TypeError
156
+ { 'message' => response.body&.slice(0, 200) || 'Unknown error' }
157
+ end
158
+ message = parsed['message'] || parsed['error'] || 'Unknown error'
159
+ raise Woods::Error, "Unblocked API error #{response.code}: #{message}"
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Unblocked
5
+ # Converts extracted unit JSON into condensed Markdown documents
6
+ # optimized for Unblocked's code review and Q&A context.
7
+ #
8
+ # Each unit type has a specialized formatting strategy that emphasizes
9
+ # what matters for code review: associations, blast radius, entry points,
10
+ # side effects, and structural complexity.
11
+ #
12
+ # @example
13
+ # builder = DocumentBuilder.new(repo_url: "https://github.com/bigcartel/admin")
14
+ # doc = builder.build(unit_data)
15
+ # # => { title: "Order (model)", body: "# Order (model)\n...", uri: "https://..." }
16
+ #
17
+ class DocumentBuilder
18
+ # @param repo_url [String] GitHub repo base URL for citation URIs
19
+ def initialize(repo_url:)
20
+ @repo_url = repo_url.chomp('/')
21
+ end
22
+
23
+ # Build a document hash from a unit's extracted data.
24
+ #
25
+ # @param unit_data [Hash] Parsed unit JSON (from IndexReader)
26
+ # @return [Hash] { title:, body:, uri: }
27
+ def build(unit_data)
28
+ type = unit_data['type']
29
+ identifier = unit_data['identifier']
30
+ file_path = unit_data['file_path']
31
+
32
+ {
33
+ title: "#{identifier} (#{type})",
34
+ body: build_body(unit_data),
35
+ uri: build_uri(file_path)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def build_uri(file_path)
42
+ return @repo_url unless file_path
43
+
44
+ "#{@repo_url}/blob/main/#{file_path}"
45
+ end
46
+
47
+ def build_body(unit_data)
48
+ type = unit_data['type']
49
+ case type
50
+ when 'model' then build_model_body(unit_data)
51
+ when 'controller' then build_controller_body(unit_data)
52
+ when 'service', 'job', 'mailer', 'manager', 'decorator', 'concern'
53
+ build_generic_body(unit_data)
54
+ when 'graphql', 'graphql_type', 'graphql_mutation', 'graphql_resolver', 'graphql_query'
55
+ build_graphql_body(unit_data)
56
+ else build_generic_body(unit_data)
57
+ end
58
+ end
59
+
60
+ # ── Model formatting ─────────────────────────────────────────────
61
+
62
+ def build_model_body(unit)
63
+ meta = unit['metadata'] || {}
64
+ sections = []
65
+
66
+ sections << model_header(unit, meta)
67
+ sections << model_associations(meta)
68
+ sections << model_dependents(unit)
69
+ sections << model_entry_points(unit)
70
+ sections << model_schema_highlights(meta)
71
+ sections << model_side_effects(unit)
72
+
73
+ sections.compact.join("\n\n")
74
+ end
75
+
76
+ def model_header(unit, meta)
77
+ parts = ["# #{unit['identifier']} (model)"]
78
+ file_info = ["**File:** `#{unit['file_path']}`"]
79
+ file_info << "**LOC:** #{meta['loc']}" if meta['loc']
80
+ file_info << "**Table:** #{meta['table_name']}" if meta['table_name']
81
+ column_count = meta['column_count'] || (meta['columns'] || []).size
82
+ file_info << "(#{column_count} columns)" if column_count&.positive?
83
+ parts << file_info.join(' | ')
84
+ parts.join("\n")
85
+ end
86
+
87
+ def model_associations(meta)
88
+ assocs = meta['associations'] || []
89
+ return nil if assocs.empty?
90
+
91
+ grouped = assocs.group_by { |a| a['type'] }
92
+ lines = ["## Associations (#{assocs.size})"]
93
+
94
+ %w[belongs_to has_many has_one has_and_belongs_to_many].each do |type|
95
+ items = grouped[type]
96
+ next unless items&.any?
97
+
98
+ targets = items.map do |a|
99
+ name = a['target'] || a['name']
100
+ dep = a.dig('options', 'dependent')
101
+ dep ? "#{name} (#{dep})" : name
102
+ end
103
+ lines << "**#{type}:** #{targets.join(', ')}"
104
+ end
105
+
106
+ lines.join("\n")
107
+ end
108
+
109
+ def model_dependents(unit)
110
+ deps = unit['dependents'] || []
111
+ return nil if deps.empty?
112
+
113
+ grouped = deps.group_by { |d| d['type'] }
114
+ summary_parts = grouped.map { |type, items| "#{items.size} #{type}s" }
115
+
116
+ lines = ["## Dependents (#{deps.size} units)"]
117
+ lines << summary_parts.join(', ')
118
+
119
+ # Blast radius assessment
120
+ if deps.size > 50
121
+ lines << '**High blast radius** — changes here affect many parts of the codebase'
122
+ elsif deps.size > 20
123
+ lines << '**Moderate blast radius** — changes may ripple to dependent code'
124
+ end
125
+
126
+ lines.join("\n")
127
+ end
128
+
129
+ def model_entry_points(unit)
130
+ deps = unit['dependents'] || []
131
+ controllers = deps.select { |d| d['type'] == 'controller' }
132
+ graphql = deps.select { |d| d['type']&.start_with?('graphql') }
133
+ jobs = deps.select { |d| d['type'] == 'job' }
134
+
135
+ return nil if controllers.empty? && graphql.empty?
136
+
137
+ lines = ['## Entry Points']
138
+ lines << "**Controllers:** #{controllers.map { |c| c['identifier'] }.join(', ')}" if controllers.any?
139
+ lines << "**GraphQL:** #{graphql.map { |g| g['identifier'] }.join(', ')}" if graphql.any?
140
+ lines << "**Jobs:** #{jobs.map { |j| j['identifier'] }.join(', ')}" if jobs.any?
141
+
142
+ lines.join("\n")
143
+ end
144
+
145
+ def model_schema_highlights(meta)
146
+ parts = []
147
+
148
+ enums = meta['enums']
149
+ if enums.is_a?(Hash) && enums.any?
150
+ enum_strs = enums.map { |name, values| "#{name} (#{format_enum_values(values)})" }
151
+ parts << "**Enums:** #{enum_strs.join('; ')}"
152
+ end
153
+
154
+ scopes = meta['scopes']
155
+ parts << "**Scopes:** #{scopes.map { |s| s['name'] }.join(', ')}" if scopes.is_a?(Array) && scopes.any?
156
+
157
+ concerns = meta['inlined_concerns']
158
+ parts << "**Concerns:** #{concerns.join(', ')}" if concerns.is_a?(Array) && concerns.any?
159
+
160
+ callbacks = meta['callbacks']
161
+ if callbacks.is_a?(Array) && callbacks.any?
162
+ parts << "**Callbacks (#{callbacks.size}):** #{format_callbacks(callbacks)}"
163
+ end
164
+
165
+ return nil if parts.empty?
166
+
167
+ (['## Schema Highlights'] + parts).join("\n")
168
+ end
169
+
170
+ def model_side_effects(unit)
171
+ deps = unit['dependents'] || []
172
+ jobs = deps.select { |d| d['type'] == 'job' }
173
+ mailers = deps.select { |d| d['type'] == 'mailer' }
174
+
175
+ return nil if jobs.empty? && mailers.empty?
176
+
177
+ lines = ['## Side Effects']
178
+ lines << "**Jobs:** #{jobs.map { |j| j['identifier'] }.join(', ')}" if jobs.any?
179
+ lines << "**Mailers:** #{mailers.map { |m| m['identifier'] }.join(', ')}" if mailers.any?
180
+
181
+ lines.join("\n")
182
+ end
183
+
184
+ # ── Controller formatting ────────────────────────────────────────
185
+
186
+ def build_controller_body(unit)
187
+ meta = unit['metadata'] || {}
188
+ sections = []
189
+
190
+ sections << "# #{unit['identifier']} (controller)"
191
+ sections << "**File:** `#{unit['file_path']}`"
192
+
193
+ ancestors = meta['ancestors']
194
+ sections << "**Inherits:** #{ancestors[1..3]&.join(' → ')}" if ancestors.is_a?(Array) && ancestors.size > 1
195
+
196
+ sections << controller_routes(meta)
197
+ sections << controller_dependencies(unit)
198
+ sections << controller_dependents(unit)
199
+
200
+ sections.compact.join("\n\n")
201
+ end
202
+
203
+ def controller_routes(meta)
204
+ routes = meta['routes']
205
+ return nil unless routes.is_a?(Hash) && routes.any?
206
+
207
+ lines = ['## Routes']
208
+ routes.each do |action, route_list|
209
+ next unless route_list.is_a?(Array)
210
+
211
+ route_list.each do |route|
212
+ next unless route.is_a?(Hash)
213
+
214
+ lines << "- `#{route['verb']} #{route['path']}` (#{action})"
215
+ end
216
+ end
217
+
218
+ lines.size > 1 ? lines.first(20).join("\n") : nil
219
+ end
220
+
221
+ def controller_dependencies(unit)
222
+ deps = unit['dependencies'] || []
223
+ return nil if deps.empty?
224
+
225
+ models = deps.select { |d| d['type'] == 'model' }.map { |d| d['target'] }
226
+ return nil if models.empty?
227
+
228
+ "## Dependencies\n**Models:** #{models.join(', ')}"
229
+ end
230
+
231
+ def controller_dependents(unit)
232
+ deps = unit['dependents'] || []
233
+ views = deps.select { |d| d['type'] == 'view_template' }
234
+ return nil if views.empty?
235
+
236
+ "## Views\n#{views.map { |v| "- `#{v['identifier']}`" }.first(10).join("\n")}"
237
+ end
238
+
239
+ # ── GraphQL formatting ───────────────────────────────────────────
240
+
241
+ def build_graphql_body(unit)
242
+ sections = []
243
+
244
+ sections << "# #{unit['identifier']} (#{unit['type']})"
245
+ sections << "**File:** `#{unit['file_path']}`"
246
+
247
+ deps = unit['dependencies'] || []
248
+ models = deps.select { |d| d['type'] == 'model' }.map { |d| d['target'] }
249
+ sections << "**Models:** #{models.join(', ')}" if models.any?
250
+
251
+ dependents = unit['dependents'] || []
252
+ sections << "**Referenced by:** #{dependents.size} units" if dependents.any?
253
+
254
+ sections.compact.join("\n\n")
255
+ end
256
+
257
+ # ── Generic formatting (services, jobs, mailers, etc.) ──────────
258
+
259
+ def build_generic_body(unit)
260
+ meta = unit['metadata'] || {}
261
+ sections = []
262
+
263
+ sections << "# #{unit['identifier']} (#{unit['type']})"
264
+ sections << "**File:** `#{unit['file_path']}`"
265
+ sections << "**LOC:** #{meta['loc']}" if meta['loc']
266
+
267
+ deps = unit['dependencies'] || []
268
+ if deps.any?
269
+ by_type = deps.group_by { |d| d['type'] }
270
+ dep_parts = by_type.map { |type, items| "#{type}: #{items.map { |d| d['target'] }.join(', ')}" }
271
+ sections << "## Dependencies\n#{dep_parts.join("\n")}"
272
+ end
273
+
274
+ dependents = unit['dependents'] || []
275
+ if dependents.any?
276
+ grouped = dependents.group_by { |d| d['type'] }
277
+ summary = grouped.map { |type, items| "#{items.size} #{type}s" }
278
+ sections << "## Dependents (#{dependents.size})\n#{summary.join(', ')}"
279
+ end
280
+
281
+ sections.compact.join("\n\n")
282
+ end
283
+
284
+ # ── Helpers ──────────────────────────────────────────────────────
285
+
286
+ def format_enum_values(values)
287
+ case values
288
+ when Hash then values.keys.first(5).join(', ')
289
+ when Array then values.first(5).join(', ')
290
+ else values.to_s
291
+ end
292
+ end
293
+
294
+ def format_callbacks(callbacks)
295
+ callbacks.first(5).map do |cb|
296
+ "#{cb['type']}: #{cb['filter']}"
297
+ end.join(', ')
298
+ end
299
+ end
300
+ end
301
+ end