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
25 changes: 25 additions & 0 deletions app/admin/plan_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ActiveAdmin.register CoPlan::PlanType, as: "PlanType" do
permit_params :name, :description, :template_content

index do
selectable_column
id_column
column :name
column :description
column :created_at
actions
end

show do
attributes_table do
row :id
row :name
row :description
row :default_tags
row :template_content
row :metadata
row :created_at
row :updated_at
end
end
end
6 changes: 6 additions & 0 deletions app/admin/plans.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
ActiveAdmin.register CoPlan::Plan, as: "Plan" do
permit_params :title, :status

filter :title
filter :status, as: :select, collection: CoPlan::Plan::STATUSES
filter :plan_type, as: :select
filter :created_at
filter :updated_at

index do
selectable_column
id_column
Expand Down
16 changes: 15 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,26 @@
puts " (Save this — it won't be shown again)"
end

puts "Seeding plan types..."
general = CoPlan::PlanType.find_or_create_by!(name: "General") do |pt|
pt.description = "General-purpose plan"
end
CoPlan::PlanType.find_or_create_by!(name: "RFC") do |pt|
pt.description = "Request for Comments — propose a significant change for team review"
pt.default_tags = ["rfc"]
end
CoPlan::PlanType.find_or_create_by!(name: "Design Doc") do |pt|
pt.description = "Technical design document for a new system or feature"
pt.default_tags = ["design"]
end
CoPlan::PlanType.find_or_create_by!(name: "ADR") do |pt|
pt.description = "Architecture Decision Record — document a key technical decision"
pt.default_tags = ["adr"]
end

# Backfill any plans without a plan type
CoPlan::Plan.where(plan_type_id: nil).update_all(plan_type_id: general.id)

puts "Seeding automated plan reviewers..."
CoPlan::AutomatedPlanReviewer.create_defaults

Expand Down
8 changes: 7 additions & 1 deletion engine/app/controllers/coplan/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class PlansController < BaseController

def index
plans = Plan
.includes(:plan_type, :created_by_user)
.where.not(status: "brainstorm")
.or(Plan.where(created_by_user: current_user))
.order(updated_at: :desc)
Expand All @@ -25,14 +26,17 @@ def create
plan = Plans::Create.call(
title: params[:title],
content: params[:content] || "",
user: current_user
user: current_user,
plan_type_id: params[:plan_type_id].presence
)
render json: plan_json(plan).merge(
current_content: plan.current_content,
current_revision: plan.current_revision
), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.message }, status: :unprocessable_content
rescue ActiveRecord::InvalidForeignKey
render json: { error: "Invalid plan_type_id" }, status: :unprocessable_content
end

def update
Expand Down Expand Up @@ -83,6 +87,8 @@ def plan_json(plan)
status: plan.status,
current_revision: plan.current_revision,
tags: plan.tags,
plan_type_id: plan.plan_type_id,
plan_type_name: plan.plan_type&.name,
created_by: plan.created_by_user.name,
created_at: plan.created_at,
updated_at: plan.updated_at
Expand Down
9 changes: 9 additions & 0 deletions engine/app/models/coplan/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Plan < ApplicationRecord

belongs_to :created_by_user, class_name: "CoPlan::User"
belongs_to :current_plan_version, class_name: "PlanVersion", optional: true
belongs_to :plan_type, optional: true
has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy
has_many :plan_collaborators, dependent: :destroy
has_many :collaborators, through: :plan_collaborators, source: :user
Expand All @@ -19,6 +20,14 @@ class Plan < ApplicationRecord
validates :title, presence: true
validates :status, presence: true, inclusion: { in: STATUSES }

def self.ransackable_attributes(auth_object = nil)
%w[id title status plan_type_id created_by_user_id current_plan_version_id current_revision created_at updated_at]
end

def self.ransackable_associations(auth_object = nil)
%w[plan_type created_by_user]
end

def to_param
id
end
Expand Down
18 changes: 18 additions & 0 deletions engine/app/models/coplan/plan_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module CoPlan
class PlanType < ApplicationRecord
has_many :plans, dependent: :nullify

after_initialize { self.default_tags ||= [] }
after_initialize { self.metadata ||= {} }

validates :name, presence: true, uniqueness: true

def self.ransackable_attributes(auth_object = nil)
%w[id name description template_content created_at updated_at]
end

def self.ransackable_associations(auth_object = nil)
%w[plans]
end
end
end
9 changes: 5 additions & 4 deletions engine/app/services/coplan/plans/create.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
module CoPlan
module Plans
class Create
def self.call(title:, content:, user:)
new(title:, content:, user:).call
def self.call(title:, content:, user:, plan_type_id: nil)
new(title:, content:, user:, plan_type_id:).call
end

def initialize(title:, content:, user:)
def initialize(title:, content:, user:, plan_type_id: nil)
@title = title
@content = content
@user = user
@plan_type_id = plan_type_id
end

