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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--require spec_helper
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,15 @@ A Rails app for managing engineering design doc review, purpose-built for AI-gen

## Testing

- **Minitest** — not RSpec
- Run the full suite: `bin/rails test`
- Test files mirror app structure: `test/models/`, `test/controllers/`, `test/services/`
- Use **fixtures** (YAML in `test/fixtures/`) — not factories
- Every model, service object, and controller action should have tests
- Fixtures use hardcoded UUIDs for stable cross-references
- **RSpec** with `rspec-rails`
- Run the full suite: `bundle exec rspec`
- Spec files mirror app structure: `spec/models/`, `spec/requests/`, `spec/services/`, `spec/helpers/`
- Use **FactoryBot** (`factory_bot_rails`) — factories live in `spec/factories/`
- Every model, service object, and controller action should have specs
- UUID primary keys are auto-assigned by `ApplicationRecord#assign_uuid` — do **not** set `id` in factories
- Factories derive associations from parent objects (e.g., `organization { plan.organization }`) to keep data consistent
- FactoryBot syntax methods (`create`, `build`) are included globally via `config.include FactoryBot::Syntax::Methods`
- `sign_in_as(user)` helper is defined in `spec/rails_helper.rb` for request specs

## Seeds

Expand Down
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ source "https://rubygems.org"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.1"
gem "minitest", "~> 5.25"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use mysql as the database for Active Record
Expand Down Expand Up @@ -73,6 +72,8 @@ group :development do
end

group :test do
gem "rspec-rails"
gem "factory_bot_rails"
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
Expand Down
34 changes: 33 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ GEM
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
diff-lcs (1.6.2)
diffy (3.4.4)
dotenv (3.2.0)
drb (2.2.3)
Expand All @@ -138,6 +139,11 @@ GEM
et-orbi (1.4.0)
tzinfo
event_stream_parser (1.0.0)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
Expand Down Expand Up @@ -345,6 +351,23 @@ GEM
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.4)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.3)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.7)
rubocop (1.84.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand Down Expand Up @@ -469,16 +492,17 @@ DEPENDENCIES
commonmarker
debug
diffy
factory_bot_rails
image_processing (~> 1.2)
importmap-rails
jbuilder
kamal
minitest (~> 5.25)
mysql2 (~> 0.5)
propshaft
puma (>= 5.0)
rack-attack
rails (~> 8.1.1)
rspec-rails
rubocop-rails-omakase
ruby-openai
selenium-webdriver
Expand Down Expand Up @@ -532,6 +556,7 @@ CHECKSUMS
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
diffy (3.4.4) sha256=79384ab5ca82d0e115b2771f0961e27c164c456074bd2ec46b637ebf7b6e47e3
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
Expand All @@ -540,6 +565,8 @@ CHECKSUMS
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8
factory_bot (6.5.6) sha256=12beb373214dccc086a7a63763d6718c49769d5606f0501e0a4442676917e077
factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68
faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c
Expand Down Expand Up @@ -628,6 +655,11 @@ CHECKSUMS
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c
rspec-rails (8.0.3) sha256=b0a440e7a10700317d898a014852e26660867298c4076dbc3baa99c768b79dc1
rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f
rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
Expand Down
35 changes: 35 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,41 @@ img, svg {
min-width: 200px;
}

/* Dropdown */
.dropdown {
position: relative;
display: inline-block;
}

.dropdown__menu {
position: absolute;
right: 0;
top: 100%;
margin-top: var(--space-xs);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
z-index: 100;
}

.dropdown__item {
display: block;
width: 100%;
padding: var(--space-sm) var(--space-md);
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: var(--font-sm);
color: var(--color-text);
}

.dropdown__item:hover {
background: var(--color-bg-muted);
}

/* Page header */
.page-header__subtitle {
color: var(--color-text-muted);
Expand Down
28 changes: 28 additions & 0 deletions app/controllers/automated_reviews_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class AutomatedReviewsController < ApplicationController
before_action :scope_to_organization
before_action :set_plan
before_action :set_reviewer, only: [:create]

def create
authorize!(@plan, :update?)

AutomatedReviewJob.perform_later(
plan_id: @plan.id,
reviewer_id: @reviewer.id,
plan_version_id: @plan.current_plan_version_id,
triggered_by: current_user
)

redirect_to plan_path(@plan), notice: "#{@reviewer.name} review queued."
end

private

def set_plan
@plan = @organization.plans.find(params[:plan_id])
end

def set_reviewer
@reviewer = @organization.automated_plan_reviewers.enabled.find(params[:reviewer_id])
end
end
3 changes: 3 additions & 0 deletions app/controllers/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ def update_status
new_status = params[:status]
if Plan::STATUSES.include?(new_status) && @plan.update(status: new_status)
broadcast_plan_update(@plan)
if @plan.saved_change_to_status?
Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: new_status, triggered_by: current_user)
end
redirect_to plan_path(@plan), notice: "Status updated to #{new_status}."
else
redirect_to plan_path(@plan), alert: "Invalid status."
Expand Down
25 changes: 25 additions & 0 deletions app/javascript/controllers/dropdown_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["menu"]

toggle() {
const menu = this.menuTarget
menu.style.display = menu.style.display === "none" ? "" : "none"
}

close(event) {
if (!this.element.contains(event.target)) {
this.menuTarget.style.display = "none"
}
}

connect() {
this._closeHandler = this.close.bind(this)
document.addEventListener("click", this._closeHandler)
}

disconnect() {
document.removeEventListener("click", this._closeHandler)
}
}
71 changes: 71 additions & 0 deletions app/jobs/automated_review_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
class AutomatedReviewJob < ApplicationJob
queue_as :default

