Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
744 changes: 744 additions & 0 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

423 changes: 423 additions & 0 deletions .claude/active/CLAUDE.feature-smartcache.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
rake worker:run:test &
bundle exec rake spec
bundle exec rake spec:all
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.claude/
CLAUDE.md
.claude/_*
.claude/settings.local.json
CLAUDE.local.md
_snippets/
.bundle/
Expand Down
24 changes: 18 additions & 6 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@ task :default do
puts `rake -T`
end

desc 'Run unit and integration tests'
Rake::TestTask.new(:spec_only) do |t|
t.pattern = 'spec/tests/**/*_spec.rb'
t.warning = false
namespace :spec do
# Internal tasks (no desc = hidden from rake -T)
Rake::TestTask.new(:unit_integration) do |t|
t.pattern = 'spec/tests/{unit,integration}/**/*_spec.rb'
t.warning = false
t.description = nil # Hide from rake -T
end

Rake::TestTask.new(:all_tests) do |t|
t.pattern = 'spec/tests/**/*_spec.rb'
t.warning = false
t.description = nil # Hide from rake -T
end

desc 'Run all tests (unit + integration + acceptance) - requires worker running'
task all: ['cache:ensure', :all_tests]
end

# Run specs with cache check
task spec: ['cache:ensure', :spec_only]
desc 'Run unit and integration tests (no worker required)'
task spec: ['cache:ensure', 'spec:unit_integration']

desc 'Keep rerunning unit/integration tests upon changes'
task :respec do
Expand Down
2 changes: 1 addition & 1 deletion app/application/controllers/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class App < Roda
# Appraisal results cached in Redis by worker (1-day TTL)
request_id = [request.env, request.path, Time.now.to_f].hash