def call
ActiveRecord::Base.transaction do
plan = Plan.create!(title: @title, created_by_user: @user)
plan = Plan.create!(title: @title, created_by_user: @user, plan_type_id: @plan_type_id)
version = PlanVersion.create!(
plan: plan,
revision: 1,
Expand Down
18 changes: 18 additions & 0 deletions engine/db/migrate/20260403000000_create_coplan_plan_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class CreateCoplanPlanTypes < ActiveRecord::Migration[8.1]
def change
create_table :coplan_plan_types, id: { type: :string, limit: 36 } do |t|
t.string :name, null: false
t.text :description
t.json :default_tags
t.text :template_content
t.json :metadata
t.timestamps
end

add_index :coplan_plan_types, :name, unique: true

add_column :coplan_plans, :plan_type_id, :string, limit: 36
add_index :coplan_plans, :plan_type_id
add_foreign_key :coplan_plans, :coplan_plan_types, column: :plan_type_id
end
end
22 changes: 22 additions & 0 deletions engine/db/migrate/20260403100000_seed_general_plan_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class SeedGeneralPlanType < ActiveRecord::Migration[8.1]
def up
general_id = SecureRandom.uuid_v7
execute <<~SQL
INSERT INTO coplan_plan_types (id, name, description, default_tags, template_content, metadata, created_at, updated_at)
VALUES (#{quote(general_id)}, 'General', 'General-purpose plan', '[]', NULL, '{}', NOW(), NOW())
SQL

execute <<~SQL
UPDATE coplan_plans SET plan_type_id = #{quote(general_id)} WHERE plan_type_id IS NULL
SQL
end

def down
general = execute("SELECT id FROM coplan_plan_types WHERE name = 'General' LIMIT 1")
if general.any?
general_id = general.first[0]
execute("UPDATE coplan_plans SET plan_type_id = NULL WHERE plan_type_id = #{quote(general_id)}")
execute("DELETE FROM coplan_plan_types WHERE id = #{quote(general_id)}")
end
end
end
2 changes: 2 additions & 0 deletions engine/lib/coplan/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Configuration
attr_accessor :onboarding_banner
attr_accessor :agent_auth_instructions
attr_accessor :agent_curl_prefix
attr_accessor :seed_plan_types

# Lambda for user search used by the /api/v1/users/search endpoint.
# Accepts a query string, returns an array of hashes with keys:
Expand All @@ -28,6 +29,7 @@ def initialize
@notification_handler = nil
@onboarding_banner = 'Want to upload Agentic plans? Give your agent <a href="/agent-instructions">these instructions</a>.'
@agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"'
@seed_plan_types = []
@agent_auth_instructions = <<~MARKDOWN
## Authentication

Expand Down
9 changes: 9 additions & 0 deletions spec/factories/plan_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FactoryBot.define do
factory :plan_type, class: "CoPlan::PlanType" do
sequence(:name) { |n| "Plan Type #{n}" }
description { "A plan type for testing" }
default_tags { [] }
template_content { "# Template\n\nDefault content." }
metadata { {} }
end
end
44 changes: 44 additions & 0 deletions spec/models/plan_type_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
require "rails_helper"

RSpec.describe CoPlan::PlanType, type: :model do
it "is valid with valid attributes" do
plan_type = create(:plan_type)
expect(plan_type).to be_valid
end

it "requires name" do
plan_type = build(:plan_type, name: nil)
expect(plan_type).not_to be_valid
expect(plan_type.errors[:name]).to include("can't be blank")
end

it "validates name uniqueness" do
create(:plan_type, name: "RFC")
duplicate = build(:plan_type, name: "RFC")
expect(duplicate).not_to be_valid
expect(duplicate.errors[:name]).to include("has already been taken")
end

it "defaults default_tags to empty array" do
plan_type = CoPlan::PlanType.new
expect(plan_type.default_tags).to eq([])
end

it "defaults metadata to empty hash" do
plan_type = CoPlan::PlanType.new
expect(plan_type.metadata).to eq({})
end

it "has many plans" do
plan_type = create(:plan_type)
plan = create(:plan, plan_type: plan_type)
expect(plan_type.plans).to include(plan)
end

it "nullifies plans when destroyed" do
plan_type = create(:plan_type)
plan = create(:plan, plan_type: plan_type)
plan_type.destroy!
expect(plan.reload.plan_type_id).to be_nil
end
end
16 changes: 16 additions & 0 deletions spec/requests/api/v1/plans_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@
expect(body["current_revision"]).to eq(1)
end

it "create with plan_type_id" do
plan_type = create(:plan_type)
post api_v1_plans_path, params: { title: "Typed Plan", content: "# Typed", plan_type_id: plan_type.id }, headers: headers, as: :json
expect(response).to have_http_status(:created)
body = JSON.parse(response.body)
expect(body["plan_type_id"]).to eq(plan_type.id)
expect(body["plan_type_name"]).to eq(plan_type.name)
end

it "create with invalid plan_type_id returns 422" do
post api_v1_plans_path, params: { title: "Bad Type", plan_type_id: "nonexistent-id" }, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_content)
body = JSON.parse(response.body)
expect(body["error"]).to include("plan_type_id")
end

it "create without title fails" do
post api_v1_plans_path, params: { content: "no title" }, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_content)
Expand Down
26 changes: 26 additions & 0 deletions spec/services/plans/create_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,30 @@
expect(version.actor_id).to eq(user.id)
expect(version.content_sha256).to be_present
end

it "creates plan with plan_type" do
user = create(:coplan_user)
plan_type = create(:plan_type)
plan = CoPlan::Plans::Create.call(
title: "Typed Plan",
content: "# Typed",
user: user,
plan_type_id: plan_type.id
)

expect(plan).to be_persisted
expect(plan.plan_type).to eq(plan_type)
end

it "creates plan without plan_type" do
user = create(:coplan_user)
plan = CoPlan::Plans::Create.call(
title: "Untyped Plan",
content: "# Untyped",
user: user
)

expect(plan).to be_persisted
expect(plan.plan_type_id).to be_nil
end
end
Loading