discard_on AiProviders::OpenAi::Error
discard_on AiProviders::Anthropic::Error

def perform(plan_id:, reviewer_id:, plan_version_id:, triggered_by: nil)
plan = Plan.find(plan_id)
reviewer = AutomatedPlanReviewer.find(reviewer_id)
version = PlanVersion.find(plan_version_id)

return unless reviewer.enabled?

response = call_ai_provider(reviewer, version.content_markdown)
feedback_items = Plans::ReviewResponseParser.call(response, plan_content: version.content_markdown)

create_review_comments(plan, version, reviewer, feedback_items, triggered_by)
end

private

def call_ai_provider(reviewer, content)
system_prompt = Plans::ReviewPromptFormatter.call(reviewer_prompt: reviewer.prompt_text)
provider_class = resolve_provider(reviewer.ai_provider)
provider_class.call(
system_prompt: system_prompt,
user_content: content,
model: reviewer.ai_model
)
end

def resolve_provider(provider_name)
case provider_name
when "openai" then AiProviders::OpenAi
when "anthropic" then AiProviders::Anthropic
else raise ArgumentError, "Unknown AI provider: #{provider_name}"
end
end

def create_review_comments(plan, version, reviewer, feedback_items, triggered_by)
created_by = triggered_by || plan.created_by_user

feedback_items.each do |item|
thread = plan.comment_threads.create!(
organization: plan.organization,
plan_version: version,
created_by_user: created_by,
anchor_text: item[:anchor_text],
status: "open"
)

thread.comments.create!(
organization: plan.organization,
author_type: AutomatedPlanReviewer::ACTOR_TYPE,
author_id: reviewer.id,
body_markdown: item[:comment]
)

broadcast_new_thread(plan, thread)
end
end

def broadcast_new_thread(plan, thread)
Turbo::StreamsChannel.broadcast_prepend_to(
plan,
target: "comment-threads",
partial: "comment_threads/thread",
locals: { thread: thread, plan: plan }
)
end
end
19 changes: 19 additions & 0 deletions app/services/ai_providers/anthropic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module AiProviders
class Anthropic
def self.call(system_prompt:, user_content:, model: "claude-sonnet-4-20250514")
new(system_prompt:, user_content:, model:).call
end

def initialize(system_prompt:, user_content:, model:)
@system_prompt = system_prompt
@user_content = user_content
@model = model
end

def call
raise Error, "Anthropic provider not yet implemented. Use OpenAI for now."
end

class Error < StandardError; end
end
end
42 changes: 42 additions & 0 deletions app/services/ai_providers/open_ai.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module AiProviders
class OpenAi
def self.call(system_prompt:, user_content:, model: "gpt-4o")
new(system_prompt:, user_content:, model:).call
end

def initialize(system_prompt:, user_content:, model:)
@system_prompt = system_prompt
@user_content = user_content
@model = model
end

def call
client = OpenAI::Client.new(access_token: api_key)

response = client.chat(
parameters: {
model: @model,
messages: [
{ role: "system", content: @system_prompt },
{ role: "user", content: @user_content }
]
}
)

content = response.dig("choices", 0, "message", "content")
raise Error, "No response content from OpenAI" if content.blank?

content
end

private

def api_key
key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
raise Error, "OpenAI API key not configured" if key.blank?
key
end

class Error < StandardError; end
end
end
23 changes: 23 additions & 0 deletions app/services/plans/review_prompt_formatter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module Plans
class ReviewPromptFormatter
RESPONSE_FORMAT_INSTRUCTIONS = <<~INSTRUCTIONS.freeze
You MUST respond with a JSON array of feedback items. Each item is an object with two keys:
- "anchor_text": An exact substring copied verbatim from the plan document that this feedback applies to. Keep it short (a phrase or single sentence). Must match the plan text exactly. Use null for general feedback not tied to specific text.
- "comment": Your feedback in Markdown. Be concise and actionable.

Example response:
```json
[
{"anchor_text": "API tokens scoped to a user", "comment": "Consider adding token expiration by default. Long-lived tokens without expiry are a common security risk."},
{"anchor_text": null, "comment": "Overall the plan looks solid. One general concern: there's no mention of audit logging for administrative actions."}
]
```

Return ONLY the JSON array. No other text before or after it.
INSTRUCTIONS

def self.call(reviewer_prompt:)
"#{reviewer_prompt}\n\n#{RESPONSE_FORMAT_INSTRUCTIONS}"
end
end
end
Loading