diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..09c4798 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,744 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**CodePraise Web API** is a RESTful JSON API that analyzes GitHub repositories to generate "praise reports" showing individual contributor contributions to team projects. It pulls data from GitHub's API, clones repos, and analyzes git blame information to assess contributions. + +This is a **Web API only** - there is NO web interface. All responses are JSON. A separate frontend application consumes this API. + +## Common Development Commands + +### Setup +```bash +bundle install +cp config/secrets_example.yml config/secrets.yml # Add your GitHub token +bundle exec rake db:migrate # Dev database +RACK_ENV=test bundle exec rake db:migrate # Test database +``` + +### Cache Setup (Required) + +Redis is required for caching appraisal results. Environment isolation via separate Redis databases: + +- Development: `redis://localhost:6379/0` +- Test: `redis://localhost:6379/1` +- Production: as assigned by provider + +```bash +rake cache:redis:start # Start Redis Docker container +rake cache:redis:stop # Stop Redis Docker container +rake cache:status # Check cache connectivity +rake cache:ensure # Start if not running (used by rake spec) +rake cache:list # List all cached keys +rake cache:wipe # Wipe all cached keys +``` + +**Note:** `rake spec` automatically ensures cache is running before tests. + +### Testing +```bash +rake spec # Run unit + integration tests (no worker required) +rake spec:all # Run all tests including acceptance (requires worker) +bash spec/acceptance_tests # Start worker and run full test suite (recommended) +rake respec # Continuously run tests on file changes +``` + +**Important:** Acceptance tests require the background worker. Use `bash spec/acceptance_tests` which starts the worker automatically, or manually run `rake worker:run:test` before `rake spec:all`. + +### Running the Application +```bash +rake run # Start Puma web server +rake rerun # Auto-restart on file changes +rake console # Launch Pry console with app loaded +``` + +### Background Worker (Development) +```bash +rake worker:run:dev # Run Shoryuken worker in development mode +rake worker:run:test # Run worker in testing mode +rake worker:run:production # Run worker in production mode +``` + +### Queue Management +```bash +rake queues:create # Create SQS queue for worker +rake queues:status # Report status of worker queue +rake queues:purge # Purge all messages from queue +``` + +### Database Management +```bash +rake db:migrate # Run database migrations +rake db:wipe # Clear all data from tables (dev/test only) +rake db:drop # Delete database file (dev/test only) +``` + +### Repository Store Management + +```bash +rake repos:create # Create directory for repo store +rake repos:list # List cloned repos in repo store +rake repos:wipe # Delete all cloned repos +``` + +### Code Quality +```bash +rake quality:all # Run all quality checks (rubocop + reek + flog) +rake quality:rubocop # Code style linter +rake quality:reek # Code smell detector +rake quality:flog # Complexity analysis +``` + +### Test Fixtures +```bash +rake vcr:wipe # Delete VCR cassettes (recorded HTTP fixtures) +``` + +## API Endpoints + +### Root check +`GET /` +- **200**: API server running (returns API version and environment info) + +### Appraise a previously stored project +`GET /api/v1/projects/{owner_name}/{project_name}[/{folder_path}]` +- **200**: Appraisal returned (JSON with project and folder contributions) +- **404**: Project or folder not found +- **500**: Problems finding or cloning GitHub project + +### Store a project for appraisal +`POST /api/v1/projects/{owner_name}/{project_name}` +- **201**: Project stored (returns project JSON) +- **404**: Project not found on GitHub +- **500**: Problems storing the project + +### Get list of projects +`GET /api/v1/projects?list={base64_encoded_json_array}` +- **200**: List of projects returned (JSON array) +- **400**: Missing or invalid list parameter + +## Architecture + +This application uses **Clean Architecture / Enterprise Design Patterns** with strict layer separation: + +### 4-Layer Web API Architecture + +``` +WEB LAYER (Controllers) + ↓ +APPLICATION LAYER (Services + Requests + Responses) + ↓ +INFRASTRUCTURE LAYER (Database, GitHub Gateway, Git Gateway, Mappers, Cache, Messaging) + ↓ +DOMAIN LAYER (Entities + Values) +``` + +### Worker-Based Appraisal Architecture (Smart Cache) + +Appraisal requests are processed asynchronously via background workers with Redis caching. The **smart cache** strategy caches entire project appraisals at the root level, then extracts subfolder contributions from cache on subsequent requests: + +``` +Client Request (any folder path) + ↓ +API: Check Redis cache (root key: appraisal:{owner}/{project}/) + ↓ (hit) +Extract requested subfolder from cached root → Return JSON ──→ Client + ↓ (miss) +Send AppraisalJob to SQS (always root) → Return 202 Processing + ↓ +Worker receives request + ↓ +Clone repo (if needed) → Appraise ROOT folder → Store JSON in Redis + ↓ +Notify via Faye (progress updates) + ↓ +Client polls API → Cache hit → Extract subfolder → Return result +``` + +**Smart Cache Benefits:** + +- **Single worker request caches all subfolder data**: One appraisal serves all subfolder requests +- **Subfolder requests are instant**: Extract from cached root (no worker call) +- **Simpler cache model**: One key per project instead of many keys per folder +- **Reduced SQS traffic**: Fewer worker requests + +**Key features:** +- **Redis as primary cache**: Worker stores serialized JSON with 1-day TTL +- **No Rack::Cache for appraisals**: Redis is the source of truth +- **Worker does all heavy lifting**: Clone + git blame + serialization +- **API extracts subfolders from cache**: Lightweight extraction from cached root + +**Key architectural change**: The PRESENTATION layer has been restructured for Web API: +- **NO HTML views or view objects** - removed entirely +- **Representers** use Roar gem to serialize domain entities to JSON +- **Responses** are simple data structures passed between layers + +### Layer Responsibilities + +**1. Domain Layer (`app/domain/`)** + +Organized into bounded contexts with **immutable value objects** using `Dry::Struct`: + +**Projects Context (`app/domain/projects/`):** +- `Entity::Project`: Aggregate root for projects with owner and contributors +- `Entity::Member`: GitHub user/contributor entity +- Pure business logic with type-safe attributes +- Entities include `to_attr_hash` method for persistence + +**Contributions Context (`app/domain/contributions/`):** + +- `Value::Appraisal`: Immutable result of appraising a folder (success or error) + - Located in API domain as it's used by both API and worker + - Uses `Nominal(Object)` type for folder attribute (duck typing) + +**Contributions Context (`workers/domain/contributions/`):** +- `Entity::FolderContributions`: Aggregate root for folder-level contributions +- `Entity::FileContributions`: File-level code contributions +- `Entity::LineContribution`: Individual line attribution +- `Entity::Contributor`: Contributor in context of code analysis +- `Value::CreditShare`: Credits shared by contributors (using SimpleDelegator) +- `Value::FilePath`: Value object for file path operations +- `Value::CodeLanguage`: Programming language identification +- `Value::Contributors`: Collection of contributors with identity grouping logic +- `Mixins::ContributionsCalculator`: Shared calculation logic +- These entities/values are worker-only (git blame analysis) + +**Domain Characteristics:** +- **No database or framework dependencies** +- Type-safe with `Dry::Types` (`Strict::String`, `Strict::Integer`, etc.) +- Custom types in `lib/types.rb` (e.g., `HashedArrays`, `HashedIntegers`) + +**2. Infrastructure Layer (`app/infrastructure/`)** + +**Database (`app/infrastructure/database/`):** +- **ORM Models** (`orm/`): Thin Sequel models defining table relationships + - `ProjectOrm`: `many_to_one :owner`, `many_to_many :contributors` + - `MemberOrm`: `one_to_many :owned_projects`, join table relationships +- **Repositories** (`repositories/`): Persistence logic + - `Projects`: CRUD operations for projects + - `Members`: CRUD operations for members + - `For`: Polymorphic router mapping entity types to repositories + - Pattern: `Repository::For.entity(project).create(project)` + - Pattern: `Repository::For.klass(Entity::Project).find_full_name(owner, name)` + +**Cache (`app/infrastructure/cache/`):** + +- **Redis Cache Client** (`Cache::Remote`): Redis client wrapper for appraisal caching + - `get(key)`, `set(key, value, ttl:)`, `exists?(key)` methods + - Environment isolation via separate Redis databases (no key prefixes) + - **Smart cache key format**: `appraisal:{owner}/{project}/` (root only, subfolders extracted from cached root) +- Development: Local Redis (`redis://localhost:6379/0`) +- Test: Local Redis (`redis://localhost:6379/1`) +- Production: Redis cloud cache (as assigned by provider) + +**GitHub Integration (`app/infrastructure/github/`):** +- **Gateway** (`Api`): Authenticated HTTP requests to GitHub API +- **Mappers** (`mappers/`): Transform API responses into domain entities + - `ProjectMapper`: GitHub JSON → `Entity::Project` + - `MemberMapper`: GitHub JSON → `Entity::Member` + +**Messaging (`app/infrastructure/messaging/`):** + +- **Queue** (`queue.rb`): AWS SQS queue wrapper + - Sends appraisal jobs to background worker + - Polls queue for messages (used by worker) + - Requires AWS credentials: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` +- **AppraisalJob** (`appraisal_job.rb`): DTO for worker job payload + - Contains: project (Entity), folder_path, id (request_id) + - Serialized via `Representer::AppraisalJob` for SQS transport + +**3. Application Layer (`app/application/`)** + +**Services (`app/application/services/`):** + +- Use **Dry::Transaction** for composable service pipelines +- `AddProject`: Fetches project from GitHub and stores in database + - Steps: `validate_project`, `store_project` +- `FetchOrRequestAppraisal`: Checks Redis cache, extracts subfolder, dispatches to worker if miss + - Steps: `find_project_details`, `check_project_eligibility`, `check_project_appraisal_cache`, `extract_folder_from_appraisal_on_cache_hit`, `request_appraisal_worker_on_cache_miss` + - Cache hit: Extracts requested subfolder from cached root JSON + - Subfolder not found in cache: Returns 404 (not a cache miss) + - Cache miss: Sends AppraisalJob to SQS (always root), returns `processing` status +- `ListProjects`: Returns list of stored projects + - Steps: `validate_list`, `retrieve_list` + +**Requests (`app/application/requests/`):** +- `Appraisal`: Parses route parameters for project appraisal, generates cache keys +- `EncodedProjectList`: Encodes/decodes base64 JSON project lists + +**Responses (`app/application/responses/`):** + +- `ApiResult`: Standard API response wrapper (status + message) +- `ProjectsList`: Struct for list of projects + +**Controllers (`app/application/controllers/`):** +- `App`: Main Roda application with routing +- `Helpers`: Controller helper methods +- Routes use services, not direct database access +- All responses are JSON via representers + +**4. Presentation Layer (`app/presentation/`)** + +**Representers (`app/presentation/representers/`):** + +- Use **Roar** gem for JSON serialization (not HTML views) +- `HttpResponse`: HTTP status code + JSON response wrapper +- `Project`: Serializes `Entity::Project` to JSON with hypermedia links +- `ProjectsList`: Serializes collection of projects +- `Appraisal`: Serializes `Value::Appraisal` with status wrapper (used by worker) +- `AppraisalJob`: Serializes job payload sent to worker via SQS +- `FolderContributions`: Serializes folder-level contributions; includes `extract_subfolder` for smart cache +- `FileContributions`: Serializes file-level contributions +- `Contributor`: Serializes contributor data +- `Member`: Serializes member/owner data +- `LineContribution`: Serializes line-level attribution +- `CreditShare`: Serializes credit distribution +- `FilePath`: Serializes file path information +- `OpenStructWithLinks`: Helper for hypermedia links + +**Key differences from traditional web app:** +- **NO HTML templates** (no Slim views) +- **NO view objects** (no presentation logic wrappers) +- **NO static assets** (CSS, JavaScript, images) +- Representers provide JSON serialization only +- Hypermedia links embedded in JSON responses + +### Key Architectural Patterns + +**Bounded Contexts (DDD):** +- Domain organized into **Projects** and **Contributions** contexts +- Each context has its own entities, values, and domain logic +- Contexts can share some entities (e.g., `Member`/`Contributor`) +- Clear separation of concerns within the domain layer + +**Service Layer with Dry::Transaction:** +- Services composed of discrete steps +- Each step returns `Success(data)` or `Failure(error)` +- Railway-oriented programming for error handling +- Steps are private methods in service classes + +**Repository Pattern with Polymorphic Routing:** +```ruby +# Generic entity-based routing +Repository::For.entity(project).create(project) +Repository::For.klass(Entity::Project).find_full_name(owner, name) +``` + +**Data Mapper Pattern:** +- Multiple mapper types transform external data to domain entities: + - GitHub mappers: API JSON → domain entities + - Git mappers: git commands/porcelain output → domain entities +- Dependency injection for testability (pass gateway as parameter) + +**Gateway Pattern:** +- `Github::Api`: Wraps HTTP communication with GitHub API +- `Git::GitCommand`: Wraps git command-line operations +- Gateways handle external system complexity and failure modes + +**Representer Pattern (Roar):** +- Presentation layer uses representers to serialize domain entities to JSON +- Representers handle hypermedia links (HATEOAS) +- Clear separation: domain entities remain unaware of JSON serialization + +**Appraisal Caching (Smart Cache with Redis):** + +- Worker stores **root folder** appraisal JSON in Redis with TTL +- API extracts subfolders from cached root on demand +- **Cache key format**: `appraisal:{owner}/{project}/` (root only) +- Success TTL: 1 day (86400 seconds) +- Error TTL: 10 seconds (allows quick retry) +- **Subfolder extraction**: `Representer::FolderContributions.extract_subfolder` navigates cached tree + +**Immutable Value Objects:** +- Domain entities are immutable via `Dry::Struct` +- Value objects use `SimpleDelegator` for specialized collections +- No accidental mutation of business logic + +### Background Worker Architecture + +The application uses **Shoryuken** with **AWS SQS** for asynchronous appraisal operations and **Faye** for real-time progress updates via websockets. + +The worker has its own **DDD-style layer structure** parallel to the API: + +```text +workers/ +├── domain/contributions/ # Entities, values, lib for git analysis +├── infrastructure/ +│ ├── git/ # Git gateway, repositories, mappers +│ └── messaging/ # Faye progress publisher +├── presentation/values/ # Progress monitor calculations +├── application/ +│ ├── controllers/worker.rb # Shoryuken entry point +│ ├── services/appraise_project.rb +│ └── requests/job_reporter.rb +└── shoryuken*.yml # Queue configs +``` + +**Worker Application Layer (`workers/application/`):** + +- `controllers/worker.rb`: Main Shoryuken worker class (`Appraiser::Worker`) + - Receives AppraisalJob from SQS queue + - Dispatches to `Appraiser::Service::AppraiseProject` service + - Reports progress via Faye websockets + +- `services/appraise_project.rb`: Worker service (`Appraiser::Service::AppraiseProject`) + - Dry::Transaction pipeline: `prepare_inputs` → `clone_repo` → `appraise_contributions` → `cache_result` + - Clones repo if not exists locally + - Runs git blame analysis via `Mapper::Contributions` + - Stores serialized JSON in Redis with TTL + +- `requests/job_reporter.rb`: Manages progress reporting + - Deserializes AppraisalJob from JSON + - Publishes progress updates to Faye channel + +**Worker Infrastructure Layer (`workers/infrastructure/`):** + +- `git/gateway/git_command.rb`: Execute git commands on local repos +- `git/repositories/`: Git repository operations + - `GitRepo`: Factory for local/remote repo handling + - `LocalRepo`: Manages cloned local repositories + - `RemoteRepo`: Clones remote repos to local storage + - `BlameReporter`: Runs git blame to get line-by-line attribution + - `RepoFile`: Represents individual files in repo +- `git/mappers/`: Transform git data into domain entities + - `ContributionsMapper`: Orchestrates full contribution analysis + - `FolderContributionsMapper`: Maps folder structure to `Entity::FolderContributions` + - `FileContributionsMapper`: Maps file data to `Entity::FileContributions` + - `BlameContributor`: Maps git contributor info to `Entity::Contributor` + - `PorcelainParser`: Parses git blame porcelain format +- `messaging/progress_publisher.rb`: Sends progress to Faye + - POSTs JSON messages to `/faye` endpoint + - Channel format: `/{request_id}` + +**Worker Presentation Layer (`workers/presentation/`):** + +- `values/progress_monitor.rb`: Tracks progress phases + - `AppraisalMonitor`: Full appraisal progress (15-50% clone, 55-85% appraise, 90-100% cache) + +**Configuration Files:** + +- `workers/shoryuken.yml`: Production SQS queue URL +- `workers/shoryuken_dev.yml`: Development queue configuration +- `workers/shoryuken_test.yml`: Test queue configuration + +**Code Loading:** + +- `require_app.rb`: Loads API layers (domain, infrastructure, presentation, application) +- `require_worker.rb`: Loads worker layers (domain, infrastructure, presentation, application) + +**Websocket Integration (Faye):** + +The web server mounts Faye at `/faye` (configured in `config.ru`): +```ruby +use Faye::RackAdapter, mount: '/faye', timeout: 25 +``` + +Frontend clients subscribe to channels matching their `request_id` to receive real-time progress updates. + +**Async Request Flow:** + +``` +1. Client requests appraisal → API generates unique request_id +2. API checks Redis cache for existing result +3. If cache hit: return cached JSON immediately (200) +4. If cache miss: + - API sends AppraisalJob to SQS queue + - API returns 202 Processing with request_id + - Client subscribes to Faye channel /{request_id} +5. Worker picks up job from SQS +6. Worker clones repo (if needed) → appraises → caches in Redis +7. Progress sent to Faye (15-50% clone, 55-85% appraise, 90-100% cache) +8. Client retries appraisal request after receiving 100% +9. API returns cached result (200) +``` + +**Required Environment Variables (for worker):** + +- `AWS_ACCESS_KEY_ID`: AWS credentials for SQS +- `AWS_SECRET_ACCESS_KEY`: AWS credentials for SQS +- `AWS_REGION`: AWS region (e.g., `us-east-1`) +- `WORKER_QUEUE_URL`: Full SQS queue URL +- `REDIS_URL`: Redis URL for caching results (e.g., `redis://localhost:6379/0`) +- `API_HOST`: API base URL for Faye endpoint (e.g., `https://api.example.com`) + +### Data Flow Examples + +**Adding a GitHub project:** +``` +POST /api/v1/projects/{owner}/{name} + ↓ +Controller → Service::AddProject.call(owner_name:, project_name:) + ↓ +Service steps: + 1. validate_project (check if already exists) + 2. find_project → Github::ProjectMapper.find(owner, name) + - ProjectMapper → Github::Api.repo_data() [HTTP call] + - Builds Entity::Project from API response + 3. store_project → Repository::For.entity(project).create(project) + - Find/create owner Member in DB + - Create Project record + - Find/create Contributor Members + - Link via projects_members join table + ↓ +Controller receives Success(ApiResult) or Failure(ApiResult) + ↓ +Representer::HttpResponse wraps result + ↓ +Representer::Project.new(project).to_json + ↓ +JSON response with 201 status +``` + +**Viewing project contributions (Smart Cache):** + +``` +GET /api/v1/projects/{owner}/{name}[/{folder}] + ↓ +Controller → Request::Appraisal parses route parameters (owner, project, folder) + ↓ +Controller → Service::FetchOrRequestAppraisal.call(requested: request) + ↓ +Service steps: + 1. find_project_details + - Repository::For.klass(Entity::Project).find_full_name(owner, name) + - Returns Failure(:not_found) if not in database + 2. check_project_eligibility + - Rejects projects that are too large + 3. check_project_appraisal_cache + - Cache::Remote.get(request.cache_key) - always root key + - Sets cache_hit flag if found + 4. extract_folder_from_appraisal_on_cache_hit + - On cache hit: extract requested subfolder from cached root JSON + - Root request: return cached JSON unchanged + - Subfolder request: navigate tree, rebuild JSON with extracted folder + - Subfolder not found: Return Failure(:not_found) - bad request, not cache miss + 5. request_appraisal_worker_on_cache_miss + - On cache miss: send AppraisalJob to SQS (always root folder) + - Return Failure(:processing) with request_id + ↓ +Controller receives Success or Failure + ↓ +Cache hit: Return extracted/cached JSON directly (200) +Subfolder not found: Return 404 Not Found +Cache miss: Return 202 Processing with request_id +``` + +**Worker processes appraisal (async):** + +``` +Worker receives AppraisalJob from SQS + ↓ +Appraiser::Service::AppraiseProject steps: + 1. prepare_inputs - convert OpenStruct to Entity::Project + 2. clone_repo - clone if not exists locally (15-50% progress) + 3. appraise_contributions - run git blame analysis (55-85% progress) + 4. cache_result - store JSON in Redis with TTL (90-100% progress) + ↓ +Faye notification: 100% progress + ↓ +Client retries → API returns cached result +``` + +**Getting project list:** +``` +GET /api/v1/projects?list={base64_json_array} + ↓ +Controller → Request::EncodedProjectList.new(params) + ↓ +Controller → Service::ListProjects.call(list_request: list_req) + ↓ +Service steps: + 1. validate_list (check list parameter exists) + 2. retrieve_list + - Decode base64 JSON list + - Repository::For.klass(Entity::Project).find_full_name for each + - Filter out nil results + - Create Response::ProjectsList + ↓ +Controller receives Success(ApiResult) or Failure(ApiResult) + ↓ +Representer::HttpResponse wraps result + ↓ +Representer::ProjectsList.new(projects).to_json + ↓ +JSON response with 200 status +``` + +## Database Schema + +**Tables:** +- `members`: GitHub users (origin_id, username, email) +- `projects`: GitHub repos (origin_id, name, size, urls, owner_id FK) +- `projects_members`: Join table for many-to-many contributors relationship + +**Migrations:** `db/migrations/00X_*.rb` + +## Configuration + +**Environment Management:** +- Uses **Figaro** for configuration (`config/secrets.yml`) +- Separate databases per environment (controlled by `RACK_ENV`) +- `require_app.rb`: Smart loader for selective layer loading + - Default loads: `domain`, `infrastructure`, `application`, `presentation` + - Can selectively load layers: `require_app(%w[domain infrastructure])` + - Enables loading subsets for console/testing + +**Key Config Files:** + +- `config/environment.rb`: Loads Figaro, sets up Sequel database +- `config/secrets.yml`: GitHub token, database filename, Redis URL (git-ignored) +- `config.ru`: Rack application entry point with Faye websocket support +- `require_app.rb`: Layer-selective code loader for API +- `require_worker.rb`: Layer-selective code loader for worker + +**Caching Configuration:** + +- **Development**: Redis database 0 (`redis://localhost:6379/0`) +- **Test**: Redis database 1 (`redis://localhost:6379/1`) +- **Production**: Redis cloud cache at `REDIS_URL` + +## Testing Strategy + +**Test Organization:** +Tests are organized by scope in `spec/tests/`: +- `unit/`: Unit tests for individual classes (mappers, value objects, entities) +- `integration/`: Integration tests (database + gateways, cross-layer interactions) +- `acceptance/`: End-to-end API tests through HTTP interface (using Rack::Test) + +**Test Helpers (`spec/helpers/`):** +- `spec_helper.rb`: Shared test configuration +- `vcr_helper.rb`: VCR configuration for HTTP recording +- `database_helper.rb`: Database cleanup utilities + +**API Testing (Acceptance):** +- Uses **Rack::Test** to make HTTP requests to the API +- Tests JSON responses and HTTP status codes +- Example: `get "/api/v1/projects/#{owner}/#{name}"` then parse JSON +- All acceptance tests verify JSON structure and content + +**VCR (HTTP Recording):** +- Records GitHub API responses to `spec/fixtures/cassettes/*.yml` +- Enables offline testing without real API calls +- Filters sensitive tokens from cassettes +- Use `rake vcr:wipe` to delete and re-record + +**Database Testing:** +- `DatabaseHelper.wipe_database` clears all tables between tests +- Separate test database (`RACK_ENV=test`) + +**Test Fixtures:** +- `spec/fixtures/cassettes/`: VCR HTTP response recordings +- `spec/fixtures/project_info.rb`: Sample project data for tests +- `spec/fixtures/github_results.yml`: Expected GitHub API response structures + +## Working in This Codebase + +**Code Location Decisions:** + +*API (`app/`):* +- Domain entities & value objects → `app/domain/{context}/entities/` or `app/domain/{context}/values/` +- Domain logic & calculations → `app/domain/{context}/lib/` +- Data transformation → `app/infrastructure/*/mappers/` +- External service access → `app/infrastructure/*/gateway/` or `app/infrastructure/*/gateways/` +- Database CRUD operations → `app/infrastructure/database/repositories/` +- Database schema → `app/infrastructure/database/orm/` +- Caching → `app/infrastructure/cache/` +- HTTP routes → `app/application/controllers/app.rb` +- Service objects (business workflows) → `app/application/services/` +- Request parsing → `app/application/requests/` +- Response data structures → `app/application/responses/` and `app/presentation/responses/` +- JSON representers → `app/presentation/representers/` + +*Worker (`workers/`):* + +- Contributions domain (git analysis) → `workers/domain/contributions/` +- Git operations → `workers/infrastructure/git/` +- Faye messaging → `workers/infrastructure/messaging/` +- Progress tracking → `workers/presentation/values/` +- Worker entry point → `workers/application/controllers/worker.rb` +- Worker services → `workers/application/services/` +- Request handling → `workers/application/requests/` + +**Adding a New API Endpoint:** +1. Define service object in `app/application/services/` using Dry::Transaction +2. Create request object in `app/application/requests/` if needed +3. Create response struct in `app/application/responses/` or `app/presentation/responses/` +4. Add route in `app/application/controllers/app.rb` +5. Create representer in `app/presentation/representers/` for JSON serialization +6. Write acceptance test in `spec/tests/acceptance/api_spec.rb` +7. Add integration tests for service in `spec/tests/integration/` + +**Adding a New Domain Feature:** +1. Create/modify domain entities in `app/domain/{context}/entities/` +2. Create/modify value objects in `app/domain/{context}/values/` +3. Add domain logic/calculations in `app/domain/{context}/lib/` +4. Add database migration if schema changes (`db/migrations/`) +5. Create/update ORM model in `app/infrastructure/database/orm/` +6. Add repository methods in `app/infrastructure/database/repositories/` +7. Create mappers in `app/infrastructure/*/mappers/` to transform external data +8. Create service object in `app/application/services/` +9. Create representer in `app/presentation/representers/` +10. Write tests in `spec/tests/{unit,integration,acceptance}/` + +**Dependency Injection Pattern:** +- Infrastructure classes accept gateway/config parameters for testability +- Example: `ProjectMapper.new(github_token)` or `ProjectMapper.new(gateway_instance)` + +**Type Safety:** +- Use `Dry::Types` for entity attributes: `Strict::String`, `Strict::Integer`, etc. +- `Integer.optional` for nullable fields (e.g., database IDs) +- Entities validate types on instantiation + +## Technology Stack + +- **Web**: Roda 3.x (routing), Puma 6.x (server) +- **JSON Serialization**: Roar (Representers with hypermedia) +- **Data**: Dry-Struct/Dry-Types (validation), Dry-Transaction (services), Sequel 5.x (ORM), SQLite (dev/test), PostgreSQL (production) +- **Caching**: Redis (primary cache for appraisals) +- **Background Jobs**: Shoryuken 6.x (worker), AWS SQS (queue) +- **Websockets**: Faye (real-time progress updates) +- **HTTP**: HTTP gem 5.x +- **Testing**: Minitest, Rack::Test, VCR, WebMock, SimpleCov +- **Quality**: RuboCop, Reek, Flog + +## Important Notes + +- **This is a Web API only** - there is no web interface +- All responses are JSON (via Roar representers) +- Hypermedia links are embedded in JSON responses (HATEOAS) +- Redis is the primary cache for appraisal results (no Rack::Cache) +- Services use Dry::Transaction for railway-oriented programming +- Controllers should be thin - delegate to services +- Representers handle JSON serialization - domain entities stay pure +- Background worker handles async git clone and appraisal operations via AWS SQS +- Real-time progress updates sent to clients via Faye websockets + +## Heroku Deployment + +**Procfile Processes:** + +- `release`: Runs `rake db:migrate` and `rake queues:create` on each deploy +- `web`: Puma web server with Faye websocket support +- `worker`: Shoryuken background worker for git clone and appraisal jobs + +**Required Heroku Config Vars:** + +- `GITHUB_TOKEN`: GitHub API access token +- `AWS_ACCESS_KEY_ID`: AWS credentials for SQS +- `AWS_SECRET_ACCESS_KEY`: AWS credentials for SQS +- `AWS_REGION`: AWS region (e.g., `us-east-1`) +- `WORKER_QUEUE`: SQS queue name +- `WORKER_QUEUE_URL`: Full SQS queue URL +- `API_HOST`: API base URL (e.g., `https://your-app.herokuapp.com`) +- `REDIS_URL`: Redis URL for caching (provision your own Redis instance) +- `DATABASE_URL`: PostgreSQL URL (auto-set by Heroku Postgres add-on) + +**Scaling:** + +```bash +heroku ps:scale web=1 worker=1 +``` + +**Architecture Note:** Web and worker dynos run in separate isolated containers with ephemeral filesystems. The worker clones repos to its local ephemeral storage, performs git blame analysis, and caches the serialized JSON results in Redis. The API then retrieves cached results from Redis - it never accesses the cloned repos directly. This architecture works both locally and in production Heroku deployments. diff --git a/.claude/active/CLAUDE.feature-smartcache.md b/.claude/active/CLAUDE.feature-smartcache.md new file mode 100644 index 0000000..c2608a3 --- /dev/null +++ b/.claude/active/CLAUDE.feature-smartcache.md @@ -0,0 +1,423 @@ +# Feature Plan: Smart Cache - Project-Level Caching with Subfolder Extraction + +## Overview + +Cache entire project appraisals once, then extract subfolder contributions from cache on subsequent requests without calling the worker again. + +## Current Architecture + +``` +Client → API → Check Redis cache (folder-specific key) + ↓ (hit) + Return cached JSON + ↓ (miss) + Send AppraisalRequest to SQS → Return 202 Processing + ↓ + Worker receives request + ↓ + Clone if needed + ↓ + Appraise FOLDER only + ↓ + Store JSON in Redis (folder key) + ↓ + Notify via Faye +``` + +**Current cache key format**: `appraisal:{owner}/{project}/{folder_path}` + +**Problem**: Each subfolder requires a separate worker appraisal, even though: +- The worker clones the entire repo anyway +- Git blame runs on all files during appraisal +- Subfolder data is a subset of root appraisal data + +## Proposed Architecture + +``` +Client → API → Check Redis cache (project root key) + ↓ (hit) + Extract subfolder from cached root → Return JSON + ↓ (miss) + Send AppraisalRequest to SQS (always root) → Return 202 Processing + ↓ + Worker receives request + ↓ + Clone if needed + ↓ + Appraise ROOT folder + ↓ + Store JSON in Redis (root key only) + ↓ + Notify via Faye +``` + +**New cache key format**: `appraisal:{owner}/{project}/` (root only) + +**Benefits**: +- Single worker request caches all subfolder data +- Subsequent subfolder requests are cache hits +- Reduced SQS messages, worker load, and latency + +## Key Changes + +### 1. Worker Changes + +- **Modify**: Always appraise root folder (ignore folder_path in request) +- **Modify**: Cache key always uses root path (empty string) +- **Keep**: Progress reporting unchanged + +### 2. Web API Changes + +- **Add**: Subfolder extraction logic from cached root appraisal +- **Modify**: Cache lookup uses root key, then extracts subfolder +- **Modify**: AppraisalRequest always requests root (folder_path = "") +- **Add**: New domain/infrastructure for subfolder extraction + +### 3. Cache Strategy + +- **Single key per project**: `appraisal:{owner}/{project}/` +- **Value**: Full project `FolderContributions` JSON (root) +- **TTL**: 1 day (unchanged) +- **Extraction**: API extracts subfolder from cached root on demand + +## Discussion Points + +### Feasibility Assessment + +**Pros:** + +1. ⚠️ **Reduced worker calls**: One appraisal serves all subfolder requests +2. ⚠️ **Lower latency**: Subfolders served from cache after first request +3. ⚠️ **Simpler cache model**: One key per project vs many keys per folder +4. ⚠️ **Less SQS traffic**: Fewer worker requests + +**Concerns to Address:** + +1. ~~⚠️ **Cache size**: Root appraisal JSON larger than subfolder-only~~ **RESOLVED: ~30KB is practical** + +2. ~~⚠️ **Extraction complexity**: How to extract subfolder from root?~~ **RESOLVED: Navigate tree via Roar deserialization** + +3. ~~⚠️ **First request latency**: Root appraisal takes longer than subfolder~~ **ACCEPTED: Same as current (full clone happens anyway)** + +4. ⚠️ **Subfolder not found**: What if requested subfolder doesn't exist? + - Return nil from extraction, service returns 404 + +5. ~~⚠️ **Representer changes**: How to serialize extracted subfolder?~~ **RESOLVED: Re-serialize OpenStruct subtree via representer** + +## Implementation Phases + +### Phase 1: Analysis & Design ✅ + +- [x] Analyze `FolderContributions` structure for subfolder extraction +- [x] Measure typical root appraisal JSON sizes (~30KB practical) +- [x] Design subfolder extraction algorithm (tree traversal via Roar deserialization) +- [x] Decide on location for extraction logic (Representer) +- [x] Document decisions in this file + +### Phase 2: Refactor - Create `Request::Appraisal` and `Messaging::AppraisalJob` ✅ + +**Replace `Request::ProjectPath` with `Request::Appraisal`:** + +- [x] Create `Request::Appraisal` in `app/application/requests/` + - Subsumes `ProjectPath` functionality (owner_name, project_name, folder_name) + - Add `project_fullname` method + - Add `cache_key` method (single source of truth) + - Add `root_request?` helper +- [x] Update controller to use `Request::Appraisal` instead of `ProjectPath` +- [x] Update `Service::FetchOrRequestAppraisal` to use `request.cache_key` +- [x] Remove duplicate `appraisal_cache_key` helper from service +- [x] Delete old `Request::ProjectPath` +- [x] Add unit tests for `Request::Appraisal` + +**Consolidate worker job DTO:** + +- [x] Create `Messaging::AppraisalJob` in `app/infrastructure/messaging/` +- [x] Create `Representer::AppraisalJob` (replaces `AppraisalRequest`) +- [x] Update service to use `Messaging::AppraisalJob` +- [x] Update worker `JobReporter` to use new naming +- [x] Delete legacy `Response::CloneRequest` and `Response::AppraisalRequest` +- [x] Delete legacy `Representer::CloneRequest` and `Representer::AppraisalRequest` +- [x] Update tests and documentation +- [x] Verify all existing tests pass (88 tests, 0 failures) + +### Phase 3: Representer Subfolder Extraction ✅ + +**Add extraction methods to `Representer::FolderContributions`:** + +- [x] Add `find_subfolder(root_ostruct, folder_path)` - tree traversal helper +- [x] Add `extract_subfolder(json_string, folder_path)` - returns OpenStruct or nil +- [x] Add `extract_subfolder_json(json_string, folder_path)` - returns JSON string or nil +- [x] Handle edge cases: root path (empty string), path not found, trailing slashes +- [x] Unit tests for extraction methods (16 tests in `folder_contributions_extraction_spec.rb`) + +### Phase 4: Smart Cache Key and Worker Request ✅ + +**API owns smart cache strategy; worker remains general-purpose:** + +- [x] Update `Request::Appraisal#cache_key` for root-only behavior + - `cache_key` now always returns root key: `appraisal:{owner}/{project}/` + - `folder_name` still available for subfolder extraction +- [x] Update `FetchOrRequestAppraisal` to always send root to worker + - Added `ROOT_FOLDER_PATH = ''` constant in API service + - Worker receives root path, appraises faithfully +- [x] Worker unchanged - remains general-purpose (appraises whatever folder it's told) +- [x] Update unit tests for smart cache behavior + +### Phase 5: API Service Subfolder Extraction ✅ + +**Modify `FetchOrRequestAppraisal` to extract subfolders from cache:** + +- [x] On cache hit: extract requested subfolder from cached root JSON + - Added `extract_subfolder` and `rebuild_appraisal_json` helper methods + - Root requests return cached JSON unchanged + - Subfolder requests extract and rebuild JSON with correct `folder_path` +- [x] Handle subfolder-not-found (return 404) + - Returns `NO_FOLDER_ERR` with `:not_found` status +- [x] Update service unit/integration tests + - Added 2 integration tests: subfolder extraction (happy) and subfolder not found (sad) + +### Phase 6: Cleanup & Testing ✅ + +- [x] End-to-end acceptance tests for smart cache flow +- [x] Test scenarios: root request, subfolder request, nested subfolder, invalid path +- [x] Verify old folder-specific cache keys are ignored (TTL expiration) +- [x] Update `.claude/CLAUDE.md` documentation with new architecture +- [x] Update session log with completion status + +## Session Log + +### Session 1 + +- Initial discussion of smart cache feature +- Updated CLAUDE.md to reflect current project state +- Created this planning document +- Answered all 6 Open Questions: + - Q1: FolderContributions has nested tree structure; JSON preserves it via recursive representer + - Q2: Measured JSON sizes (~30KB for root project); root-only caching is practical + - Q3: Extraction logic in Representer (presentation layer) - keeps entity worker-only + - Q4: Tree traversal is O(depth); Roar `from_json` handles nested deserialization automatically + - Q5: Cache invalidation unchanged; smart cache simplifies (one key per project) + - Q6: Create `Request::Appraisal` subsuming `ProjectPath` with cache key methods +- Made 5 decisions (see Decisions Made section) +- Refined Implementation Phases (6 phases) with Phase 2 as refactor +- Committed: `8c020a3` - docs: complete Phase 1 planning for smart cache feature +- **Status**: Phase 1 COMPLETE + +### Session 2 + +- Implemented Phase 2 refactoring: + - Created `Request::Appraisal` (replaces `Request::ProjectPath`) + - Created `Messaging::AppraisalJob` DTO (replaces `Response::AppraisalRequest`) + - Created `Representer::AppraisalJob` (replaces `Representer::AppraisalRequest`) + - Updated controller, service, and worker to use new objects + - Deleted legacy code: `CloneRequest`, `AppraisalRequest` (response/representer) + - Updated CLAUDE.md documentation + - All 88 tests passing +- Restructured test tasks: + - `rake spec` now runs only unit + integration tests (77 tests, no worker required) + - `rake spec:all` runs all tests including acceptance (88 tests, requires worker) + - `bash spec/acceptance_tests` starts worker and calls `spec:all` + - Updated CLAUDE.md testing documentation +- Committed: `cfac067` - tests: restructure test tasks for worker-independent development +- **Status**: Phase 2 COMPLETE + +### Session 3 + +- Implemented Phase 3 subfolder extraction: + - Added `extract_subfolder(json_string, folder_path)` class method to `Representer::FolderContributions` + - Added `extract_subfolder_json(json_string, folder_path)` for JSON string output + - Added private `find_subfolder` tree traversal helper + - Handles edge cases: root path, nil, trailing/leading slashes, non-existent folders, error appraisals + - Created JSON fixtures in `spec/fixtures/json/` (not wiped by `vcr:wipe`) + - Added 16 unit tests in `folder_contributions_extraction_spec.rb` + - All 93 tests passing (77 original + 16 new) +- Implemented Phase 4 smart cache (API-owned strategy): + - `Request::Appraisal#cache_key` now always returns root key + - `FetchOrRequestAppraisal` always sends root folder_path to worker + - Worker unchanged - remains general-purpose (appraises whatever it's told) + - Design: API owns smart cache strategy; worker stays simple + - Updated unit tests for smart cache behavior + - All 93 tests passing +- **Status**: Phase 4 COMPLETE; ready for Phase 5 + +### Session 4 + +- Implemented Phase 5 API service subfolder extraction: + - Added `extract_subfolder(cached_json, folder_name)` helper to `FetchOrRequestAppraisal` + - Added `rebuild_appraisal_json` helper for JSON reconstruction + - Root requests return cached JSON unchanged + - Subfolder requests extract, rebuild JSON with updated `folder_path` and `folder` + - Returns 404 when subfolder not found in cached root + - Design decision: Keep extraction as helper method (not separate step) - it's an implementation detail of cache lookup +- Added 2 integration tests: + - HAPPY: extract subfolder from cached root on cache hit + - SAD: return 404 when subfolder not found in cache +- All 95 tests passing (93 original + 2 new integration) +- **Status**: Phase 5 COMPLETE; ready for Phase 6 + +### Session 5 + +- Refactored `FetchOrRequestAppraisal` to separate caching from folder extraction: + - Renamed `check_cache` → `check_project_appraisal_cache` + - Created separate step `extract_folder_from_appraisal_on_cache_hit` + - Renamed `request_appraisal_worker` → `request_appraisal_worker_on_cache_miss` + - Key insight: "folder not found" is a bad request (404), not a cache miss +- Moved `rebuild_appraisal_json` to `Representer::Appraisal.rebuild_with_extracted_folder` + - Fixed `::JSON` namespace issue (avoids conflict with `Representable::JSON`) +- Implemented Phase 6 acceptance tests: + - Added smart cache test: extract multiple subfolders from cached root + - Added invalid subfolder test: returns 404 after root is cached + - Used actual YPBT-app folders (spec, models, services) +- Verified JSON fixtures in `spec/fixtures/json/` are correct and in use: + - `sample_appraisal.json`: used in 3 integration tests + 13 unit tests + - `error_appraisal.json`: used in 1 unit test (error handling) +- Updated CLAUDE.md documentation with smart cache architecture: + - Updated Worker-Based Appraisal Architecture diagram + - Updated cache key format documentation (root-only) + - Updated FetchOrRequestAppraisal step names + - Updated data flow example for viewing contributions +- All 107 tests passing +- **Status**: Phase 6 COMPLETE; Smart Cache feature COMPLETE + +--- + +## Open Questions + +1. ~~How is `FolderContributions` structured? Can subfolders be extracted?~~ **ANSWERED: See Decision 1** +2. ~~What is the typical JSON size for root vs subfolder appraisals?~~ **ANSWERED: ~30KB root; practical for Redis** +3. ~~Should extraction happen in domain layer or infrastructure?~~ **ANSWERED: See Decision 2** +4. ~~How to handle deep nested paths efficiently?~~ **ANSWERED: See Decision 3** +5. ~~Cache invalidation strategy unchanged?~~ **ANSWERED: See Decision 4** +6. ~~Should cache-key generation be consolidated with request parsing?~~ **ANSWERED: See Decision 5** + +--- + +## Decisions Made + +### Decision 1: Navigate Cached Tree for Subfolder Extraction + +- JSON preserves nested `FolderContributions` structure via recursive representer +- Subfolder extraction will **navigate the deserialized tree** (not filter/reconstruct) +- **Rationale**: Leverages existing JSON structure; no reconstruction needed + +### Decision 2: Extraction Logic in Presentation Layer (Representer) + +- Extraction logic will reside in `Representer::FolderContributions` as class methods +- Tentative interface: + - `extract_subfolder(json_string, folder_path)` → OpenStruct or nil + - `extract_subfolder_json(json_string, folder_path)` → JSON string or nil +- **Rationale**: + - Representer already knows the JSON shape + - This is representation logic, not business logic + - Keeps service layer thin + - Avoids needing `FolderContributions` entity in API (stays worker-only) +- Alternative considered: Domain entity - rejected because (a) would require API to have the entity, (b) extraction is representation concern not business logic +- **⚠️ Concern noted**: Representer-based traversal may be brittle to changes in the entity structure. If `FolderContributions` entity changes how it organizes subfolders or paths, the representer extraction logic could break. If this becomes an issue, reconsider having the entity own the traversal logic (would require sharing entity between API and worker). + +### Decision 3: Roar Deserialization is Sufficient + +- Roar's `from_json` already handles nested deserialization automatically +- Deserializes to `OpenStruct` tree (not domain entities) - sufficient for traversal +- No mapper needed (JSON → Entity conversion would be extra work with no benefit) +- API only needs to pass through JSON to client; OpenStruct sufficient for subfolder lookup +- **Verified**: Tested with cached `YPBT-app` project - full nested tree deserializes correctly + +### Decision 4: Cache Invalidation Strategy Unchanged + +- TTL-based expiration remains: 1 day for success, 10 seconds for errors +- Manual wipe via `rake cache:wipe` unchanged +- Smart cache **simplifies** invalidation: one key per project instead of many +- No automatic invalidation on repo updates (deferred to future feature) +- **Future considerations** (out of scope for this feature): + - Webhook-based invalidation on GitHub push + - Manual wipe + re-appraisal workflow + +### Decision 5: Create `Request::Appraisal` Subsuming `ProjectPath` + +- New `Request::Appraisal` object replaces `Request::ProjectPath` +- **Naming**: `Request::Appraisal` (not `Request::AppraisalRequest` - avoids redundancy) + - Clean pairing: `Request::Appraisal` (what client asks for) vs `Value::Appraisal` (what worker produces) +- Owns cache key generation as single source of truth +- Provides: + - `owner_name`, `project_name`, `folder_name` (from ProjectPath) + - `project_fullname` method + - `cache_key` method (currently folder-specific, will become root-only) + - `root_request?` helper +- **Rationale**: + - Semantic clarity: Request (what is asked for) vs Appraisal (result of work) + - Removes duplicate cache key logic from service + - Centralizes "what is being requested" concept + - Stays in application layer where request objects belong +- **Implementation**: Completed in Phase 2 +- **Note**: `Value::Appraisal#cache_key` remains for worker use (result caching) + +### Decision 6: Create `Messaging::AppraisalJob` for Worker Queue + +- New `Messaging::AppraisalJob` DTO replaces `Response::AppraisalRequest` +- **Naming**: "Job" clarifies it's a work payload, not a response +- **Location**: `app/infrastructure/messaging/` (alongside Queue) +- **Pattern**: Data Transfer Object (DTO) for SQS transport +- Serialized via `Representer::AppraisalJob` +- **Cleanup**: Deleted legacy `CloneRequest` and associated representer + +--- + +## Quick Reference + +### Current Cache Key Format + +``` +appraisal:{owner}/{project}/{folder_path} +``` + +Examples: +- Root: `appraisal:ISS-SOA/codepraise-api/` +- Subfolder: `appraisal:ISS-SOA/codepraise-api/app/domain/` + +### Proposed Cache Key Format + +``` +appraisal:{owner}/{project}/ +``` + +Examples: +- All requests use: `appraisal:ISS-SOA/codepraise-api/` + +### Key Files to Modify + +**Worker:** + +- `workers/application/services/appraise_project.rb` +- `app/domain/contributions/values/appraisal.rb` + +**API:** + +- `app/application/services/fetch_or_request_appraisal.rb` +- New: subfolder extraction logic (location TBD) + +**Tests:** + +- `spec/tests/unit/appraisal_spec.rb` +- `spec/tests/integration/services_spec.rb` + +--- + +## Notes + +### Commit Practices + +- **Summarize changes before requesting commit permission** - provide brief summary by folder/file for user review BEFORE asking to commit +- **Separate concerns into distinct commits** - e.g., test setup changes vs feature changes +- **User is author, Claude is co-author** - use `Co-Authored-By: Claude ` +- **Use conventional commit messages** - `feat:`, `fix:`, `refactor:`, `docs:`, etc. + - `feat:` only for changes to external API/service features + - `refactor:` for internal changes (new domain objects, infrastructure, etc.) + +### Implementation Practices + +- **Seek consent before moving to next phase** - summarize completed work, commit, then ask to proceed +- **Include coverage report in commits** - after successful tests, amend `coverage/.resultset.json` to coding commits + +### Planning Practices + +- **IMPORTANT: Pause after each question** - wait for explicit user approval before proceeding to the next question during planning discussions diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index 27c2c6e..ba578ce 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -58,4 +58,4 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | rake worker:run:test & - bundle exec rake spec \ No newline at end of file + bundle exec rake spec:all \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9115917..75df40c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -.claude/ -CLAUDE.md +.claude/_* +.claude/settings.local.json CLAUDE.local.md _snippets/ .bundle/ diff --git a/Rakefile b/Rakefile index 9003c6a..33153d1 100644 --- a/Rakefile +++ b/Rakefile @@ -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 diff --git a/app/application/controllers/app.rb b/app/application/controllers/app.rb index 729269c..de63b95 100644 --- a/app/application/controllers/app.rb +++ b/app/application/controllers/app.rb @@ -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 ) diff --git a/app/application/requests/project_request_path.rb b/app/application/requests/appraisal.rb similarity index 53% rename from app/application/requests/project_request_path.rb rename to app/application/requests/appraisal.rb index b5acd4d..0db22cd 100644 --- a/app/application/requests/project_request_path.rb +++ b/app/application/requests/appraisal.rb @@ -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 @@ -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 diff --git a/app/application/services/fetch_or_request_appraisal.rb b/app/application/services/fetch_or_request_appraisal.rb index f9e6132..373efe8 100644 --- a/app/application/services/fetch_or_request_appraisal.rb +++ b/app/application/services/fetch_or_request_appraisal.rb @@ -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' @@ -45,17 +47,15 @@ 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 @@ -63,13 +63,27 @@ def check_cache(input) 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, @@ -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 diff --git a/app/infrastructure/messaging/appraisal_job.rb b/app/infrastructure/messaging/appraisal_job.rb new file mode 100644 index 0000000..f4d9c18 --- /dev/null +++ b/app/infrastructure/messaging/appraisal_job.rb @@ -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 diff --git a/app/presentation/representers/appraisal_request_representer.rb b/app/presentation/representers/appraisal_job_representer.rb similarity index 64% rename from app/presentation/representers/appraisal_request_representer.rb rename to app/presentation/representers/appraisal_job_representer.rb index 31b937a..30d405a 100644 --- a/app/presentation/representers/appraisal_request_representer.rb +++ b/app/presentation/representers/appraisal_job_representer.rb @@ -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 diff --git a/app/presentation/representers/appraisal_representer.rb b/app/presentation/representers/appraisal_representer.rb index 03ff0e4..fc7759b 100644 --- a/app/presentation/representers/appraisal_representer.rb +++ b/app/presentation/representers/appraisal_representer.rb @@ -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 diff --git a/app/presentation/representers/clone_request_representer.rb b/app/presentation/representers/clone_request_representer.rb deleted file mode 100644 index 0055626..0000000 --- a/app/presentation/representers/clone_request_representer.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require 'roar/decorator' -require 'roar/json' -require_relative 'project_representer' - -# Represents essential Repo information for API output -module CodePraise - module Representer - # Representer object for project clone requests - class CloneRequest < Roar::Decorator - include Roar::JSON - - property :project, extend: Representer::Project, class: OpenStruct - property :id - end - end -end diff --git a/app/presentation/representers/folder_contributions_representer.rb b/app/presentation/representers/folder_contributions_representer.rb index 8d4b77e..4f69a09 100644 --- a/app/presentation/representers/folder_contributions_representer.rb +++ b/app/presentation/representers/folder_contributions_representer.rb @@ -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 diff --git a/app/presentation/responses/clone_request.rb b/app/presentation/responses/clone_request.rb deleted file mode 100644 index e77e66f..0000000 --- a/app/presentation/responses/clone_request.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module CodePraise - module Response - # Request to clone a project (legacy - for backwards compatibility) - CloneRequest = Struct.new :project, :id - - # Request to appraise a project folder - # Includes full project info so worker doesn't need database access - AppraisalRequest = Struct.new :project, :folder_path, :id - end -end diff --git a/coverage/.resultset.json b/coverage/.resultset.json index bde9e12..2aa2c7e 100644 --- a/coverage/.resultset.json +++ b/coverage/.resultset.json @@ -1,7 +1,7 @@ { "RSpec": { "coverage": { - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/require_app.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/require_app.rb": { "lines": [ null, null, @@ -16,12 +16,12 @@ 1, null, 1, - 38, + 37, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/require_worker.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/require_worker.rb": { "lines": [ null, null, @@ -46,7 +46,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/config/environment.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/config/environment.rb": { "lines": [ null, null, @@ -115,7 +115,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/domain/contributions/values/appraisal.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/domain/contributions/values/appraisal.rb": { "lines": [ null, null, @@ -200,7 +200,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/domain/projects/entities/project.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/domain/projects/entities/project.rb": { "lines": [ null, null, @@ -227,23 +227,23 @@ 1, null, 1, - 25, + 18, null, null, 1, - 11, + 19, null, null, 1, null, - 17, + 20, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/domain/projects/entities/member.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/domain/projects/entities/member.rb": { "lines": [ null, null, @@ -262,14 +262,14 @@ 1, null, 1, - 65, + 77, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/cache/remote_cache.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/cache/remote_cache.rb": { "lines": [ null, null, @@ -284,7 +284,7 @@ null, 1, 1, - 48, + 60, null, null, null, @@ -292,14 +292,14 @@ null, null, 1, - 8, + 10, null, null, null, null, null, 1, - 11, + 19, null, null, null, @@ -310,18 +310,18 @@ null, null, 1, - 61, + 67, null, null, 1, - 70, + 79, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/database/orm/member_orm.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/database/orm/member_orm.rb": { "lines": [ null, null, @@ -343,14 +343,14 @@ 1, null, 1, - 64, + 76, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/database/orm/project_orm.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/database/orm/project_orm.rb": { "lines": [ null, null, @@ -374,7 +374,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/database/repositories/for.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/database/repositories/for.rb": { "lines": [ null, null, @@ -391,18 +391,18 @@ null, null, 1, - 30, + 39, null, null, 1, - 16, + 19, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/database/repositories/members.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/database/repositories/members.rb": { "lines": [ null, null, @@ -419,9 +419,9 @@ null, null, 1, - 120, + 164, null, - 120, + 164, null, null, null, @@ -430,20 +430,20 @@ null, null, 1, - 30, - 90, + 41, + 123, null, null, null, 1, - 64, + 76, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/database/repositories/projects.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/database/repositories/projects.rb": { "lines": [ null, null, @@ -462,12 +462,12 @@ null, null, null, - 29, + 38, null, null, null, null, - 29, + 38, null, null, 1, @@ -478,7 +478,7 @@ null, null, 1, - 16, + 19, null, null, 1, @@ -487,21 +487,21 @@ null, null, 1, - 16, - 16, + 19, + 19, null, null, 1, - 16, + 19, null, - 16, - 16, + 19, + 19, null, null, 1, - 61, + 76, null, - 30, + 41, null, null, null, @@ -512,21 +512,21 @@ null, 1, 1, - 16, + 19, null, null, 1, - 16, + 19, null, null, 1, - 16, + 19, null, - 16, - 16, + 19, + 19, null, - 16, - 48, + 19, + 57, null, null, null, @@ -536,7 +536,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/github/gateways/github_api.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/github/gateways/github_api.rb": { "lines": [ null, null, @@ -549,45 +549,45 @@ 1, null, 1, - 55, + 61, null, null, 1, - 30, + 33, null, null, null, null, 1, - 25, + 28, null, null, null, 1, 1, - 30, - 30, + 33, + 33, null, null, 1, - 30, + 33, null, null, null, null, 1, 1, - 55, + 61, null, null, 1, - 55, + 61, null, null, null, null, - 54, - 54, + 60, + 60, null, null, null, @@ -603,7 +603,7 @@ null, null, 1, - 54, + 60, null, null, 1, @@ -615,7 +615,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/github/mappers/member_mapper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/github/mappers/member_mapper.rb": { "lines": [ null, null, @@ -625,29 +625,29 @@ null, 1, 1, - 25, - 25, - 25, + 28, + 28, + 28, null, null, 1, - 25, - 75, + 28, + 84, null, null, null, 1, - 100, + 112, null, null, null, 1, 1, - 100, + 112, null, null, 1, - 100, + 112, null, null, null, @@ -658,15 +658,15 @@ 1, null, 1, - 100, + 112, null, null, 1, - 100, + 112, null, null, 1, - 100, + 112, null, null, null, @@ -674,7 +674,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/github/mappers/project_mapper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/github/mappers/project_mapper.rb": { "lines": [ null, null, @@ -685,31 +685,31 @@ null, 1, 1, - 30, - 30, - 30, + 33, + 33, + 33, null, null, 1, - 30, - 25, + 33, + 28, null, null, 1, - 25, + 28, null, null, null, 1, 1, - 25, - 25, + 28, + 28, null, null, null, null, 1, - 25, + 28, null, null, null, @@ -722,39 +722,52 @@ null, null, 1, - 25, + 28, null, null, 1, - 25, + 28, null, null, 1, - 25, + 28, null, null, 1, - 25, + 28, null, null, 1, - 25, + 28, null, null, 1, - 25, + 28, null, null, 1, - 25, + 28, + null, + null, + null, + null, + null + ] + }, + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/messaging/appraisal_job.rb": { + "lines": [ + null, null, + 1, + 1, null, null, + 1, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/infrastructure/messaging/queue.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/infrastructure/messaging/queue.rb": { "lines": [ null, null, @@ -768,13 +781,13 @@ 1, null, 1, - 3, - 3, + 4, + 4, null, null, null, null, - 3, + 4, null, null, null, @@ -782,7 +795,7 @@ null, null, 1, - 3, + 4, null, null, null, @@ -800,7 +813,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/appraisal_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/appraisal_job_representer.rb": { "lines": [ null, null, @@ -811,10 +824,6 @@ 1, 1, null, - 1, - 1, - null, - null, null, 1, 1, @@ -824,23 +833,10 @@ 1, null, null, - 1, - 6, - null, - null, - 7, - 7, - null, - null, - 1, - 6, - null, - null, - null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/project_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/project_representer.rb": { "lines": [ null, null, @@ -869,24 +865,24 @@ 1, null, 1, - 14, + 15, null, null, 1, null, 1, - 14, + 15, null, null, 1, - 14, + 15, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/member_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/member_representer.rb": { "lines": [ null, null, @@ -910,7 +906,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/folder_contributions_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/appraisal_representer.rb": { "lines": [ null, null, @@ -920,85 +916,158 @@ null, 1, 1, - 1, - 1, null, 1, 1, null, - 1, - 1, + null, null, 1, 1, + null, 1, 1, 1, + null, + null, 1, + 6, + null, + null, + 7, + 7, + null, + null, 1, + 6, + null, + null, + null, + null, 1, - 1, + 5, + 5, + 5, + 5, + null, + 0, + null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/contributor_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/folder_contributions_representer.rb": { "lines": [ null, null, 1, 1, + 1, null, 1, 1, - null, 1, 1, null, 1, 1, null, - null, - null - ] - }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/credit_share_representer.rb": { - "lines": [ - null, + 1, + 1, null, 1, 1, 1, - null, 1, - null, 1, 1, - null, + 1, 1, 1, null, + null, 1, + null, + null, + null, + null, 1, + 23, + null, + 21, + 21, + null, + 19, + 19, + null, + 17, + null, null, null, null, - null - ] - }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/file_contributions_representer.rb": { - "lines": [ null, null, 1, + 3, + 3, + null, + 2, + null, + null, 1, - 1, + null, + null, null, 1, + 21, + 20, + null, + null, + 19, + 19, + null, + 19, + null, 1, + null, + null, + null, 1, + 75, + null, + null, + null, + null, + null, + null, 1, + 23, + null, + 23, + 56, + null, + null, + 56, + null, + null, + 44, + 6, + 6, + null, + null, + null, + 6, + null, + null, + null, + null, + null + ] + }, + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/contributor_representer.rb": { + "lines": [ + null, null, 1, 1, @@ -1008,7 +1077,7 @@ null, 1, 1, - 1, + null, 1, 1, null, @@ -1016,12 +1085,13 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/file_path_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/credit_share_representer.rb": { "lines": [ null, null, 1, 1, + 1, null, 1, null, @@ -1035,10 +1105,11 @@ 1, null, null, + null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/line_contribution_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/file_contributions_representer.rb": { "lines": [ null, null, @@ -1047,7 +1118,7 @@ 1, null, 1, - null, + 1, 1, 1, null, @@ -1056,6 +1127,8 @@ null, 1, 1, + null, + 1, 1, 1, 1, @@ -1065,30 +1138,29 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/appraisal_request_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/file_path_representer.rb": { "lines": [ null, null, 1, 1, + null, 1, null, 1, 1, null, - null, 1, 1, null, 1, 1, - 1, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/clone_request_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/line_contribution_representer.rb": { "lines": [ null, null, @@ -1096,6 +1168,7 @@ 1, 1, null, + 1, null, 1, 1, @@ -1105,12 +1178,16 @@ null, 1, 1, + 1, + 1, + 1, + 1, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/http_response_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/http_response_representer.rb": { "lines": [ null, null, @@ -1145,14 +1222,14 @@ null, null, 1, - 11, + 13, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/openstruct_with_links.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/openstruct_with_links.rb": { "lines": [ null, null, @@ -1168,7 +1245,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/representers/projects_representer.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/representers/projects_representer.rb": { "lines": [ null, null, @@ -1191,7 +1268,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/responses/api_result.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/responses/api_result.rb": { "lines": [ null, null, @@ -1211,32 +1288,16 @@ null, 1, 1, - 30, - null, - 29, - null, - null, - null, - null - ] - }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/responses/clone_request.rb": { - "lines": [ - null, - null, - 1, - 1, - null, - 1, + 34, null, + 33, null, null, - 1, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/presentation/responses/projects_list.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/presentation/responses/projects_list.rb": { "lines": [ null, null, @@ -1248,7 +1309,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/application/controllers/app.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/application/controllers/app.rb": { "lines": [ null, null, @@ -1265,10 +1326,10 @@ null, null, 1, - 14, + 20, null, null, - 14, + 20, 1, null, 1, @@ -1279,41 +1340,35 @@ 1, null, null, - 13, - 13, - 13, - null, - 10, - null, - null, - 8, + 19, + 19, + 19, null, - 8, + 16, null, + 14, null, + 14, null, - 8, null, null, + 14, null, null, null, - 8, - 5, - 5, null, null, + 14, null, - 3, - 3, - 3, - 3, + 7, + 7, null, null, null, - 0, - 0, + 7, + 7, null, + 7, null, null, null, @@ -1356,7 +1411,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/application/controllers/helpers.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/application/controllers/helpers.rb": { "lines": [ null, null, @@ -1385,88 +1440,101 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/application/requests/project_list.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/application/requests/appraisal.rb": { "lines": [ null, null, 1, 1, - 1, null, - 1, - 1, null, 1, 1, null, 1, - 6, + 27, + 27, + 27, + 27, null, null, + 1, null, 1, - 6, + 14, + null, + null, + 1, + 18, null, null, null, 1, + 17, null, null, null, + 1, + 2, null, null, + null, + null + ] + }, + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/application/requests/project_list.rb": { + "lines": [ null, null, + 1, + 1, + 1, null, 1, - 6, + 1, null, + 1, + 1, + null, + 1, + 6, null, null, null, 1, - 5, + 6, null, null, null, 1, - 3, null, null, null, - null - ] - }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/application/requests/project_request_path.rb": { - "lines": [ null, null, - 1, - 1, null, - 1, - 1, - 8, - 8, - 8, - 8, null, null, 1, + 6, + null, + null, + null, null, 1, - 9, + 5, + null, null, null, 1, - 0, + 3, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/application/services/add_project.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/application/services/add_project.rb": { "lines": [ null, null, @@ -1488,24 +1556,24 @@ null, null, 1, - 12, + 13, 1, null, - 11, + 12, null, - 9, + 10, null, 3, null, null, 1, null, + 10, 9, - 8, null, 1, null, - 9, + 10, null, 0, 0, @@ -1514,7 +1582,7 @@ null, null, 1, - 11, + 12, null, null, null, @@ -1522,7 +1590,7 @@ null, null, 1, - 12, + 13, null, null, null, @@ -1530,7 +1598,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/application/services/fetch_or_request_appraisal.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/application/services/fetch_or_request_appraisal.rb": { "lines": [ null, null, @@ -1547,6 +1615,7 @@ 1, 1, 1, + 1, null, 1, null, @@ -1555,15 +1624,16 @@ 1, 1, 1, + 1, null, null, 1, - 12, + 20, null, null, null, - 12, - 10, + 20, + 18, null, 2, null, @@ -1572,25 +1642,23 @@ null, null, 1, - 10, + 18, 2, null, - 8, + 16, null, null, null, 1, - 8, - 8, - null, - 8, - 8, + 16, + 16, null, + 16, + 11, + 11, null, null, - 4, - 4, - 4, + 16, null, null, 0, @@ -1599,13 +1667,27 @@ null, 1, null, - 8, + 16, null, + 11, + 11, null, - 4, null, + 11, + null, + 9, + 9, + null, + null, + 1, + null, + 14, null, - 4, + null, + 5, + null, + null, + 5, null, null, null, @@ -1616,18 +1698,15 @@ null, null, null, - 1, - 8, - 8, - null, null, 1, - 4, null, + 1, + 5, null, null, - 4, null, + 5, null, null, 1, @@ -1635,10 +1714,24 @@ null, null, null, + null, + 1, + null, + 11, + null, + null, + 7, + 7, + null, + null, + 5, + null, + null, + null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/app/application/services/list_projects.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/app/application/services/list_projects.rb": { "lines": [ null, null, @@ -1682,7 +1775,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/entities/contributor.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/entities/contributor.rb": { "lines": [ null, null, @@ -1715,7 +1808,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/entities/file_contributions.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/entities/file_contributions.rb": { "lines": [ null, null, @@ -1765,7 +1858,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/lib/contributions_calculator.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/lib/contributions_calculator.rb": { "lines": [ null, null, @@ -1799,7 +1892,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/values/code_language.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/values/code_language.rb": { "lines": [ null, null, @@ -1956,7 +2049,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/entities/folder_contributions.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/entities/folder_contributions.rb": { "lines": [ null, null, @@ -2041,7 +2134,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/entities/line_contribution.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/entities/line_contribution.rb": { "lines": [ null, null, @@ -2074,7 +2167,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/lib/types.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/lib/types.rb": { "lines": [ null, null, @@ -2097,7 +2190,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/values/contributors.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/values/contributors.rb": { "lines": [ 1, 1, @@ -2152,7 +2245,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/values/credit_share.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/values/credit_share.rb": { "lines": [ null, null, @@ -2249,7 +2342,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/domain/contributions/values/file_path.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/domain/contributions/values/file_path.rb": { "lines": [ null, null, @@ -2304,7 +2397,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/gateway/git_command.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/gateway/git_command.rb": { "lines": [ null, null, @@ -2373,7 +2466,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/mappers/blame_contributor.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/mappers/blame_contributor.rb": { "lines": [ null, null, @@ -2404,7 +2497,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/mappers/contributions_mapper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/mappers/contributions_mapper.rb": { "lines": [ null, null, @@ -2445,7 +2538,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/mappers/file_contributions_mapper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/mappers/file_contributions_mapper.rb": { "lines": [ null, null, @@ -2516,7 +2609,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/mappers/folder_contributions_mapper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/mappers/folder_contributions_mapper.rb": { "lines": [ null, null, @@ -2551,7 +2644,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/mappers/porcelain_parser.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/mappers/porcelain_parser.rb": { "lines": [ null, null, @@ -2605,7 +2698,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/repositories/blame_reporter.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/repositories/blame_reporter.rb": { "lines": [ null, null, @@ -2680,7 +2773,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/repositories/repo_file.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/repositories/repo_file.rb": { "lines": [ null, null, @@ -2705,7 +2798,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/repositories/git_repo.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/repositories/git_repo.rb": { "lines": [ null, null, @@ -2746,7 +2839,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/repositories/local_repo.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/repositories/local_repo.rb": { "lines": [ null, null, @@ -2813,7 +2906,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/repositories/remote_repo.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/repositories/remote_repo.rb": { "lines": [ null, null, @@ -2850,7 +2943,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/git/repositories/repo_store.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/git/repositories/repo_store.rb": { "lines": [ null, null, @@ -2859,19 +2952,19 @@ null, 1, 1, - 11, - 15, + 12, + 17, null, null, 1, - 15, + 17, null, null, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/infrastructure/messaging/progress_publisher.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/infrastructure/messaging/progress_publisher.rb": { "lines": [ null, null, @@ -2908,7 +3001,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/presentation/values/progress_monitor.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/presentation/values/progress_monitor.rb": { "lines": [ null, null, @@ -2976,7 +3069,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/helpers/vcr_helper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/helpers/vcr_helper.rb": { "lines": [ null, null, @@ -3000,12 +3093,12 @@ null, null, 1, - 32, - 2462, - 2462, + 35, + 2795, + 2795, null, null, - 32, + 35, null, null, null, @@ -3014,12 +3107,12 @@ null, null, 1, - 32, + 35, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/helpers/database_helper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/helpers/database_helper.rb": { "lines": [ null, null, @@ -3028,15 +3121,15 @@ null, 1, null, - 24, - 24, - 24, - 24, + 27, + 27, + 27, + 27, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/helpers/cache_helper.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/helpers/cache_helper.rb": { "lines": [ null, null, @@ -3047,18 +3140,18 @@ 1, null, 1, - 40, + 44, null, null, null, 1, - 58, - 58, + 64, + 64, null, null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/integration/layers/domain_contributions_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/integration/layers/domain_contributions_spec.rb": { "lines": [ null, null, @@ -3127,7 +3220,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/integration/layers/gateway_database_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/integration/layers/gateway_database_spec.rb": { "lines": [ null, null, @@ -3178,7 +3271,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/integration/layers/github_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/integration/layers/github_spec.rb": { "lines": [ null, null, @@ -3261,7 +3354,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/integration/services/add_project_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/integration/services/add_project_spec.rb": { "lines": [ null, null, @@ -3368,7 +3461,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/integration/services/fetch_or_request_appraisal_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/integration/services/fetch_or_request_appraisal_spec.rb": { "lines": [ null, null, @@ -3383,21 +3476,92 @@ 1, null, 1, - 4, - 4, - 4, - 4, + 6, + 6, + 6, + 6, null, - 4, + 6, + null, + null, + 1, + 6, + 6, + null, + null, + 1, + 1, + null, + 1, + null, + null, + 1, + null, + null, + 1, + 1, + 1, + null, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + null, + null, + null, + null, + null, + 1, + 1, + 1, + null, + null, + 1, + null, + 1, null, null, 1, - 4, - 4, null, null, 1, 1, + 1, + null, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + null, + null, + null, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + 1, + 1, + null, + null, + 1, null, 1, null, @@ -3417,6 +3581,7 @@ null, null, null, + null, 1, null, null, @@ -3445,6 +3610,7 @@ null, null, null, + null, 1, 1, null, @@ -3475,6 +3641,7 @@ null, null, null, + null, 1, null, null, @@ -3513,6 +3680,7 @@ null, null, null, + null, 1, null, null, @@ -3527,7 +3695,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/integration/services/list_projects_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/integration/services/list_projects_spec.rb": { "lines": [ null, null, @@ -3614,7 +3782,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/unit/appraisal_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/unit/appraisal_spec.rb": { "lines": [ null, null, @@ -3812,7 +3980,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/unit/gateway_git_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/unit/folder_contributions_extraction_spec.rb": { "lines": [ null, null, @@ -3822,60 +3990,53 @@ 1, null, 1, + 12, + null, + null, 1, 1, null, null, + 1, + 1, + 1, null, null, null, 1, - null, - null, 1, 1, null, null, - null, - null, + 1, 1, null, - null - ] - }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/unit/remote_cache_spec.rb": { - "lines": [ null, null, 1, 1, null, + null, 1, 1, - 7, - 7, - null, null, - 1, - 7, null, null, 1, 1, 1, null, - 1, - null, null, 1, 1, null, null, + null, 1, 1, 1, null, - 1, + null, 1, 1, null, @@ -3885,8 +4046,6 @@ 1, 1, null, - 1, - null, null, 1, 1, @@ -3894,7 +4053,180 @@ null, null, 1, - 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + null, + null, + 1, + 1, + null, + 1, + null, + null, + 1, + 1, + null, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + null, + null, + null, + 1, + 1, + 1, + null, + null, + null, + 1, + 1, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + 1, + 1, + 1, + null, + null, + null + ] + }, + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/unit/gateway_git_spec.rb": { + "lines": [ + null, + null, + 1, + null, + 1, + 1, + null, + 1, + 1, + 1, + null, + null, + null, + null, + null, + 1, + null, + null, + 1, + 1, + null, + null, + null, + null, + 1, + null, + null + ] + }, + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/unit/remote_cache_spec.rb": { + "lines": [ + null, + null, + 1, + 1, + null, + 1, + 1, + 7, + 7, + null, + null, + 1, + 7, + null, + null, + 1, + 1, + 1, + null, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + 1, + null, + 1, + 1, + 1, + null, + null, + null, + 1, + 1, + 1, + null, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + 1, 1, 1, null, @@ -3920,7 +4252,142 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/unit/result_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/unit/request_appraisal_spec.rb": { + "lines": [ + null, + null, + 1, + null, + 1, + null, + 1, + 1, + null, + 1, + 13, + 13, + null, + null, + null, + 1, + 1, + 6, + null, + null, + null, + null, + 6, + null, + null, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + 1, + 3, + null, + null, + null, + null, + 3, + null, + null, + null, + null, + 1, + 1, + null, + null, + 1, + null, + 1, + null, + null, + 1, + 1, + null, + null, + null, + 1, + 1, + 2, + null, + null, + null, + null, + 2, + null, + null, + null, + null, + 1, + 1, + null, + null, + 1, + null, + 1, + null, + null, + null, + 1, + 1, + 1, + null, + null, + null, + null, + 1, + null, + null, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + null, + null, + 1, + null, + null, + null, + null, + 1, + null, + null, + null + ] + }, + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/unit/result_spec.rb": { "lines": [ null, null, @@ -3949,7 +4416,7 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/spec/tests/unit/worker_appraise_spec.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/spec/tests/unit/worker_appraise_spec.rb": { "lines": [ null, null, @@ -4100,13 +4567,14 @@ null ] }, - "/Users/soumyaray/Sync/Dropbox/ossdev/classes/SOA-class/projects/soa2025/api-codepraise-2025/workers/application/services/appraise_project.rb": { + "/Users/soumyaray/Sync/Dropbox/ossdev/projects/codepraise/api-codepraise/workers/application/services/appraise_project.rb": { "lines": [ null, null, 1, null, 1, + 1, null, null, 1, @@ -4117,8 +4585,6 @@ 1, 1, null, - 1, - null, null, null, null, @@ -4213,6 +4679,8 @@ 0, null, null, + 1, + null, null, 1, 4, @@ -4254,10 +4722,11 @@ null, null, null, + null, null ] } }, - "timestamp": 1766390257 + "timestamp": 1766550348 } } diff --git a/spec/acceptance_tests b/spec/acceptance_tests index 3e8ddf0..46e59d0 100755 --- a/spec/acceptance_tests +++ b/spec/acceptance_tests @@ -17,9 +17,9 @@ rake worker:run:test > spec/logs/worker.log 2>&1 & # Give worker time to start sleep 2 -# Run acceptance tests -echo "Running tests..." -bundle exec rake spec +# Run all tests (unit + integration + acceptance) +echo "Running all tests..." +bundle exec rake spec:all # Show worker log summary echo "" diff --git a/spec/fixtures/json/error_appraisal.json b/spec/fixtures/json/error_appraisal.json new file mode 100644 index 0000000..cc684ce --- /dev/null +++ b/spec/fixtures/json/error_appraisal.json @@ -0,0 +1,8 @@ +{ + "status": "error", + "project": { + "name": "test-project" + }, + "error_type": "invalid_project", + "message": "Project not found" +} diff --git a/spec/fixtures/json/sample_appraisal.json b/spec/fixtures/json/sample_appraisal.json new file mode 100644 index 0000000..9f579dd --- /dev/null +++ b/spec/fixtures/json/sample_appraisal.json @@ -0,0 +1,62 @@ +{ + "status": "ok", + "project": { + "name": "test-project", + "fullname": "owner/test-project" + }, + "folder_path": "", + "folder": { + "path": "", + "line_count": 1000, + "total_credits": 1000, + "any_subfolders?": true, + "any_base_files?": true, + "credit_share": { + "share": { + "dev1": 600, + "dev2": 400 + } + }, + "base_files": [], + "subfolders": [ + { + "path": "app", + "line_count": 500, + "any_subfolders?": true, + "any_base_files?": false, + "subfolders": [ + { + "path": "app/domain", + "line_count": 200, + "any_subfolders?": true, + "any_base_files?": true, + "subfolders": [ + { + "path": "app/domain/entities", + "line_count": 100, + "any_subfolders?": false, + "any_base_files?": true, + "subfolders": [] + } + ] + }, + { + "path": "app/services", + "line_count": 300, + "any_subfolders?": false, + "any_base_files?": true, + "subfolders": [] + } + ] + }, + { + "path": "spec", + "line_count": 400, + "any_subfolders?": false, + "any_base_files?": true, + "subfolders": [] + } + ], + "contributors": [] + } +} diff --git a/spec/tests/acceptance/api_spec.rb b/spec/tests/acceptance/api_spec.rb index f4c84ae..96c01b6 100644 --- a/spec/tests/acceptance/api_spec.rb +++ b/spec/tests/acceptance/api_spec.rb @@ -87,22 +87,66 @@ def app _(appraisal['folder']['base_files'].count).must_equal 3 end - it 'should be report error for an invalid subfolder' do + it 'should extract different subfolders from cached root (smart cache)' do + # Smart cache: one worker call caches root, all subfolders extracted from cache CodePraise::Service::AddProject.new.call( owner_name: USERNAME, project_name: PROJECT_NAME ) - get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}/foobar" + # First request for root - triggers worker + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}" _(last_response.status).must_equal 202 5.times { sleep(1); print('_') } - # Error appraisals are cached with status 'error' - get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}/foobar" + # Root should now be cached + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}" _(last_response.status).must_equal 200 - appraisal = JSON.parse last_response.body - _(appraisal['status']).must_equal 'error' - _(appraisal['error_type']).wont_be_nil + + # Request 'spec' subfolder - should extract from cached root (no 202) + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}/spec" + _(last_response.status).must_equal 200 + spec_appraisal = JSON.parse last_response.body + _(spec_appraisal['folder_path']).must_equal 'spec' + _(spec_appraisal['folder']['path']).must_equal 'spec' + + # Request 'models' subfolder - should also extract from cached root (no 202) + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}/models" + _(last_response.status).must_equal 200 + models_appraisal = JSON.parse last_response.body + _(models_appraisal['folder_path']).must_equal 'models' + _(models_appraisal['folder']['path']).must_equal 'models' + + # Request 'services' subfolder - should also work + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}/services" + _(last_response.status).must_equal 200 + services_appraisal = JSON.parse last_response.body + _(services_appraisal['folder_path']).must_equal 'services' + _(services_appraisal['folder']['path']).must_equal 'services' + end + + it 'should return 404 for an invalid subfolder after root is cached' do + # Smart cache: worker appraises root, API extracts subfolders + CodePraise::Service::AddProject.new.call( + owner_name: USERNAME, project_name: PROJECT_NAME + ) + + # First request triggers worker to appraise root + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}" + _(last_response.status).must_equal 202 + + 5.times { sleep(1); print('_') } + + # Wait for root to be cached + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}" + _(last_response.status).must_equal 200 + + # Now request invalid subfolder - should get 404 (not error appraisal) + get "/api/v1/projects/#{USERNAME}/#{PROJECT_NAME}/foobar" + _(last_response.status).must_equal 404 + response = JSON.parse last_response.body + _(response['status']).must_equal 'not_found' + _(response['message']).must_include 'Folder not found' end it 'should be report error for an invalid project' do diff --git a/spec/tests/integration/services/fetch_or_request_appraisal_spec.rb b/spec/tests/integration/services/fetch_or_request_appraisal_spec.rb index ff29df5..d57d383 100644 --- a/spec/tests/integration/services/fetch_or_request_appraisal_spec.rb +++ b/spec/tests/integration/services/fetch_or_request_appraisal_spec.rb @@ -25,7 +25,7 @@ end describe 'Fetch or Request Appraisal' do - it 'HAPPY: should return cached JSON when cache hit' do + it 'HAPPY: should return cached JSON when cache hit for root request' do # GIVEN: a valid project in database and cached appraisal gh_project = CodePraise::Github::ProjectMapper .new(GITHUB_TOKEN) @@ -34,15 +34,16 @@ # Pre-populate cache with appraisal JSON cache_key = "appraisal:#{USERNAME}/#{PROJECT_NAME}/" - cached_json = '{"status":"ok","data":{"path":"","subfolders":[]}}' + cached_json = File.read('spec/fixtures/json/sample_appraisal.json') @cache.set(cache_key, cached_json, ttl: 86_400) - # WHEN: we request appraisal + # WHEN: we request root appraisal request = OpenStruct.new( owner_name: USERNAME, project_name: PROJECT_NAME, project_fullname: "#{USERNAME}/#{PROJECT_NAME}", - folder_name: '' + folder_name: '', + cache_key: cache_key ) result = CodePraise::Service::FetchOrRequestAppraisal.new.call( @@ -51,12 +52,83 @@ config: @config ) - # THEN: we should get success with cached JSON + # THEN: we should get success with cached JSON (unchanged for root) _(result.success?).must_equal true _(result.value![:cache_hit]).must_equal true _(result.value![:cached_json]).must_equal cached_json end + it 'HAPPY: should extract subfolder from cached root on cache hit' do + # GIVEN: a valid project in database and cached root appraisal + gh_project = CodePraise::Github::ProjectMapper + .new(GITHUB_TOKEN) + .find(USERNAME, PROJECT_NAME) + CodePraise::Repository::For.entity(gh_project).create(gh_project) + + # Pre-populate cache with root appraisal JSON + cache_key = "appraisal:#{USERNAME}/#{PROJECT_NAME}/" + cached_json = File.read('spec/fixtures/json/sample_appraisal.json') + @cache.set(cache_key, cached_json, ttl: 86_400) + + # WHEN: we request a subfolder appraisal + request = OpenStruct.new( + owner_name: USERNAME, + project_name: PROJECT_NAME, + project_fullname: "#{USERNAME}/#{PROJECT_NAME}", + folder_name: 'app', + cache_key: cache_key + ) + + result = CodePraise::Service::FetchOrRequestAppraisal.new.call( + requested: request, + request_id: 'test-123', + config: @config + ) + + # THEN: we should get success with extracted subfolder JSON + _(result.success?).must_equal true + _(result.value![:cache_hit]).must_equal true + + # Verify extracted JSON contains the subfolder data + extracted_data = JSON.parse(result.value![:cached_json]) + _(extracted_data['folder_path']).must_equal 'app' + _(extracted_data['folder']['path']).must_equal 'app' + _(extracted_data['folder']['line_count']).must_equal 500 + end + + it 'SAD: should return 404 when requested subfolder not found in cache' do + # GIVEN: a valid project in database and cached root appraisal + gh_project = CodePraise::Github::ProjectMapper + .new(GITHUB_TOKEN) + .find(USERNAME, PROJECT_NAME) + CodePraise::Repository::For.entity(gh_project).create(gh_project) + + # Pre-populate cache with root appraisal JSON + cache_key = "appraisal:#{USERNAME}/#{PROJECT_NAME}/" + cached_json = File.read('spec/fixtures/json/sample_appraisal.json') + @cache.set(cache_key, cached_json, ttl: 86_400) + + # WHEN: we request a non-existent subfolder + request = OpenStruct.new( + owner_name: USERNAME, + project_name: PROJECT_NAME, + project_fullname: "#{USERNAME}/#{PROJECT_NAME}", + folder_name: 'nonexistent/path', + cache_key: cache_key + ) + + result = CodePraise::Service::FetchOrRequestAppraisal.new.call( + requested: request, + request_id: 'test-123', + config: @config + ) + + # THEN: we should get failure with not_found status + _(result.failure?).must_equal true + _(result.failure.status).must_equal :not_found + _(result.failure.message).must_equal 'Folder not found in project' + end + it 'HAPPY: should return processing status when cache miss' do # GIVEN: a valid project in database but NO cached appraisal gh_project = CodePraise::Github::ProjectMapper @@ -69,7 +141,8 @@ owner_name: USERNAME, project_name: PROJECT_NAME, project_fullname: "#{USERNAME}/#{PROJECT_NAME}", - folder_name: '' + folder_name: '', + cache_key: "appraisal:#{USERNAME}/#{PROJECT_NAME}/" ) # Mock the queue to avoid actual SQS calls @@ -100,7 +173,8 @@ owner_name: USERNAME, project_name: PROJECT_NAME, project_fullname: "#{USERNAME}/#{PROJECT_NAME}", - folder_name: '' + folder_name: '', + cache_key: "appraisal:#{USERNAME}/#{PROJECT_NAME}/" ) result = CodePraise::Service::FetchOrRequestAppraisal.new.call( @@ -138,7 +212,8 @@ owner_name: USERNAME, project_name: PROJECT_NAME, project_fullname: "#{USERNAME}/#{PROJECT_NAME}", - folder_name: '' + folder_name: '', + cache_key: "appraisal:#{USERNAME}/#{PROJECT_NAME}/" ) result = CodePraise::Service::FetchOrRequestAppraisal.new.call( diff --git a/spec/tests/unit/folder_contributions_extraction_spec.rb b/spec/tests/unit/folder_contributions_extraction_spec.rb new file mode 100644 index 0000000..0b29cc5 --- /dev/null +++ b/spec/tests/unit/folder_contributions_extraction_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require_relative '../../helpers/spec_helper' + +describe 'Unit test of Representer::FolderContributions subfolder extraction' do + FIXTURES_PATH = File.join(File.dirname(__FILE__), '../../fixtures/json') + + def sample_appraisal_json + File.read(File.join(FIXTURES_PATH, 'sample_appraisal.json')) + end + + def error_appraisal_json + File.read(File.join(FIXTURES_PATH, 'error_appraisal.json')) + end + + describe 'extract_subfolder' do + it 'should return root folder for empty path' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, '' + ) + + _(result).wont_be_nil + _(result.path).must_equal '' + _(result.line_count).must_equal 1000 + end + + it 'should return root folder for nil path' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, nil + ) + + _(result).wont_be_nil + _(result.path).must_equal '' + end + + it 'should extract top-level subfolder' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, 'app' + ) + + _(result).wont_be_nil + _(result.path).must_equal 'app' + _(result.line_count).must_equal 500 + end + + it 'should extract nested subfolder' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, 'app/domain' + ) + + _(result).wont_be_nil + _(result.path).must_equal 'app/domain' + _(result.line_count).must_equal 200 + end + + it 'should extract deeply nested subfolder' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, 'app/domain/entities' + ) + + _(result).wont_be_nil + _(result.path).must_equal 'app/domain/entities' + _(result.line_count).must_equal 100 + end + + it 'should return nil for non-existent folder' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, 'nonexistent' + ) + + _(result).must_be_nil + end + + it 'should return nil for non-existent nested folder' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, 'app/nonexistent' + ) + + _(result).must_be_nil + end + + it 'should handle trailing slashes' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, 'app/domain/' + ) + + _(result).wont_be_nil + _(result.path).must_equal 'app/domain' + end + + it 'should handle leading slashes' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + sample_appraisal_json, '/app/domain' + ) + + _(result).wont_be_nil + _(result.path).must_equal 'app/domain' + end + + it 'should return nil for error appraisal' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + error_appraisal_json, '' + ) + + _(result).must_be_nil + end + + it 'should return nil for nil json' do + result = CodePraise::Representer::FolderContributions.extract_subfolder(nil, '') + + _(result).must_be_nil + end + + it 'should return nil for empty json' do + result = CodePraise::Representer::FolderContributions.extract_subfolder('', '') + + _(result).must_be_nil + end + + it 'should return nil for invalid json' do + result = CodePraise::Representer::FolderContributions.extract_subfolder( + 'not valid json', '' + ) + + _(result).must_be_nil + end + end + + describe 'extract_subfolder_json' do + it 'should return JSON string for valid subfolder' do + result = CodePraise::Representer::FolderContributions.extract_subfolder_json( + sample_appraisal_json, 'app' + ) + + _(result).wont_be_nil + parsed = JSON.parse(result) + _(parsed['path']).must_equal 'app' + _(parsed['line_count']).must_equal 500 + end + + it 'should return nil for non-existent folder' do + result = CodePraise::Representer::FolderContributions.extract_subfolder_json( + sample_appraisal_json, 'nonexistent' + ) + + _(result).must_be_nil + end + + it 'should preserve nested structure in JSON output' do + result = CodePraise::Representer::FolderContributions.extract_subfolder_json( + sample_appraisal_json, 'app' + ) + + _(result).wont_be_nil + parsed = JSON.parse(result) + _(parsed['subfolders']).wont_be_nil + _(parsed['subfolders'].length).must_equal 2 + end + end +end diff --git a/spec/tests/unit/request_appraisal_spec.rb b/spec/tests/unit/request_appraisal_spec.rb new file mode 100644 index 0000000..e0f239d --- /dev/null +++ b/spec/tests/unit/request_appraisal_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require_relative '../../helpers/spec_helper' + +describe 'Unit test of Request::Appraisal' do + # Mock Roda request object with captures and remaining_path + class MockRodaRequest + attr_reader :captures, :remaining_path + + def initialize(captures:, remaining_path:) + @captures = captures + @remaining_path = remaining_path + end + end + + describe 'root folder request' do + before do + @mock_request = MockRodaRequest.new( + captures: %w[testowner test-project], + remaining_path: '' + ) + + @request = CodePraise::Request::Appraisal.new( + 'testowner', 'test-project', @mock_request + ) + end + + it 'should parse owner_name' do + _(@request.owner_name).must_equal 'testowner' + end + + it 'should parse project_name' do + _(@request.project_name).must_equal 'test-project' + end + + it 'should have empty folder_name for root' do + _(@request.folder_name).must_equal '' + end + + it 'should compute project_fullname' do + _(@request.project_fullname).must_equal 'testowner/test-project' + end + + it 'should generate correct cache_key for root' do + _(@request.cache_key).must_equal 'appraisal:testowner/test-project/' + end + + it 'should identify as root request' do + _(@request.root_request?).must_equal true + end + end + + describe 'subfolder request' do + before do + @mock_request = MockRodaRequest.new( + captures: %w[testowner test-project], + remaining_path: '/app/domain' + ) + + @request = CodePraise::Request::Appraisal.new( + 'testowner', 'test-project', @mock_request + ) + end + + it 'should parse folder_name from remaining_path' do + _(@request.folder_name).must_equal 'app/domain' + end + + it 'should generate root cache_key (smart cache)' do + # Smart cache: always use root key regardless of folder_name + _(@request.cache_key).must_equal 'appraisal:testowner/test-project/' + end + + it 'should not identify as root request' do + _(@request.root_request?).must_equal false + end + end + + describe 'nested subfolder request' do + before do + @mock_request = MockRodaRequest.new( + captures: %w[ISS-SOA codepraise-api], + remaining_path: '/app/domain/contributions/entities' + ) + + @request = CodePraise::Request::Appraisal.new( + 'ISS-SOA', 'codepraise-api', @mock_request + ) + end + + it 'should handle deeply nested folder paths' do + _(@request.folder_name).must_equal 'app/domain/contributions/entities' + end + + it 'should generate root cache_key for nested folder (smart cache)' do + # Smart cache: always use root key regardless of folder depth + _(@request.cache_key).must_equal 'appraisal:ISS-SOA/codepraise-api/' + end + end + + describe 'smart cache key behavior' do + it 'should always use root cache key for any folder request' do + mock_request = MockRodaRequest.new( + captures: %w[testowner test-project], + remaining_path: '/some/deep/nested/path' + ) + + request = CodePraise::Request::Appraisal.new( + 'testowner', 'test-project', mock_request + ) + + # Smart cache: cache key is always root, regardless of requested folder + _(request.cache_key).must_equal 'appraisal:testowner/test-project/' + _(request.folder_name).must_equal 'some/deep/nested/path' + end + + it 'should match Value::Appraisal root cache key format' do + mock_request = MockRodaRequest.new( + captures: %w[testowner test-project], + remaining_path: '' + ) + + request = CodePraise::Request::Appraisal.new( + 'testowner', 'test-project', mock_request + ) + + # Both Request::Appraisal and Value::Appraisal use same root key format + _(request.cache_key).must_equal 'appraisal:testowner/test-project/' + end + end +end diff --git a/spec/tests/unit/worker_appraise_spec.rb b/spec/tests/unit/worker_appraise_spec.rb index 6ec557a..aff4764 100644 --- a/spec/tests/unit/worker_appraise_spec.rb +++ b/spec/tests/unit/worker_appraise_spec.rb @@ -90,18 +90,18 @@ end end -describe 'Unit test of AppraisalRequest' do - it 'should create AppraisalRequest struct' do +describe 'Unit test of Messaging::AppraisalJob' do + it 'should create AppraisalJob struct' do project = OpenStruct.new(name: 'test') - request = CodePraise::Response::AppraisalRequest.new(project, 'app/models', 'request-123') + job = CodePraise::Messaging::AppraisalJob.new(project, 'app/models', 'request-123') - _(request.project.name).must_equal 'test' - _(request.folder_path).must_equal 'app/models' - _(request.id).must_equal 'request-123' + _(job.project.name).must_equal 'test' + _(job.folder_path).must_equal 'app/models' + _(job.id).must_equal 'request-123' end end -describe 'Unit test of Representer::AppraisalRequest' do +describe 'Unit test of Representer::AppraisalJob' do before do @owner = CodePraise::Entity::Member.new( id: nil, @@ -121,11 +121,11 @@ contributors: [] ) - @request = CodePraise::Response::AppraisalRequest.new(@project, 'app/models', 'req-123') + @job = CodePraise::Messaging::AppraisalJob.new(@project, 'app/models', 'req-123') end it 'should serialize to JSON' do - json = CodePraise::Representer::AppraisalRequest.new(@request).to_json + json = CodePraise::Representer::AppraisalJob.new(@job).to_json parsed = JSON.parse(json) _(parsed['folder_path']).must_equal 'app/models' @@ -134,9 +134,9 @@ end it 'should deserialize from JSON' do - json = CodePraise::Representer::AppraisalRequest.new(@request).to_json + json = CodePraise::Representer::AppraisalJob.new(@job).to_json - deserialized = CodePraise::Representer::AppraisalRequest + deserialized = CodePraise::Representer::AppraisalJob .new(OpenStruct.new) .from_json(json) diff --git a/workers/application/requests/job_reporter.rb b/workers/application/requests/job_reporter.rb index 686ecd2..1a3b2ab 100644 --- a/workers/application/requests/job_reporter.rb +++ b/workers/application/requests/job_reporter.rb @@ -7,12 +7,14 @@ module Appraiser class JobReporter attr_reader :project, :folder_path - def initialize(request_json, config) - request = parse_request(request_json) - - @project = request.project - @folder_path = request.respond_to?(:folder_path) ? (request.folder_path || '') : '' - @publisher = ProgressPublisher.new(config, request.id) + def initialize(job_json, config) + job = CodePraise::Representer::AppraisalJob + .new(OpenStruct.new) + .from_json(job_json) + + @project = job.project + @folder_path = job.folder_path || '' + @publisher = ProgressPublisher.new(config, job.id) end def report(msg) @@ -30,24 +32,5 @@ def report_each_second(seconds, &operation) def progress_callback ->(percent) { report(percent.to_s) } end - - private - - # Parse request - handles both CloneRequest and AppraisalRequest formats - def parse_request(request_json) - parsed = JSON.parse(request_json) - - if parsed.key?('folder_path') - # New AppraisalRequest format - CodePraise::Representer::AppraisalRequest - .new(OpenStruct.new) - .from_json(request_json) - else - # Legacy CloneRequest format - CodePraise::Representer::CloneRequest - .new(OpenStruct.new) - .from_json(request_json) - end - end end end