path_request = Request::ProjectPath.new(
path_request = Request::Appraisal.new(
owner_name, project_name, request
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

module CodePraise
module Request
# Application value for the path of a requested project
class ProjectPath
# Application value for an appraisal request
# Parses route parameters and provides cache key generation
class Appraisal
CACHE_KEY_PREFIX = 'appraisal'

def initialize(owner_name, project_name, request)
@owner_name = owner_name
@project_name = project_name
Expand All @@ -20,6 +23,16 @@ def folder_name
def project_fullname
@request.captures.join '/'
end

# Cache key for project appraisal (always root - smart cache)
def cache_key
"#{CACHE_KEY_PREFIX}:#{project_fullname}/"
end

# Is this a request for the root folder?
def root_request?
folder_name.empty?
end
end
end
end
70 changes: 48 additions & 22 deletions app/application/services/fetch_or_request_appraisal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ class FetchOrRequestAppraisal

step :find_project_details
step :check_project_eligibility
step :check_cache
step :request_appraisal_worker
step :check_project_appraisal_cache
step :extract_folder_from_appraisal_on_cache_hit
step :request_appraisal_worker_on_cache_miss

private

NO_PROJ_ERR = 'Project not found'
NO_FOLDER_ERR = 'Folder not found in project'
DB_ERR = 'Having trouble accessing the database'
REQUEST_ERR = 'Could not request appraisal'
TOO_LARGE_ERR = 'Project is too large to analyze'
Expand Down Expand Up @@ -45,31 +47,43 @@ def check_project_eligibility(input)
end
end

def check_cache(input)
def check_project_appraisal_cache(input)
cache = Cache::Remote.new(input[:config])
cache_key = appraisal_cache_key(input)
cached_json = cache.get(input[:requested].cache_key)

cached_json = cache.get(cache_key)
return Success(input) unless cached_json
if cached_json
input[:cached_appraisal_json] = cached_json
input[:cache_hit] = true
end

# Cache hit - return the cached JSON directly
# Mark as cache hit so controller knows to pass through
input[:cached_json] = cached_json
input[:cache_hit] = true
Success(input)
rescue StandardError => e
# Cache errors should not fail the request - continue to worker
App.logger.warn "Cache error: #{e.message}"
Success(input)
end

def request_appraisal_worker(input)
# If cache hit, we're done - return success with cached data
def extract_folder_from_appraisal_on_cache_hit(input)
# Skip extraction on cache miss - worker will handle it
return Success(input) unless input[:cache_hit]

folder_name = input[:requested].folder_name
extracted_json = extract_folder_json(input[:cached_appraisal_json], folder_name)

# Folder not found in cached appraisal - this is a bad request, not a cache miss
return Failure(Response::ApiResult.new(status: :not_found, message: NO_FOLDER_ERR)) unless extracted_json

input[:cached_json] = extracted_json
Success(input)
end

def request_appraisal_worker_on_cache_miss(input)
# Cache hit - we're done, return success with cached data
return Success(input) if input[:cache_hit]

# Cache miss - send request to worker
# Cache miss - send job to worker
Messaging::Queue.new(App.config.WORKER_QUEUE_URL, App.config)
.send(appraisal_request_json(input))
.send(appraisal_job_json(input))

Failure(Response::ApiResult.new(
status: :processing,
Expand All @@ -82,22 +96,34 @@ def request_appraisal_worker(input)

# Helper methods

def appraisal_cache_key(input)
folder_path = input[:requested].folder_name || ''
"appraisal:#{input[:project].fullname}/#{folder_path}"
end
# Smart cache: always request root appraisal from worker
ROOT_FOLDER_PATH = ''

def appraisal_request_json(input)
Response::AppraisalRequest.new(
def appraisal_job_json(input)
Messaging::AppraisalJob.new(
input[:project],
input[:requested].folder_name || '',
ROOT_FOLDER_PATH,
input[:request_id]
).then { Representer::AppraisalRequest.new(it).to_json }
).then { Representer::AppraisalJob.new(it).to_json }
end

def log_error(error)
App.logger.error [error.inspect, error.backtrace].flatten.join("\n")
end

# Extracts folder from cached root appraisal JSON
# Returns rebuilt appraisal JSON with extracted folder, or nil if not found
def extract_folder_json(cached_json, folder_name)
# Root request - return cached JSON as-is
return cached_json if folder_name.empty?

# Extract subfolder from cached root
subfolder = Representer::FolderContributions.extract_subfolder(cached_json, folder_name)
return nil unless subfolder

# Rebuild appraisal JSON with extracted subfolder
Representer::Appraisal.rebuild_with_extracted_folder(cached_json, folder_name, subfolder)
end
end
end
end
9 changes: 9 additions & 0 deletions app/infrastructure/messaging/appraisal_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module CodePraise
module Messaging
# Data Transfer Object for appraisal job sent to worker queue
# Contains all data needed by worker to perform appraisal
AppraisalJob = Struct.new(:project, :folder_path, :id)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

module CodePraise
module Representer
# Representer for appraisal request sent to worker queue
# Includes full project info so worker doesn't need database access
class AppraisalRequest < Roar::Decorator
# Representer for appraisal job sent to worker queue
# Serializes/deserializes Messaging::AppraisalJob for SQS transport
class AppraisalJob < Roar::Decorator
include Roar::JSON

property :project, extend: Representer::Project, class: OpenStruct
Expand Down
11 changes: 11 additions & 0 deletions app/presentation/representers/appraisal_representer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ class Appraisal < Roar::Decorator
def status
represented.status.to_s
end

# Rebuilds appraisal JSON with an extracted folder
# Used by smart cache to return subfolder from cached root appraisal
def self.rebuild_with_extracted_folder(appraisal_json, folder_path, folder_ostruct)
data = ::JSON.parse(appraisal_json)
data['folder_path'] = folder_path
data['folder'] = ::JSON.parse(FolderContributions.new(folder_ostruct).to_json)
::JSON.generate(data)
rescue ::JSON::ParserError
nil
end
end
end
end
18 changes: 0 additions & 18 deletions app/presentation/representers/clone_request_representer.rb

This file was deleted.

75 changes: 75 additions & 0 deletions app/presentation/representers/folder_contributions_representer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,81 @@ class FolderContributions < Roar::Decorator
collection :base_files, extend: Representer::FileContributions, class: OpenStruct
collection :subfolders, extend: Representer::FolderContributions, class: OpenStruct
collection :contributors, extend: Representer::Contributor, class: OpenStruct

# Subfolder extraction methods for smart cache
class << self
# Extracts a subfolder from cached root JSON, returns OpenStruct or nil
# @param json_string [String] Full appraisal JSON from cache
# @param folder_path [String] Target folder path (e.g., "app/domain")
# @return [OpenStruct, nil] The subfolder as OpenStruct, or nil if not found
def extract_subfolder(json_string, folder_path)
return nil if json_string.nil? || json_string.empty?

appraisal = parse_appraisal(json_string)
return nil unless appraisal&.folder

normalized_path = normalize_path(folder_path)
return appraisal.folder if normalized_path.empty?

find_subfolder(appraisal.folder, normalized_path)
end

# Extracts a subfolder and returns it as JSON string
# @param json_string [String] Full appraisal JSON from cache
# @param folder_path [String] Target folder path
# @return [String, nil] JSON string of subfolder, or nil if not found
def extract_subfolder_json(json_string, folder_path)
subfolder = extract_subfolder(json_string, folder_path)
return nil unless subfolder

new(subfolder).to_json
end

private

# Parse appraisal JSON into hash/OpenStruct structure
# Uses JSON.parse directly since Appraisal representer has serialization-only features
def parse_appraisal(json_string)
data = JSON.parse(json_string)
return nil unless data['status'] == 'ok' && data['folder']

# Parse the folder portion using FolderContributions representer
folder_json = JSON.generate(data['folder'])
folder = new(OpenStruct.new).from_json(folder_json)

OpenStruct.new(status: data['status'], folder: folder)
rescue JSON::ParserError
nil
end

# Normalize folder path: remove leading/trailing slashes
def normalize_path(path)
path.to_s.gsub(%r{^/|/$}, '')
end

# Recursively find subfolder by path in the tree
# @param folder [OpenStruct] Current folder node
# @param target_path [String] Normalized target path
# @return [OpenStruct, nil]
def find_subfolder(folder, target_path)
return nil unless folder.subfolders

folder.subfolders.each do |subfolder|
subfolder_path = normalize_path(subfolder.path)

# Exact match
return subfolder if subfolder_path == target_path

# Check if target is nested within this subfolder
if target_path.start_with?("#{subfolder_path}/")
result = find_subfolder(subfolder, target_path)
return result if result
end
end

nil
end
end
end
end
end
12 changes: 0 additions & 12 deletions app/presentation/responses/clone_request.rb

This file was deleted.

Loading