From e648006f8e495f70ddc6a9dab32e00df4f1d1e51 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Wed, 15 Apr 2026 13:44:11 -0500 Subject: [PATCH 1/6] Add plan references with keyed identifiers Introduce a Reference model that connects plans to external resources (repos, PRs, docs) and to other plans. Key features: - References have an optional key for stable semantic identity (e.g. auth-repo, impl-pr) that agents and humans can use to identify what a reference represents - Auto-extraction from plan content: markdown links, bare URLs, and reference-style link definitions ([key]: url "title") are parsed on every version save - URL classification: GitHub repos, PRs, Google Docs, Notion, Confluence, other plans, and generic links - Cross-plan linking via target_plan_id for plan-type references - Full REST API: CRUD on /api/v1/plans/:id/references plus a search endpoint to find plans by referenced URL - Tabbed plan UI showing Content and References tabs - Agent instructions updated to document the references API and encourage keyed references Amp-Thread-ID: https://ampcode.com/threads/T-019d9222-fc6d-72ca-a6ec-c538f8ace722 Co-authored-by: Amp --- app/admin/references.rb | 32 ++++ ...182145_create_coplan_references.co_plan.rb | 22 +++ db/schema.rb | 34 +++- .../assets/stylesheets/coplan/application.css | 150 ++++++++++++++++++ .../coplan/api/v1/plans_controller.rb | 34 +++- .../coplan/api/v1/references_controller.rb | 98 ++++++++++++ .../coplan/application_controller.rb | 1 + .../controllers/coplan/plans_controller.rb | 1 + .../coplan/references_controller.rb | 43 +++++ .../app/helpers/coplan/references_helper.rb | 13 ++ .../controllers/coplan/tabs_controller.js | 18 +++ engine/app/models/coplan/plan.rb | 1 + engine/app/models/coplan/plan_version.rb | 5 + engine/app/models/coplan/reference.rb | 51 ++++++ .../coplan/references/extract_from_content.rb | 87 ++++++++++ .../coplan/agent_instructions/show.text.erb | 62 +++++++- .../views/coplan/plans/_references.html.erb | 50 ++++++ engine/app/views/coplan/plans/show.html.erb | 69 ++++---- engine/config/routes.rb | 5 + ...20260410000000_create_coplan_references.rb | 21 +++ spec/factories/references.rb | 13 ++ .../coplan/plan_version_references_spec.rb | 19 +++ spec/models/coplan/reference_spec.rb | 110 +++++++++++++ spec/requests/api/v1/references_spec.rb | 102 ++++++++++++ .../references/extract_from_content_spec.rb | 119 ++++++++++++++ 25 files changed, 1120 insertions(+), 40 deletions(-) create mode 100644 app/admin/references.rb create mode 100644 db/migrate/20260410182145_create_coplan_references.co_plan.rb create mode 100644 engine/app/controllers/coplan/api/v1/references_controller.rb create mode 100644 engine/app/controllers/coplan/references_controller.rb create mode 100644 engine/app/helpers/coplan/references_helper.rb create mode 100644 engine/app/javascript/controllers/coplan/tabs_controller.js create mode 100644 engine/app/models/coplan/reference.rb create mode 100644 engine/app/services/coplan/references/extract_from_content.rb create mode 100644 engine/app/views/coplan/plans/_references.html.erb create mode 100644 engine/db/migrate/20260410000000_create_coplan_references.rb create mode 100644 spec/factories/references.rb create mode 100644 spec/models/coplan/plan_version_references_spec.rb create mode 100644 spec/models/coplan/reference_spec.rb create mode 100644 spec/requests/api/v1/references_spec.rb create mode 100644 spec/services/coplan/references/extract_from_content_spec.rb diff --git a/app/admin/references.rb b/app/admin/references.rb new file mode 100644 index 0000000..f9d3402 --- /dev/null +++ b/app/admin/references.rb @@ -0,0 +1,32 @@ +ActiveAdmin.register CoPlan::Reference, as: "Reference" do + permit_params :plan_id, :key, :url, :title, :reference_type, :source, :target_plan_id + + index do + selectable_column + id_column + column :plan + column :key + column :url + column :title + column :reference_type + column :source + column :target_plan_id + column :created_at + actions + end + + show do + attributes_table do + row :id + row :plan + row :key + row :url + row :title + row :reference_type + row :source + row :target_plan_id + row :created_at + row :updated_at + end + end +end diff --git a/db/migrate/20260410182145_create_coplan_references.co_plan.rb b/db/migrate/20260410182145_create_coplan_references.co_plan.rb new file mode 100644 index 0000000..ff671c6 --- /dev/null +++ b/db/migrate/20260410182145_create_coplan_references.co_plan.rb @@ -0,0 +1,22 @@ +# This migration comes from co_plan (originally 20260410000000) +class CreateCoplanReferences < ActiveRecord::Migration[8.1] + def change + create_table :coplan_references, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :key + t.string :url, null: false + t.string :title + t.string :reference_type, null: false + t.string :source, null: false + t.string :target_plan_id, limit: 36 + t.timestamps + end + + add_index :coplan_references, [:plan_id, :key], unique: true + add_index :coplan_references, [:plan_id, :url], unique: true + add_index :coplan_references, :target_plan_id + add_index :coplan_references, :source + add_foreign_key :coplan_references, :coplan_plans, column: :plan_id + add_foreign_key :coplan_references, :coplan_plans, column: :target_plan_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 388165c..0850e46 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_06_200000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_10_182145) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -70,13 +70,13 @@ t.integer "start_line" t.string "status", default: "pending", null: false t.datetime "updated_at", null: false - t.index ["addressed_in_plan_version_id"], name: "fk_rails_e7003e0df7" - t.index ["created_by_user_id"], name: "fk_rails_88fb5e06ca" - t.index ["out_of_date_since_version_id"], name: "fk_rails_be37c1499d" + t.index ["addressed_in_plan_version_id"], name: "fk_rails_a77cc69a6e" + t.index ["created_by_user_id"], name: "fk_rails_34dfdd2aac" + t.index ["out_of_date_since_version_id"], name: "fk_rails_60a8d49098" t.index ["plan_id", "out_of_date"], name: "index_coplan_comment_threads_on_plan_id_and_out_of_date" t.index ["plan_id", "status"], name: "index_coplan_comment_threads_on_plan_id_and_status" - t.index ["plan_version_id"], name: "fk_rails_676660f283" - t.index ["resolved_by_user_id"], name: "fk_rails_8625e1eb43" + t.index ["plan_version_id"], name: "fk_rails_514df5a253" + t.index ["resolved_by_user_id"], name: "fk_rails_e5ed569cf1" end create_table "coplan_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -117,7 +117,7 @@ t.string "status", default: "open", null: false t.datetime "updated_at", null: false t.index ["plan_id", "status"], name: "index_coplan_edit_sessions_on_plan_id_and_status" - t.index ["plan_version_id"], name: "fk_rails_14c3f0737b" + t.index ["plan_version_id"], name: "fk_rails_55d7ec476a" end create_table "coplan_notifications", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -211,12 +211,28 @@ t.string "title", null: false t.datetime "updated_at", null: false t.index ["created_by_user_id"], name: "index_coplan_plans_on_created_by_user_id" - t.index ["current_plan_version_id"], name: "fk_rails_c401577583" + t.index ["current_plan_version_id"], name: "fk_rails_4193983681" t.index ["plan_type_id"], name: "index_coplan_plans_on_plan_type_id" t.index ["status"], name: "index_coplan_plans_on_status" t.index ["updated_at"], name: "index_coplan_plans_on_updated_at" end + create_table "coplan_references", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key" + t.string "plan_id", limit: 36, null: false + t.string "reference_type", null: false + t.string "source", null: false + t.string "target_plan_id", limit: 36 + t.string "title" + t.datetime "updated_at", null: false + t.string "url", null: false + t.index ["plan_id", "key"], name: "index_coplan_references_on_plan_id_and_key", unique: true + t.index ["plan_id", "url"], name: "index_coplan_references_on_plan_id_and_url", unique: true + t.index ["source"], name: "index_coplan_references_on_source" + t.index ["target_plan_id"], name: "index_coplan_references_on_target_plan_id" + end + create_table "coplan_tags", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "name", null: false @@ -269,4 +285,6 @@ add_foreign_key "coplan_plans", "coplan_plan_types", column: "plan_type_id" add_foreign_key "coplan_plans", "coplan_plan_versions", column: "current_plan_version_id" add_foreign_key "coplan_plans", "coplan_users", column: "created_by_user_id" + add_foreign_key "coplan_references", "coplan_plans", column: "plan_id" + add_foreign_key "coplan_references", "coplan_plans", column: "target_plan_id" end diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 33235a0..9aae993 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1872,6 +1872,156 @@ body:has(.comment-toolbar) .main-content { margin-top: var(--space-sm); } +/* Plan tabs */ +.plan-tabs__nav { + display: flex; + gap: var(--space-xs); + margin-bottom: calc(-1px); + position: relative; + z-index: 1; +} + +.plan-tabs__tab { + padding: var(--space-sm) var(--space-lg); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-muted); + text-decoration: none; + border: 1px solid transparent; + border-bottom: none; + border-radius: var(--radius) var(--radius) 0 0; + transition: color 0.15s, background 0.15s; + cursor: pointer; +} + +.plan-tabs__tab:hover { + color: var(--color-text); + background: var(--color-bg); + text-decoration: none; +} + +.plan-tabs__tab--active { + color: var(--color-text); + background: var(--color-surface); + border-color: var(--color-border); + font-weight: 600; +} + +.plan-tabs__count { + font-size: 0.75rem; + color: var(--color-text-muted); + font-weight: 400; +} + +.plan-tabs__panel--hidden { + display: none; +} + +/* References section */ +.references-section { + padding: var(--space-lg); +} + +.references-list { + list-style: none; + padding: 0; + margin: 0; +} + +.references-list__item { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm) 0; + border-bottom: 1px solid var(--color-border); +} + +.references-list__item:last-child { + border-bottom: none; +} + +.references-list__icon { + flex-shrink: 0; + font-size: var(--text-lg); + line-height: 1.4; +} + +.references-list__content { + flex: 1; + min-width: 0; +} + +.references-list__link { + display: block; + word-break: break-all; + font-size: var(--text-sm); +} + +.references-list__meta { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-top: 2px; +} + +.references-list__source { + font-size: 0.75rem; + color: var(--color-text-muted); + font-style: italic; +} + +.badge--ref-type { + font-size: 0.7rem; + padding: 1px 6px; + border-radius: 4px; + background: var(--color-bg); + color: var(--color-text-muted); + border: 1px solid var(--color-border); +} + +.badge--ref-key { + font-size: 0.7rem; + padding: 1px 6px; + border-radius: 4px; + background: var(--color-surface); + color: var(--color-primary); + border: 1px solid var(--color-primary); + font-family: var(--font-mono, monospace); +} + +.references-list__remove { + flex-shrink: 0; + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: 2px 6px; + font-size: var(--text-sm); + border-radius: var(--radius); + transition: color 0.15s, background 0.15s; +} + +.references-list__remove:hover { + color: var(--color-danger); + background: var(--color-bg); +} + +.references-add { + margin-top: var(--space-md); +} + +.references-add summary { + cursor: pointer; +} + +.references-add__form { + margin-top: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-width: 400px; +} + /* Hide sidebar on small screens */ @media (max-width: 1024px) { .content-nav { diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index fe472f7..be3aea0 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -18,7 +18,18 @@ def index def show render json: plan_json(@plan).merge( current_content: @plan.current_content, - current_revision: @plan.current_revision + current_revision: @plan.current_revision, + references: @plan.references.map { |r| + { + id: r.id, + key: r.key, + url: r.url, + title: r.title, + reference_type: r.reference_type, + source: r.source, + target_plan_id: r.target_plan_id + } + } ) end @@ -39,6 +50,17 @@ def create user: current_user, plan_type_id: plan_type&.id ) + + if params[:references].is_a?(Array) + params[:references].each do |ref_params| + next unless ref_params[:url].present? + ref_type = ref_params[:reference_type].presence || Reference.classify_url(ref_params[:url]) + ref = plan.references.find_or_initialize_by(url: ref_params[:url]) + ref.assign_attributes(key: ref_params[:key], title: ref_params[:title], reference_type: ref_type, source: "explicit") + ref.save! + end + end + render json: plan_json(plan).merge( current_content: plan.current_content, current_revision: plan.current_revision @@ -68,6 +90,16 @@ def update Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: permitted[:status], triggered_by: current_user) end + if params[:references].is_a?(Array) + params[:references].each do |ref_params| + next unless ref_params[:url].present? + ref_type = ref_params[:reference_type].presence || Reference.classify_url(ref_params[:url]) + ref = @plan.references.find_or_initialize_by(url: ref_params[:url]) + ref.assign_attributes(key: ref_params[:key], title: ref_params[:title], reference_type: ref_type, source: "explicit") + ref.save! + end + end + render json: plan_json(@plan).merge( current_content: @plan.current_content, current_revision: @plan.current_revision diff --git a/engine/app/controllers/coplan/api/v1/references_controller.rb b/engine/app/controllers/coplan/api/v1/references_controller.rb new file mode 100644 index 0000000..5edb5b5 --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/references_controller.rb @@ -0,0 +1,98 @@ +module CoPlan + module Api + module V1 + class ReferencesController < BaseController + before_action :set_plan, only: [:index, :create, :destroy] + before_action :authorize_plan_access!, only: [:index, :create, :destroy] + before_action :authorize_plan_write!, only: [:create, :destroy] + + def index + references = @plan.references.order(created_at: :desc) + references = references.where(reference_type: params[:type]) if params[:type].present? + render json: references.map { |r| reference_json(r) } + end + + def create + ref_type = params[:reference_type].presence || Reference.classify_url(params[:url]) + target_plan_id = nil + if ref_type == "plan" + candidate_id = Reference.extract_target_plan_id(params[:url]) + target_plan_id = candidate_id if candidate_id && candidate_id != @plan.id && Plan.exists?(candidate_id) + end + + ref = @plan.references.find_or_initialize_by(url: params[:url]) + ref.assign_attributes( + key: params[:key], + title: params[:title], + reference_type: ref_type, + source: "explicit", + target_plan_id: target_plan_id || params[:target_plan_id] + ) + ref.save! + + render json: reference_json(ref), status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_content + end + + def destroy + ref = @plan.references.find_by(id: params[:id]) + unless ref + render json: { error: "Reference not found" }, status: :not_found + return + end + + ref.destroy! + head :no_content + end + + def search + url = params[:url] + unless url.present? + render json: { error: "url parameter is required" }, status: :unprocessable_content + return + end + + visible_plans = Plan.where.not(status: "brainstorm") + .or(Plan.where(created_by_user: current_user)) + + references = Reference.where(url: url, plan_id: visible_plans.select(:id)) + .includes(:plan) + .order(created_at: :desc) + + render json: references.map { |r| + reference_json(r).merge( + plan_id: r.plan_id, + plan_title: r.plan.title, + plan_status: r.plan.status + ) + } + end + + private + + def authorize_plan_write! + return unless @plan + policy = CoPlan::PlanPolicy.new(current_user, @plan) + unless policy.update? + render json: { error: "Not authorized" }, status: :forbidden + end + end + + def reference_json(ref) + { + id: ref.id, + key: ref.key, + url: ref.url, + title: ref.title, + reference_type: ref.reference_type, + source: ref.source, + target_plan_id: ref.target_plan_id, + created_at: ref.created_at, + updated_at: ref.updated_at + } + end + end + end + end +end diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index 6a15fd1..1031c09 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -10,6 +10,7 @@ def self.controller_path helper CoPlan::ApplicationHelper helper CoPlan::MarkdownHelper helper CoPlan::CommentsHelper + helper CoPlan::ReferencesHelper # Skip host auth — CoPlan handles authentication internally via config.authenticate skip_before_action :authenticate_user!, raise: false diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index a2e8241..aad921d 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -26,6 +26,7 @@ def index def show authorize!(@plan, :show?) @threads = @plan.comment_threads.includes(:comments, :created_by_user).order(:created_at) + @references = @plan.references.order(reference_type: :asc, created_at: :desc) PlanViewer.track(plan: @plan, user: current_user) end diff --git a/engine/app/controllers/coplan/references_controller.rb b/engine/app/controllers/coplan/references_controller.rb new file mode 100644 index 0000000..b94f2ca --- /dev/null +++ b/engine/app/controllers/coplan/references_controller.rb @@ -0,0 +1,43 @@ +module CoPlan + class ReferencesController < ApplicationController + before_action :set_plan + + def create + authorize!(@plan, :update?) + + url = params[:reference][:url] + ref_type = Reference.classify_url(url) + target_plan_id = nil + if ref_type == "plan" + candidate_id = Reference.extract_target_plan_id(url) + target_plan_id = candidate_id if candidate_id && candidate_id != @plan.id && Plan.exists?(candidate_id) + end + + @plan.references.find_or_create_by!(url: url) do |r| + r.key = params[:reference][:key].presence + r.title = params[:reference][:title].presence + r.reference_type = ref_type + r.source = "explicit" + r.target_plan_id = target_plan_id + end + + redirect_to plan_path(@plan), notice: "Reference added." + rescue ActiveRecord::RecordInvalid => e + redirect_to plan_path(@plan), alert: e.message + end + + def destroy + authorize!(@plan, :update?) + + ref = @plan.references.find(params[:id]) + ref.destroy! + redirect_to plan_path(@plan), notice: "Reference removed." + end + + private + + def set_plan + @plan = Plan.find(params[:plan_id]) + end + end +end diff --git a/engine/app/helpers/coplan/references_helper.rb b/engine/app/helpers/coplan/references_helper.rb new file mode 100644 index 0000000..7bbed8a --- /dev/null +++ b/engine/app/helpers/coplan/references_helper.rb @@ -0,0 +1,13 @@ +module CoPlan + module ReferencesHelper + def reference_icon(reference_type) + case reference_type + when "plan" then "📋" + when "repository" then "📦" + when "pull_request" then "🔀" + when "document" then "📄" + else "🔗" + end + end + end +end diff --git a/engine/app/javascript/controllers/coplan/tabs_controller.js b/engine/app/javascript/controllers/coplan/tabs_controller.js new file mode 100644 index 0000000..f33336f --- /dev/null +++ b/engine/app/javascript/controllers/coplan/tabs_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["tab", "panel"] + + switch(event) { + event.preventDefault() + const targetId = event.currentTarget.getAttribute("href").replace("#", "") + + this.tabTargets.forEach(tab => { + tab.classList.toggle("plan-tabs__tab--active", tab.getAttribute("href") === `#${targetId}`) + }) + + this.panelTargets.forEach(panel => { + panel.classList.toggle("plan-tabs__panel--hidden", panel.id !== targetId) + }) + } +} diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index e45b5cf..79e1dbe 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -15,6 +15,7 @@ class Plan < ApplicationRecord has_many :tags, through: :plan_tags, source: :tag has_many :plan_viewers, dependent: :destroy has_many :notifications, dependent: :destroy + has_many :references, dependent: :destroy after_initialize { self.metadata ||= {} } diff --git a/engine/app/models/coplan/plan_version.rb b/engine/app/models/coplan/plan_version.rb index ccd341d..9afe521 100644 --- a/engine/app/models/coplan/plan_version.rb +++ b/engine/app/models/coplan/plan_version.rb @@ -13,9 +13,14 @@ class PlanVersion < ApplicationRecord validates :actor_type, presence: true, inclusion: { in: ACTOR_TYPES } before_validation :compute_sha256, if: -> { content_markdown.present? && content_sha256.blank? } + after_create_commit :extract_references private + def extract_references + CoPlan::References::ExtractFromContent.call(plan: plan, content: content_markdown) + end + def compute_sha256 self.content_sha256 = Digest::SHA256.hexdigest(content_markdown) end diff --git a/engine/app/models/coplan/reference.rb b/engine/app/models/coplan/reference.rb new file mode 100644 index 0000000..69ed100 --- /dev/null +++ b/engine/app/models/coplan/reference.rb @@ -0,0 +1,51 @@ +module CoPlan + class Reference < ApplicationRecord + SOURCES = %w[extracted explicit].freeze + REFERENCE_TYPES = %w[plan repository pull_request document link].freeze + + belongs_to :plan + belongs_to :target_plan, class_name: "CoPlan::Plan", optional: true + + validates :url, presence: true, uniqueness: { scope: :plan_id }, format: { with: /\Ahttps?:\/\//i, message: "must start with http:// or https://" } + validates :key, uniqueness: { scope: :plan_id }, allow_nil: true, + format: { with: /\A[a-z0-9][a-z0-9_-]*\z/, message: "must be lowercase alphanumeric with hyphens/underscores" }, length: { maximum: 64 } + validates :reference_type, presence: true, inclusion: { in: REFERENCE_TYPES } + validates :source, presence: true, inclusion: { in: SOURCES } + + scope :extracted, -> { where(source: "extracted") } + scope :explicit, -> { where(source: "explicit") } + + def self.classify_url(url) + case url + when %r{\Ahttps?://github\.com/[^/]+/[^/]+/pull/\d+} + "pull_request" + when %r{\Ahttps?://github\.com/[^/]+/[^/]+/?(\z|#|\?|/tree/|/blob/|/commit/)} + "repository" + when %r{/plans/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}} + "plan" + when %r{\Ahttps?://docs\.google\.com/}, %r{\Ahttps?://drive\.google\.com/} + "document" + when %r{\Ahttps?://[^/]*notion\.(so|site)/} + "document" + when %r{\Ahttps?://[^/]*confluence[^/]*/} + "document" + else + "link" + end + end + + def self.extract_target_plan_id(url) + return nil if url.blank? + match = url.match(%r{/plans/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})}) + match&.[](1) + end + + def self.ransackable_attributes(auth_object = nil) + %w[id plan_id key url title reference_type source target_plan_id created_at updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[plan target_plan] + end + end +end diff --git a/engine/app/services/coplan/references/extract_from_content.rb b/engine/app/services/coplan/references/extract_from_content.rb new file mode 100644 index 0000000..062a220 --- /dev/null +++ b/engine/app/services/coplan/references/extract_from_content.rb @@ -0,0 +1,87 @@ +module CoPlan + module References + class ExtractFromContent + def self.call(plan:, content: nil) + new(plan:, content:).call + end + + def initialize(plan:, content: nil) + @plan = plan + @content = content + end + + def call + content = @content || @plan.current_content + return remove_all_extracted if content.blank? + + found_urls = extract_urls(content) + + # Remove extracted references for URLs no longer in content + @plan.references.extracted.where.not(url: found_urls.keys).delete_all + + # Batch-check plan existence for plan-type references + candidate_plan_ids = found_urls.keys + .select { |url| Reference.classify_url(url) == "plan" } + .filter_map { |url| Reference.extract_target_plan_id(url) } + .reject { |id| id == @plan.id } + existing_plan_ids = candidate_plan_ids.any? ? Plan.where(id: candidate_plan_ids).pluck(:id).to_set : Set.new + + # Create or update references for found URLs + found_urls.each do |url, meta| + ref_type = Reference.classify_url(url) + target_plan_id = nil + if ref_type == "plan" + candidate_id = Reference.extract_target_plan_id(url) + target_plan_id = candidate_id if candidate_id && existing_plan_ids.include?(candidate_id) + end + + ref = @plan.references.find_or_initialize_by(url: url) + # Don't overwrite explicit references + next if ref.persisted? && ref.source == "explicit" + + ref.assign_attributes( + key: meta[:key].presence || ref.key, + title: meta[:title].presence || ref.title, + reference_type: ref_type, + source: "extracted", + target_plan_id: target_plan_id + ) + ref.save! + end + end + + private + + def remove_all_extracted + @plan.references.extracted.delete_all + end + + def extract_urls(content) + urls = {} # url => { title:, key: } + + # Match markdown reference-style link definitions: [key]: url "optional title" + content.scan(/^\[([^\]]+)\]:\s+(https?:\/\/\S+)(?:\s+"([^"]*)")?/m) do |key, url, title| + url = url.strip + k = key.strip.downcase.gsub(/[^a-z0-9_-]/, "-").gsub(/-+/, "-").truncate(64, omission: "") + urls[url] ||= { title: title&.strip, key: k } + end + + # Match markdown inline links: [title](url) + content.scan(/\[([^\]]*)\]\(([^)]+)\)/) do |title, url| + url = url.strip + next unless url.match?(%r{\Ahttps?://}) + urls[url] ||= { title: title.strip, key: nil } + end + + # Match bare URLs that aren't already inside markdown link syntax + stripped = content.gsub(/\[([^\]]*)\]\(([^)]+)\)/, "").gsub(/^\[([^\]]+)\]:\s+\S+.*$/m, "") + stripped.scan(%r{https?://[^\s<>\]\)]+}) do |url| + url = url.chomp(".").chomp(",").chomp(")").chomp(";") + urls[url] ||= { title: nil, key: nil } + end + + urls + end + end + end +end diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index e8201c8..d2c663c 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -22,7 +22,7 @@ Optional query param: `?status=considering` "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`. +Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`, `references` (array of linked resources). ### Create Plan @@ -46,7 +46,7 @@ Update plan metadata (title, status, tags). Only fields included in the request "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -Allowed fields: `title` (string), `status` (string), `tags` (array of strings). +Allowed fields: `title` (string), `status` (string), `tags` (array of strings), `references` (array — see [References](#references) below). ### List Tags @@ -99,6 +99,64 @@ Plan types categorize plans and provide default tags. When creating a plan, pass No plan types are currently configured. <% end %> +### References + +References connect plans to external resources (repos, PRs, docs) and to other plans. **Always add references** when your plan mentions GitHub repos, PRs, design docs, or other plans — they make plans discoverable and show the connective tissue between work. + +Links in plan content are **auto-extracted** as references. You can also add explicit references via the API. + +**Add references on create or update:** + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"title": "My Plan", "content": "...", "references": [{"url": "https://github.com/org/repo", "key": "main-repo", "title": "Main Repo"}, {"url": "https://github.com/org/repo/pull/42", "key": "impl-pr", "title": "Implementation PR"}]}' \ + "<%= @base %>/api/v1/plans" | jq . +``` + +Each reference object takes: `url` (required), `key` (optional — a short identifier like `auth-repo` or `impl-pr`), `title` (optional). The `reference_type` is auto-classified from the URL (repository, pull_request, plan, document, link). + +Keys let you give references semantic meaning that persists across edits. Use lowercase alphanumeric characters, hyphens, and underscores (e.g., `auth-service`, `main_repo`, `impl-pr`). Keys must be unique per plan. You can also define keys using markdown reference-style links in your content: + +``` +See the [auth-repo] for implementation details. + +[auth-repo]: https://github.com/org/auth-service "Auth Service" +``` + +These are auto-extracted with both the key and title preserved. + +**List references for a plan:** + +```bash +<%= @curl %> \ + "<%= @base %>/api/v1/plans/$PLAN_ID/references" | jq . +``` + +**Add a reference to an existing plan:** + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"url": "https://github.com/org/repo", "key": "service-repo", "title": "Service Repo"}' \ + "<%= @base %>/api/v1/plans/$PLAN_ID/references" | jq . +``` + +**Find plans that reference a URL:** + +```bash +<%= @curl %> \ + "<%= @base %>/api/v1/references/search?url=https://github.com/org/repo" | jq . +``` + +**Guidelines:** +- **Always set a `key`** when adding explicit references — it gives the reference a stable semantic identity (e.g., `auth-repo`, `impl-pr`, `design-doc`). +- Add references to any GitHub repos, PRs, design docs, or related plans mentioned in your plan. +- Links in plan content are auto-extracted — you don't need to add those manually. +- Use explicit references for resources not linked in the content but still relevant. +- Reference titles should be human-readable (e.g., "Auth Service Repo", not the raw URL). +- Use markdown reference-style links (`[key]: url "title"`) in content to auto-extract keyed references. + ### Get Versions ```bash diff --git a/engine/app/views/coplan/plans/_references.html.erb b/engine/app/views/coplan/plans/_references.html.erb new file mode 100644 index 0000000..2157ad0 --- /dev/null +++ b/engine/app/views/coplan/plans/_references.html.erb @@ -0,0 +1,50 @@ +
+ <% if references.any? %> +
    + <% references.group_by(&:reference_type).each do |type, refs| %> + <% refs.each do |ref| %> +
  • + <%= reference_icon(type) %> +
    + + <%= ref.title.presence || truncate(ref.url, length: 80) %> + + + <% if ref.key.present? %> + <%= ref.key %> + <% end %> + <%= type.humanize %> + <% if ref.source == "extracted" %> + auto-extracted + <% end %> + +
    + <% if ref.source == "explicit" %> + <%= button_to "✕", plan_reference_path(plan, ref), method: :delete, class: "references-list__remove", title: "Remove reference", data: { turbo_confirm: "Remove this reference?" } %> + <% end %> +
  • + <% end %> + <% end %> +
+ <% else %> +
+

No references yet. Links in the plan content are auto-extracted, or add one manually below.

+
+ <% end %> + +
+ + Add Reference + <%= form_with url: plan_references_path(plan), method: :post, class: "references-add__form" do |f| %> +
+ <%= f.text_field :url, placeholder: "https://...", required: true, class: "form-input", name: "reference[url]" %> +
+
+ <%= f.text_field :title, placeholder: "Title (optional)", class: "form-input", name: "reference[title]" %> +
+
+ <%= f.text_field :key, placeholder: "Key (optional, e.g. auth-repo)", class: "form-input", name: "reference[key]" %> +
+ <%= f.submit "Add", class: "btn btn--primary btn--sm" %> + <% end %> +
+
diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 28ccdfe..73448df 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -15,41 +15,52 @@ <%= render partial: "coplan/plans/header", locals: { plan: @plan } %> -
- <% if @plan.current_content.present? %> -
- - +
+ + +
+ <% if @plan.current_content.present? %> +
+ + -
- <%= render_markdown(@plan.current_content) %> +
+ <%= render_markdown(@plan.current_content) %> - - <%= render partial: "coplan/comment_threads/new_comment_form", locals: { plan: @plan } %> +
+ <% @threads.select(&:anchored?).each do |thread| %> + <%= render partial: "coplan/comment_threads/thread_popover", locals: { thread: thread, plan: @plan, current_user: current_user } %> + <% end %> +
- -
- <% @threads.select(&:anchored?).each do |thread| %> - <%= render partial: "coplan/comment_threads/thread_popover", locals: { thread: thread, plan: @plan, current_user: current_user } %> - <% end %> + <% else %> +
+

This plan has no content yet.

-
- <% else %> -
-

This plan has no content yet.

-
- <% end %> + <% end %> +
+ +
+ <%= render partial: "coplan/plans/references", locals: { references: @references, plan: @plan } %> +
<% open_count = @threads.count(&:open?) %> diff --git a/engine/config/routes.rb b/engine/config/routes.rb index ae6fb3e..be03e39 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -3,6 +3,7 @@ patch :update_status, on: :member patch :toggle_checkbox, on: :member resources :versions, controller: "plan_versions", only: [:index, :show] + resources :references, controller: "references", only: [:create, :destroy] resources :automated_reviews, only: [:create] resources :comment_threads, only: [:create] do member do @@ -39,6 +40,10 @@ patch :resolve, on: :member patch :discard, on: :member end + resources :references, only: [:index, :create, :destroy] + end + resources :references, only: [] do + get :search, on: :collection end end end diff --git a/engine/db/migrate/20260410000000_create_coplan_references.rb b/engine/db/migrate/20260410000000_create_coplan_references.rb new file mode 100644 index 0000000..6497a35 --- /dev/null +++ b/engine/db/migrate/20260410000000_create_coplan_references.rb @@ -0,0 +1,21 @@ +class CreateCoplanReferences < ActiveRecord::Migration[8.1] + def change + create_table :coplan_references, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :key + t.string :url, null: false + t.string :title + t.string :reference_type, null: false + t.string :source, null: false + t.string :target_plan_id, limit: 36 + t.timestamps + end + + add_index :coplan_references, [:plan_id, :key], unique: true + add_index :coplan_references, [:plan_id, :url], unique: true + add_index :coplan_references, :target_plan_id + add_index :coplan_references, :source + add_foreign_key :coplan_references, :coplan_plans, column: :plan_id + add_foreign_key :coplan_references, :coplan_plans, column: :target_plan_id + end +end diff --git a/spec/factories/references.rb b/spec/factories/references.rb new file mode 100644 index 0000000..e29b9b4 --- /dev/null +++ b/spec/factories/references.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :reference, class: "CoPlan::Reference" do + plan + sequence(:url) { |n| "https://example.com/page-#{n}" } + title { "Example Reference" } + reference_type { "link" } + source { "extracted" } + + trait :extracted do + source { "extracted" } + end + end +end diff --git a/spec/models/coplan/plan_version_references_spec.rb b/spec/models/coplan/plan_version_references_spec.rb new file mode 100644 index 0000000..cb4325a --- /dev/null +++ b/spec/models/coplan/plan_version_references_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe "PlanVersion reference extraction", type: :model do + let(:user) { create(:coplan_user) } + let(:plan) { create(:plan, created_by_user: user) } + + it "extracts references when a new version is created" do + CoPlan::PlanVersion.create!( + plan: plan, + revision: plan.current_revision + 1, + content_markdown: "See [Rails](https://rubyonrails.org) for details.", + actor_type: "human", + actor_id: user.id + ) + + expect(plan.references.count).to eq(1) + expect(plan.references.first.url).to eq("https://rubyonrails.org") + end +end diff --git a/spec/models/coplan/reference_spec.rb b/spec/models/coplan/reference_spec.rb new file mode 100644 index 0000000..41e2d7c --- /dev/null +++ b/spec/models/coplan/reference_spec.rb @@ -0,0 +1,110 @@ +require "rails_helper" + +RSpec.describe CoPlan::Reference, type: :model do + describe "validations" do + let(:plan) { create(:plan) } + + it "requires url" do + ref = build(:reference, plan: plan, url: nil) + expect(ref).not_to be_valid + expect(ref.errors[:url]).to include("can't be blank") + end + + it "requires reference_type" do + ref = build(:reference, plan: plan, reference_type: nil) + expect(ref).not_to be_valid + end + + it "requires source" do + ref = build(:reference, plan: plan, source: nil) + expect(ref).not_to be_valid + end + + it "validates reference_type inclusion" do + ref = build(:reference, plan: plan, reference_type: "invalid") + expect(ref).not_to be_valid + end + + it "validates source inclusion" do + ref = build(:reference, plan: plan, source: "invalid") + expect(ref).not_to be_valid + end + + it "enforces uniqueness of url per plan" do + create(:reference, plan: plan, url: "https://example.com") + ref = build(:reference, plan: plan, url: "https://example.com") + expect(ref).not_to be_valid + end + + it "allows same url on different plans" do + other_plan = create(:plan) + create(:reference, plan: plan, url: "https://example.com") + ref = build(:reference, plan: other_plan, url: "https://example.com") + expect(ref).to be_valid + end + end + + describe ".classify_url" do + it "classifies GitHub PR URLs" do + expect(described_class.classify_url("https://github.com/org/repo/pull/123")).to eq("pull_request") + end + + it "classifies GitHub repo URLs" do + expect(described_class.classify_url("https://github.com/org/repo")).to eq("repository") + expect(described_class.classify_url("https://github.com/org/repo/")).to eq("repository") + expect(described_class.classify_url("https://github.com/org/repo/tree/main")).to eq("repository") + expect(described_class.classify_url("https://github.com/org/repo/blob/main/file.rb")).to eq("repository") + end + + it "classifies CoPlan plan URLs" do + expect(described_class.classify_url("https://coplan.example.com/plans/019d54a7-ea13-72d5-bc54-fc44cb9b939a")).to eq("plan") + end + + it "classifies Google Docs URLs" do + expect(described_class.classify_url("https://docs.google.com/document/d/abc123")).to eq("document") + expect(described_class.classify_url("https://drive.google.com/file/d/abc123")).to eq("document") + end + + it "classifies Notion URLs" do + expect(described_class.classify_url("https://www.notion.so/page-abc123")).to eq("document") + expect(described_class.classify_url("https://team.notion.site/page-abc123")).to eq("document") + end + + it "classifies Confluence URLs" do + expect(described_class.classify_url("https://wiki.confluence.example.com/display/TEAM/Page")).to eq("document") + end + + it "defaults to link for unknown URLs" do + expect(described_class.classify_url("https://example.com/something")).to eq("link") + end + end + + describe ".extract_target_plan_id" do + it "extracts UUID from plan URLs" do + url = "https://coplan.example.com/plans/019d54a7-ea13-72d5-bc54-fc44cb9b939a" + expect(described_class.extract_target_plan_id(url)).to eq("019d54a7-ea13-72d5-bc54-fc44cb9b939a") + end + + it "returns nil for non-plan URLs" do + expect(described_class.extract_target_plan_id("https://example.com")).to be_nil + end + end + + describe "scopes" do + let(:plan) { create(:plan) } + + it ".extracted returns only extracted references" do + extracted = create(:reference, :extracted, plan: plan, url: "https://a.com") + create(:reference, plan: plan, url: "https://b.com", source: "explicit") + + expect(described_class.extracted).to eq([extracted]) + end + + it ".explicit returns only explicit references" do + create(:reference, :extracted, plan: plan, url: "https://a.com") + explicit = create(:reference, plan: plan, url: "https://b.com", source: "explicit") + + expect(described_class.explicit).to eq([explicit]) + end + end +end diff --git a/spec/requests/api/v1/references_spec.rb b/spec/requests/api/v1/references_spec.rb new file mode 100644 index 0000000..0ea51d3 --- /dev/null +++ b/spec/requests/api/v1/references_spec.rb @@ -0,0 +1,102 @@ +require "rails_helper" + +RSpec.describe "Api::V1::References", type: :request do + let(:user) { create(:coplan_user) } + let(:token) { create(:api_token, user: user, raw_token: "test-token-refs") } + let(:headers) { { "Authorization" => "Bearer test-token-refs", "Content-Type" => "application/json" } } + let(:plan) { create(:plan, :considering, created_by_user: user) } + + before { token } + + describe "GET /api/v1/plans/:plan_id/references" do + it "lists references for a plan" do + create(:reference, plan: plan, url: "https://github.com/org/repo", reference_type: "repository") + create(:reference, plan: plan, url: "https://example.com", reference_type: "link") + + get api_v1_plan_references_path(plan), headers: headers + expect(response).to have_http_status(:ok) + + data = JSON.parse(response.body) + expect(data.length).to eq(2) + expect(data.first).to include("url", "reference_type", "source") + end + + it "filters by type" do + create(:reference, plan: plan, url: "https://github.com/org/repo", reference_type: "repository") + create(:reference, plan: plan, url: "https://example.com", reference_type: "link") + + get api_v1_plan_references_path(plan), params: { type: "repository" }, headers: headers + data = JSON.parse(response.body) + expect(data.length).to eq(1) + expect(data.first["reference_type"]).to eq("repository") + end + end + + describe "POST /api/v1/plans/:plan_id/references" do + it "creates an explicit reference" do + post api_v1_plan_references_path(plan), + params: { url: "https://github.com/org/repo", title: "My Repo" }.to_json, + headers: headers + + expect(response).to have_http_status(:created) + data = JSON.parse(response.body) + expect(data["url"]).to eq("https://github.com/org/repo") + expect(data["reference_type"]).to eq("repository") + expect(data["source"]).to eq("explicit") + expect(data["title"]).to eq("My Repo") + end + + it "auto-classifies URL type" do + post api_v1_plan_references_path(plan), + params: { url: "https://github.com/org/repo/pull/42" }.to_json, + headers: headers + + data = JSON.parse(response.body) + expect(data["reference_type"]).to eq("pull_request") + end + end + + describe "DELETE /api/v1/plans/:plan_id/references/:id" do + it "deletes a reference" do + ref = create(:reference, plan: plan, url: "https://example.com") + + delete api_v1_plan_reference_path(plan, ref), headers: headers + expect(response).to have_http_status(:no_content) + expect(plan.references.count).to eq(0) + end + + it "returns not found for unknown reference" do + delete api_v1_plan_reference_path(plan, "nonexistent-id"), headers: headers + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /api/v1/references/search" do + it "finds plans by reference URL" do + create(:reference, plan: plan, url: "https://github.com/org/repo") + + get search_api_v1_references_path, params: { url: "https://github.com/org/repo" }, headers: headers + expect(response).to have_http_status(:ok) + + data = JSON.parse(response.body) + expect(data.length).to eq(1) + expect(data.first["plan_id"]).to eq(plan.id) + expect(data.first["plan_title"]).to eq(plan.title) + end + + it "requires url parameter" do + get search_api_v1_references_path, headers: headers + expect(response).to have_http_status(:unprocessable_content) + end + + it "excludes brainstorm plans from other users" do + other_user = create(:coplan_user) + brainstorm_plan = create(:plan, :brainstorm, created_by_user: other_user) + create(:reference, plan: brainstorm_plan, url: "https://github.com/org/repo") + + get search_api_v1_references_path, params: { url: "https://github.com/org/repo" }, headers: headers + data = JSON.parse(response.body) + expect(data.length).to eq(0) + end + end +end diff --git a/spec/services/coplan/references/extract_from_content_spec.rb b/spec/services/coplan/references/extract_from_content_spec.rb new file mode 100644 index 0000000..1afbe9f --- /dev/null +++ b/spec/services/coplan/references/extract_from_content_spec.rb @@ -0,0 +1,119 @@ +require "rails_helper" + +RSpec.describe CoPlan::References::ExtractFromContent do + let(:user) { create(:coplan_user) } + let(:plan) { create(:plan, created_by_user: user) } + + def update_content(plan, content) + version = plan.current_plan_version + version.update!(content_markdown: content) + end + + describe ".call" do + it "extracts markdown links from content" do + update_content(plan, "Check out [Rails](https://rubyonrails.org) for details.") + described_class.call(plan: plan) + + expect(plan.references.count).to eq(1) + ref = plan.references.first + expect(ref.url).to eq("https://rubyonrails.org") + expect(ref.title).to eq("Rails") + expect(ref.reference_type).to eq("link") + expect(ref.source).to eq("extracted") + end + + it "classifies GitHub repo URLs" do + update_content(plan, "See [repo](https://github.com/org/my-repo) for code.") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("repository") + end + + it "classifies GitHub PR URLs" do + update_content(plan, "See [PR](https://github.com/org/repo/pull/123) for changes.") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("pull_request") + end + + it "classifies Google Docs URLs" do + update_content(plan, "See [doc](https://docs.google.com/document/d/abc123) for details.") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("document") + end + + it "removes extracted references when links are removed from content" do + update_content(plan, "See [Rails](https://rubyonrails.org) and [Ruby](https://ruby-lang.org).") + described_class.call(plan: plan) + expect(plan.references.count).to eq(2) + + update_content(plan, "See [Rails](https://rubyonrails.org) only.") + described_class.call(plan: plan) + expect(plan.references.count).to eq(1) + expect(plan.references.first.url).to eq("https://rubyonrails.org") + end + + it "does not remove explicit references" do + create(:reference, plan: plan, url: "https://example.com", source: "explicit") + update_content(plan, "No links here.") + described_class.call(plan: plan) + + expect(plan.references.count).to eq(1) + expect(plan.references.first.source).to eq("explicit") + end + + it "does not overwrite explicit references with extracted ones" do + create(:reference, plan: plan, url: "https://rubyonrails.org", source: "explicit", title: "My Title") + update_content(plan, "See [Rails](https://rubyonrails.org).") + described_class.call(plan: plan) + + ref = plan.references.find_by(url: "https://rubyonrails.org") + expect(ref.source).to eq("explicit") + expect(ref.title).to eq("My Title") + end + + it "is idempotent" do + update_content(plan, "See [Rails](https://rubyonrails.org).") + described_class.call(plan: plan) + described_class.call(plan: plan) + + expect(plan.references.count).to eq(1) + end + + it "handles empty content" do + plan.current_plan_version.update_column(:content_markdown, "") + described_class.call(plan: plan) + expect(plan.references.count).to eq(0) + end + + it "sets target_plan_id for plan references" do + target_plan = create(:plan, created_by_user: user) + update_content(plan, "See [other plan](https://coplan.example.com/plans/#{target_plan.id}).") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("plan") + expect(ref.target_plan_id).to eq(target_plan.id) + end + + it "does not set target_plan_id for self-references" do + update_content(plan, "See [this plan](https://coplan.example.com/plans/#{plan.id}).") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.target_plan_id).to be_nil + end + + it "extracts bare URLs" do + update_content(plan, "Visit https://example.com for more info.") + described_class.call(plan: plan) + + expect(plan.references.count).to eq(1) + expect(plan.references.first.url).to eq("https://example.com") + end + end +end From 736f258b600c07115f3d9c2ae560a7d48146e066 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Apr 2026 08:37:40 -0500 Subject: [PATCH 2/6] Address PR feedback: fix regex bug, add transaction, fix find_or_create no-op - Remove /m flag from ref-def stripping regex so bare URLs after reference-style definitions are still scanned - Wrap plan create + reference saves in a transaction so a ref validation failure rolls back the plan - Switch web controller from find_or_create_by! to find_or_initialize_by + assign_attributes so submitting a URL that already exists (e.g. from auto-extraction) promotes it to explicit and applies the provided key/title Amp-Thread-ID: https://ampcode.com/threads/T-019d9222-fc6d-72ca-a6ec-c538f8ace722 Co-authored-by: Amp --- .../coplan/api/v1/plans_controller.rb | 31 ++++++++++--------- .../coplan/references_controller.rb | 16 +++++----- .../coplan/references/extract_from_content.rb | 2 +- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index be3aea0..eed1bb8 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -44,20 +44,23 @@ def create end end - plan = Plans::Create.call( - title: params[:title], - content: params[:content] || "", - user: current_user, - plan_type_id: plan_type&.id - ) - - if params[:references].is_a?(Array) - params[:references].each do |ref_params| - next unless ref_params[:url].present? - ref_type = ref_params[:reference_type].presence || Reference.classify_url(ref_params[:url]) - ref = plan.references.find_or_initialize_by(url: ref_params[:url]) - ref.assign_attributes(key: ref_params[:key], title: ref_params[:title], reference_type: ref_type, source: "explicit") - ref.save! + plan = nil + ActiveRecord::Base.transaction do + plan = Plans::Create.call( + title: params[:title], + content: params[:content] || "", + user: current_user, + plan_type_id: plan_type&.id + ) + + if params[:references].is_a?(Array) + params[:references].each do |ref_params| + next unless ref_params[:url].present? + ref_type = ref_params[:reference_type].presence || Reference.classify_url(ref_params[:url]) + ref = plan.references.find_or_initialize_by(url: ref_params[:url]) + ref.assign_attributes(key: ref_params[:key], title: ref_params[:title], reference_type: ref_type, source: "explicit") + ref.save! + end end end diff --git a/engine/app/controllers/coplan/references_controller.rb b/engine/app/controllers/coplan/references_controller.rb index b94f2ca..cc65eff 100644 --- a/engine/app/controllers/coplan/references_controller.rb +++ b/engine/app/controllers/coplan/references_controller.rb @@ -13,13 +13,15 @@ def create target_plan_id = candidate_id if candidate_id && candidate_id != @plan.id && Plan.exists?(candidate_id) end - @plan.references.find_or_create_by!(url: url) do |r| - r.key = params[:reference][:key].presence - r.title = params[:reference][:title].presence - r.reference_type = ref_type - r.source = "explicit" - r.target_plan_id = target_plan_id - end + ref = @plan.references.find_or_initialize_by(url: url) + ref.assign_attributes( + key: params[:reference][:key].presence || ref.key, + title: params[:reference][:title].presence || ref.title, + reference_type: ref_type, + source: "explicit", + target_plan_id: target_plan_id + ) + ref.save! redirect_to plan_path(@plan), notice: "Reference added." rescue ActiveRecord::RecordInvalid => e diff --git a/engine/app/services/coplan/references/extract_from_content.rb b/engine/app/services/coplan/references/extract_from_content.rb index 062a220..7fca729 100644 --- a/engine/app/services/coplan/references/extract_from_content.rb +++ b/engine/app/services/coplan/references/extract_from_content.rb @@ -74,7 +74,7 @@ def extract_urls(content) end # Match bare URLs that aren't already inside markdown link syntax - stripped = content.gsub(/\[([^\]]*)\]\(([^)]+)\)/, "").gsub(/^\[([^\]]+)\]:\s+\S+.*$/m, "") + stripped = content.gsub(/\[([^\]]*)\]\(([^)]+)\)/, "").gsub(/^\[([^\]]+)\]:\s+\S+.*$/, "") stripped.scan(%r{https?://[^\s<>\]\)]+}) do |url| url = url.chomp(".").chomp(",").chomp(")").chomp(";") urls[url] ||= { title: nil, key: nil } From 0e04622c8b32929120dcab9363f5fb7d4d28e9ed Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Apr 2026 11:07:03 -0500 Subject: [PATCH 3/6] Turbo Stream references, URL-backed tabs, fix form styling - References create/destroy now respond with turbo_stream to replace the references panel in-place instead of full-page redirect - Tab IDs prefixed with 'tab-' (tab-content, tab-references) to avoid collisions with heading anchors in plan content - Tabs controller pushes ?tab= query param to URL via replaceState so tab selection persists across navigation/reload - HTML fallback redirects include tab: 'references' param - Add Reference form restyled: inputs use .form-group convention, horizontal layout, moved above reference list - Delete button uses data-turbo-stream for inline updates Amp-Thread-ID: https://ampcode.com/threads/T-019d9222-fc6d-72ca-a6ec-c538f8ace722 Co-authored-by: Amp --- .../assets/stylesheets/coplan/application.css | 47 +++++++++++++++++-- .../coplan/references_controller.rb | 25 ++++++++-- .../controllers/coplan/tabs_controller.js | 10 ++++ .../views/coplan/plans/_references.html.erb | 38 ++++++++------- engine/app/views/coplan/plans/show.html.erb | 8 ++-- 5 files changed, 98 insertions(+), 30 deletions(-) diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 9aae993..6c09a6d 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2006,20 +2006,57 @@ body:has(.comment-toolbar) .main-content { background: var(--color-bg); } -.references-add { - margin-top: var(--space-md); +.references-section__header { + margin-bottom: var(--space-md); } .references-add summary { cursor: pointer; + list-style: none; +} + +.references-add summary::-webkit-details-marker { + display: none; } .references-add__form { margin-top: var(--space-sm); display: flex; - flex-direction: column; - gap: var(--space-sm); - max-width: 400px; + align-items: flex-end; + gap: var(--space-xs); + flex-wrap: wrap; +} + +.references-add__form .form-group { + margin-bottom: 0; + flex: 1; + min-width: 140px; +} + +.references-add__form input[type="text"] { + width: 100%; + padding: 6px var(--space-sm); + font-size: var(--text-sm); + font-family: var(--font-sans); + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + transition: border-color 0.15s; +} + +.references-add__form input[type="text"]:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +.references-add__form input[type="text"]::placeholder { + color: var(--color-text-muted); +} + +.references-add__form .btn { + flex-shrink: 0; } /* Hide sidebar on small screens */ diff --git a/engine/app/controllers/coplan/references_controller.rb b/engine/app/controllers/coplan/references_controller.rb index cc65eff..cf55dc0 100644 --- a/engine/app/controllers/coplan/references_controller.rb +++ b/engine/app/controllers/coplan/references_controller.rb @@ -23,9 +23,15 @@ def create ) ref.save! - redirect_to plan_path(@plan), notice: "Reference added." + respond_to do |format| + format.turbo_stream { render_references_stream } + format.html { redirect_to plan_path(@plan, tab: "references"), notice: "Reference added." } + end rescue ActiveRecord::RecordInvalid => e - redirect_to plan_path(@plan), alert: e.message + respond_to do |format| + format.turbo_stream { render_references_stream } + format.html { redirect_to plan_path(@plan, tab: "references"), alert: e.message } + end end def destroy @@ -33,7 +39,11 @@ def destroy ref = @plan.references.find(params[:id]) ref.destroy! - redirect_to plan_path(@plan), notice: "Reference removed." + + respond_to do |format| + format.turbo_stream { render_references_stream } + format.html { redirect_to plan_path(@plan, tab: "references"), notice: "Reference removed." } + end end private @@ -41,5 +51,14 @@ def destroy def set_plan @plan = Plan.find(params[:plan_id]) end + + def render_references_stream + references = @plan.references.reload.order(reference_type: :asc, created_at: :desc) + render turbo_stream: turbo_stream.replace( + "plan-references", + partial: "coplan/plans/references", + locals: { references: references, plan: @plan } + ) + end end end diff --git a/engine/app/javascript/controllers/coplan/tabs_controller.js b/engine/app/javascript/controllers/coplan/tabs_controller.js index f33336f..4bd72b2 100644 --- a/engine/app/javascript/controllers/coplan/tabs_controller.js +++ b/engine/app/javascript/controllers/coplan/tabs_controller.js @@ -6,6 +6,7 @@ export default class extends Controller { switch(event) { event.preventDefault() const targetId = event.currentTarget.getAttribute("href").replace("#", "") + const tabName = event.currentTarget.dataset.tabName this.tabTargets.forEach(tab => { tab.classList.toggle("plan-tabs__tab--active", tab.getAttribute("href") === `#${targetId}`) @@ -14,5 +15,14 @@ export default class extends Controller { this.panelTargets.forEach(panel => { panel.classList.toggle("plan-tabs__panel--hidden", panel.id !== targetId) }) + + // Update URL with tab param (omit for default "content" tab) + const url = new URL(window.location) + if (tabName && tabName !== "content") { + url.searchParams.set("tab", tabName) + } else { + url.searchParams.delete("tab") + } + history.replaceState(null, "", url) } } diff --git a/engine/app/views/coplan/plans/_references.html.erb b/engine/app/views/coplan/plans/_references.html.erb index 2157ad0..9884cea 100644 --- a/engine/app/views/coplan/plans/_references.html.erb +++ b/engine/app/views/coplan/plans/_references.html.erb @@ -1,4 +1,22 @@
+
+
+ + Add Reference + <%= form_with url: plan_references_path(plan), method: :post, class: "references-add__form" do |f| %> +
+ <%= f.text_field :url, placeholder: "https://...", required: true, name: "reference[url]" %> +
+
+ <%= f.text_field :title, placeholder: "Title (optional)", name: "reference[title]" %> +
+
+ <%= f.text_field :key, placeholder: "Key (optional, e.g. auth-repo)", name: "reference[key]" %> +
+ <%= f.submit "Add", class: "btn btn--primary btn--sm" %> + <% end %> +
+
+ <% if references.any? %>
    <% references.group_by(&:reference_type).each do |type, refs| %> @@ -20,7 +38,7 @@
<% if ref.source == "explicit" %> - <%= button_to "✕", plan_reference_path(plan, ref), method: :delete, class: "references-list__remove", title: "Remove reference", data: { turbo_confirm: "Remove this reference?" } %> + <%= button_to "✕", plan_reference_path(plan, ref), method: :delete, class: "references-list__remove", title: "Remove reference", data: { turbo_confirm: "Remove this reference?", turbo_stream: true } %> <% end %> <% end %> @@ -28,23 +46,7 @@ <% else %>
-

No references yet. Links in the plan content are auto-extracted, or add one manually below.

+

No references yet. Links in the plan content are auto-extracted, or add one manually.

<% end %> - -
- + Add Reference - <%= form_with url: plan_references_path(plan), method: :post, class: "references-add__form" do |f| %> -
- <%= f.text_field :url, placeholder: "https://...", required: true, class: "form-input", name: "reference[url]" %> -
-
- <%= f.text_field :title, placeholder: "Title (optional)", class: "form-input", name: "reference[title]" %> -
-
- <%= f.text_field :key, placeholder: "Key (optional, e.g. auth-repo)", class: "form-input", name: "reference[key]" %> -
- <%= f.submit "Add", class: "btn btn--primary btn--sm" %> - <% end %> -
diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 73448df..4697925 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -17,11 +17,11 @@
-
+
<% if @plan.current_content.present? %>
-
+
<%= render partial: "coplan/plans/references", locals: { references: @references, plan: @plan } %>
From f21a5a18b01d3195ab327390b97cf43688d514ff Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Apr 2026 11:16:28 -0500 Subject: [PATCH 4/6] Update references tab count via turbo_stream Amp-Thread-ID: https://ampcode.com/threads/T-019d9222-fc6d-72ca-a6ec-c538f8ace722 Co-authored-by: Amp --- .../controllers/coplan/references_controller.rb | 16 +++++++++++----- engine/app/views/coplan/plans/show.html.erb | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/engine/app/controllers/coplan/references_controller.rb b/engine/app/controllers/coplan/references_controller.rb index cf55dc0..cf1bab3 100644 --- a/engine/app/controllers/coplan/references_controller.rb +++ b/engine/app/controllers/coplan/references_controller.rb @@ -54,11 +54,17 @@ def set_plan def render_references_stream references = @plan.references.reload.order(reference_type: :asc, created_at: :desc) - render turbo_stream: turbo_stream.replace( - "plan-references", - partial: "coplan/plans/references", - locals: { references: references, plan: @plan } - ) + render turbo_stream: [ + turbo_stream.replace( + "plan-references", + partial: "coplan/plans/references", + locals: { references: references, plan: @plan } + ), + turbo_stream.replace( + "references-count", + html: content_tag(:span, references.size, class: "plan-tabs__count", id: "references-count") + ) + ] end end end diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 4697925..19f759d 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -18,7 +18,7 @@
From 6034545219090c869c4d89842fc0399d3d55ebde Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Apr 2026 11:32:10 -0500 Subject: [PATCH 5/6] Broadcast references on content edit, fix content_tag, robust system tests - Fix NoMethodError: use helpers.content_tag in controller - Broadcast references panel + count via ActionCable when a new PlanVersion extracts references, so API edits update all viewers - Add 19 system tests covering: - Tab navigation (show/hide, URL persistence, switching back) - Adding references inline via Turbo Stream (single, multiple, auto-classification) - Removing references inline with count update - Auto-extraction from content edits (new versions, link removal, explicit reference preservation, keyed reference-style links) - Display (key badges, source labels, target=_blank, truncation) - Fix stale association cache in extract_from_content spec Amp-Thread-ID: https://ampcode.com/threads/T-019d9222-fc6d-72ca-a6ec-c538f8ace722 Co-authored-by: Amp --- .../coplan/references_controller.rb | 2 +- engine/app/models/coplan/plan_version.rb | 16 ++ .../references/extract_from_content_spec.rb | 5 +- spec/system/references_spec.rb | 257 ++++++++++++++++++ 4 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 spec/system/references_spec.rb diff --git a/engine/app/controllers/coplan/references_controller.rb b/engine/app/controllers/coplan/references_controller.rb index cf1bab3..d4db257 100644 --- a/engine/app/controllers/coplan/references_controller.rb +++ b/engine/app/controllers/coplan/references_controller.rb @@ -62,7 +62,7 @@ def render_references_stream ), turbo_stream.replace( "references-count", - html: content_tag(:span, references.size, class: "plan-tabs__count", id: "references-count") + html: helpers.content_tag(:span, references.size, class: "plan-tabs__count", id: "references-count") ) ] end diff --git a/engine/app/models/coplan/plan_version.rb b/engine/app/models/coplan/plan_version.rb index 9afe521..4bf71aa 100644 --- a/engine/app/models/coplan/plan_version.rb +++ b/engine/app/models/coplan/plan_version.rb @@ -19,6 +19,22 @@ class PlanVersion < ApplicationRecord def extract_references CoPlan::References::ExtractFromContent.call(plan: plan, content: content_markdown) + broadcast_references_update + end + + def broadcast_references_update + references = plan.references.reload.order(reference_type: :asc, created_at: :desc) + Broadcaster.replace_to( + plan, + target: "plan-references", + partial: "coplan/plans/references", + locals: { references: references, plan: plan } + ) + Broadcaster.replace_to( + plan, + target: "references-count", + html: ApplicationController.helpers.content_tag(:span, references.size, class: "plan-tabs__count", id: "references-count") + ) end def compute_sha256 diff --git a/spec/services/coplan/references/extract_from_content_spec.rb b/spec/services/coplan/references/extract_from_content_spec.rb index 1afbe9f..d06c99b 100644 --- a/spec/services/coplan/references/extract_from_content_spec.rb +++ b/spec/services/coplan/references/extract_from_content_spec.rb @@ -62,8 +62,9 @@ def update_content(plan, content) update_content(plan, "No links here.") described_class.call(plan: plan) - expect(plan.references.count).to eq(1) - expect(plan.references.first.source).to eq("explicit") + refs = plan.references.reload + expect(refs.count).to eq(1) + expect(refs.first.source).to eq("explicit") end it "does not overwrite explicit references with extracted ones" do diff --git a/spec/system/references_spec.rb b/spec/system/references_spec.rb new file mode 100644 index 0000000..841c841 --- /dev/null +++ b/spec/system/references_spec.rb @@ -0,0 +1,257 @@ +require "rails_helper" + +RSpec.describe "Plan references", type: :system do + let(:user) { create(:coplan_user, email: "refuser@example.com") } + let(:plan) do + p = CoPlan::Plan.create!(title: "Test Plan", created_by_user: user) + version = CoPlan::PlanVersion.create!( + plan: p, revision: 1, + content_markdown: "# Hello\n\nSome content here.", + actor_type: "human", actor_id: user.id + ) + p.update!(current_plan_version: version, current_revision: 1) + p + end + + before do + visit sign_in_path + fill_in "Email address", with: user.email + click_button "Sign In" + expect(page).to have_current_path(root_path) + end + + describe "tab navigation" do + it "shows plan content by default and hides references" do + visit plan_path(plan) + expect(page).to have_content("Hello") + expect(page).not_to have_content("No references yet") + end + + it "clicking References tab shows references and hides content" do + visit plan_path(plan) + click_link "References" + + expect(page).to have_content("No references yet") + expect(page).not_to have_content("Some content here") + end + + it "clicking back to Content tab restores plan content" do + visit plan_path(plan) + click_link "References" + expect(page).to have_content("No references yet") + + click_link "Content" + expect(page).to have_content("Some content here") + expect(page).not_to have_content("No references yet") + end + + it "preserves tab selection via URL param on page load" do + visit plan_path(plan, tab: "references") + expect(page).to have_content("No references yet") + expect(page).not_to have_content("Some content here") + end + + it "updates the URL when switching tabs" do + visit plan_path(plan) + click_link "References" + uri = URI.parse(current_url) + expect(Rack::Utils.parse_query(uri.query)).to include("tab" => "references") + end + + it "removes tab param when switching back to content" do + visit plan_path(plan, tab: "references") + click_link "Content" + uri = URI.parse(current_url) + expect(uri.query.to_s).not_to include("tab=") + end + end + + describe "adding references" do + it "adds a reference inline without page navigation" do + visit plan_path(plan, tab: "references") + original_url = current_url + + find("summary", text: "+ Add Reference").click + fill_in "reference[url]", with: "https://github.com/org/repo" + fill_in "reference[title]", with: "My Repo" + fill_in "reference[key]", with: "my-repo" + click_button "Add" + + # Reference appears + expect(page).to have_link("My Repo", href: "https://github.com/org/repo") + # Empty state gone + expect(page).not_to have_content("No references yet") + # Tab count updated + expect(page).to have_css("#references-count", text: "1") + # Still on references tab (not redirected to content) + expect(page).to have_content("My Repo") + expect(page).not_to have_content("Some content here") + end + + it "adds multiple references in sequence" do + visit plan_path(plan, tab: "references") + + find("summary", text: "+ Add Reference").click + + fill_in "reference[url]", with: "https://github.com/org/repo" + fill_in "reference[title]", with: "Repo One" + click_button "Add" + expect(page).to have_content("Repo One") + expect(page).to have_css("#references-count", text: "1") + + # Form should be available again for another add + find("summary", text: "+ Add Reference").click + fill_in "reference[url]", with: "https://github.com/org/other" + fill_in "reference[title]", with: "Repo Two" + click_button "Add" + expect(page).to have_content("Repo Two") + expect(page).to have_css("#references-count", text: "2") + end + + it "auto-classifies URL types" do + visit plan_path(plan, tab: "references") + find("summary", text: "+ Add Reference").click + + fill_in "reference[url]", with: "https://github.com/org/repo/pull/42" + fill_in "reference[title]", with: "Fix PR" + click_button "Add" + + expect(page).to have_css(".badge--ref-type", text: /pull request/i) + end + end + + describe "removing references" do + it "removes an explicit reference inline" do + create(:reference, plan: plan, url: "https://example.com", title: "Doomed", source: "explicit") + + visit plan_path(plan, tab: "references") + expect(page).to have_content("Doomed") + expect(page).to have_css("#references-count", text: "1") + + accept_confirm("Remove this reference?") do + click_button "✕" + end + + expect(page).not_to have_content("Doomed") + expect(page).to have_css("#references-count", text: "0") + expect(page).to have_content("No references yet") + end + + it "does not show delete button for auto-extracted references" do + plan.current_plan_version.update!( + content_markdown: "See [Rails](https://rubyonrails.org) for details." + ) + CoPlan::References::ExtractFromContent.call(plan: plan) + + visit plan_path(plan, tab: "references") + expect(page).to have_content("Rails") + expect(page).not_to have_button("✕") + end + end + + describe "auto-extraction from content edits" do + it "extracts references when a new version is created" do + # Simulate an API edit creating a new version with links + CoPlan::PlanVersion.create!( + plan: plan, + revision: plan.current_revision + 1, + content_markdown: "See [Auth Service](https://github.com/org/auth) and [Design Doc](https://docs.google.com/document/d/abc).", + actor_type: "human", + actor_id: user.id + ) + + visit plan_path(plan, tab: "references") + expect(page).to have_content("Auth Service") + expect(page).to have_content("Design Doc") + expect(page).to have_css("#references-count", text: "2") + expect(page).to have_css(".badge--ref-type", text: /repository/i) + expect(page).to have_css(".badge--ref-type", text: /document/i) + end + + it "removes extracted references when links are removed from content" do + plan.current_plan_version.update!( + content_markdown: "See [Rails](https://rubyonrails.org) and [Ruby](https://ruby-lang.org)." + ) + CoPlan::References::ExtractFromContent.call(plan: plan) + expect(plan.references.count).to eq(2) + + # New version without the Ruby link + CoPlan::PlanVersion.create!( + plan: plan, + revision: plan.current_revision + 1, + content_markdown: "See [Rails](https://rubyonrails.org) only.", + actor_type: "human", + actor_id: user.id + ) + + visit plan_path(plan, tab: "references") + expect(page).to have_content("Rails") + expect(page).not_to have_content("Ruby") + expect(page).to have_css("#references-count", text: "1") + end + + it "preserves explicit references when content changes" do + create(:reference, plan: plan, url: "https://example.com/important", title: "Important Link", source: "explicit") + + CoPlan::PlanVersion.create!( + plan: plan, + revision: plan.current_revision + 1, + content_markdown: "Completely new content with no links.", + actor_type: "human", + actor_id: user.id + ) + + visit plan_path(plan, tab: "references") + expect(page).to have_content("Important Link") + expect(page).to have_css("#references-count", text: "1") + end + + it "extracts keyed references from markdown reference-style links" do + CoPlan::PlanVersion.create!( + plan: plan, + revision: plan.current_revision + 1, + content_markdown: "See the [auth-repo] for details.\n\n[auth-repo]: https://github.com/org/auth \"Auth Service\"", + actor_type: "human", + actor_id: user.id + ) + + visit plan_path(plan, tab: "references") + expect(page).to have_content("Auth Service") + expect(page).to have_css(".badge--ref-key", text: /auth-repo/i) + end + end + + describe "display" do + it "shows key badge for keyed references" do + create(:reference, plan: plan, url: "https://github.com/org/repo", key: "main-repo", title: "Main Repo", source: "explicit") + + visit plan_path(plan, tab: "references") + expect(page).to have_css(".badge--ref-key", text: /main-repo/i) + end + + it "shows source label for auto-extracted references" do + create(:reference, plan: plan, url: "https://example.com", source: "extracted") + + visit plan_path(plan, tab: "references") + expect(page).to have_content("auto-extracted") + end + + it "links open in new tab" do + create(:reference, plan: plan, url: "https://example.com", title: "External", source: "explicit") + + visit plan_path(plan, tab: "references") + link = find("a", text: "External") + expect(link[:target]).to eq("_blank") + expect(link[:rel]).to include("noopener") + end + + it "truncates long URLs when no title is set" do + long_url = "https://github.com/organization/very-long-repository-name-that-goes-on-and-on/pull/12345" + create(:reference, plan: plan, url: long_url, title: nil, source: "explicit") + + visit plan_path(plan, tab: "references") + # Should show a truncated version, not the full URL + expect(page).to have_link(href: long_url) + end + end +end From 03829cd8e00db4a4ae96b2648f5b4b10b069cc31 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Apr 2026 11:39:31 -0500 Subject: [PATCH 6/6] Trim references system specs to genuine JS/Turbo integration tests Remove 14 filler tests that only checked server-rendered HTML (covered by model, service, and request specs). Keep 5 focused tests that verify real browser integration: - Stimulus tab controller: replaceState URL updates, CSS class toggling - Turbo Stream add: DOM replacement without page navigation - Sequential adds:
re-expansion after partial replacement - Turbo Stream delete: data-turbo-confirm dialog + DOM removal - Cross-tab count: badge persistence across Stimulus tab switches Amp-Thread-ID: https://ampcode.com/threads/T-019d9c4b-b022-74c4-a0a6-929c26e7ff63 Co-authored-by: Amp --- spec/system/references_spec.rb | 209 ++++++++------------------------- 1 file changed, 48 insertions(+), 161 deletions(-) diff --git a/spec/system/references_spec.rb b/spec/system/references_spec.rb index 841c841..97e5a2a 100644 --- a/spec/system/references_spec.rb +++ b/spec/system/references_spec.rb @@ -20,238 +20,125 @@ expect(page).to have_current_path(root_path) end - describe "tab navigation" do - it "shows plan content by default and hides references" do + describe "Stimulus tab switching" do + it "toggles panel visibility and updates URL via replaceState" do visit plan_path(plan) - expect(page).to have_content("Hello") - expect(page).not_to have_content("No references yet") - end - - it "clicking References tab shows references and hides content" do - visit plan_path(plan) - click_link "References" - expect(page).to have_content("No references yet") - expect(page).not_to have_content("Some content here") - end - - it "clicking back to Content tab restores plan content" do - visit plan_path(plan) - click_link "References" - expect(page).to have_content("No references yet") - - click_link "Content" + # Content visible, references hidden (display:none) expect(page).to have_content("Some content here") expect(page).not_to have_content("No references yet") - end - it "preserves tab selection via URL param on page load" do - visit plan_path(plan, tab: "references") + click_link "References" + + # JS toggles the hidden class — no page reload expect(page).to have_content("No references yet") expect(page).not_to have_content("Some content here") - end - it "updates the URL when switching tabs" do - visit plan_path(plan) - click_link "References" + # Stimulus controller pushes ?tab=references via replaceState uri = URI.parse(current_url) expect(Rack::Utils.parse_query(uri.query)).to include("tab" => "references") - end - it "removes tab param when switching back to content" do - visit plan_path(plan, tab: "references") click_link "Content" + + # Tab param removed for default tab uri = URI.parse(current_url) expect(uri.query.to_s).not_to include("tab=") + + # Content restored + expect(page).to have_content("Some content here") + expect(page).not_to have_content("No references yet") end end - describe "adding references" do - it "adds a reference inline without page navigation" do + describe "adding references via Turbo Stream" do + it "appends reference to the DOM without navigating away from the tab" do visit plan_path(plan, tab: "references") - original_url = current_url + # Open the
form find("summary", text: "+ Add Reference").click + expect(page).to have_css("details[open]") + fill_in "reference[url]", with: "https://github.com/org/repo" fill_in "reference[title]", with: "My Repo" fill_in "reference[key]", with: "my-repo" click_button "Add" - # Reference appears + # Turbo Stream replaces the list — reference appears without navigation expect(page).to have_link("My Repo", href: "https://github.com/org/repo") - # Empty state gone expect(page).not_to have_content("No references yet") - # Tab count updated + + # Count span updated in-place via Turbo Stream (separate stream target) expect(page).to have_css("#references-count", text: "1") - # Still on references tab (not redirected to content) - expect(page).to have_content("My Repo") + + # Still on references tab — Turbo Stream didn't cause a Turbo visit + # (content tab remains hidden, references tab content is visible) expect(page).not_to have_content("Some content here") + expect(page).to have_content("My Repo") end - it "adds multiple references in sequence" do + it "supports sequential adds with form re-expansion" do visit plan_path(plan, tab: "references") find("summary", text: "+ Add Reference").click - fill_in "reference[url]", with: "https://github.com/org/repo" fill_in "reference[title]", with: "Repo One" click_button "Add" expect(page).to have_content("Repo One") expect(page).to have_css("#references-count", text: "1") - # Form should be available again for another add + # After Turbo Stream replaces the partial,
is collapsed; + # user must be able to re-expand and add another find("summary", text: "+ Add Reference").click fill_in "reference[url]", with: "https://github.com/org/other" fill_in "reference[title]", with: "Repo Two" click_button "Add" + + expect(page).to have_content("Repo One") expect(page).to have_content("Repo Two") expect(page).to have_css("#references-count", text: "2") end - - it "auto-classifies URL types" do - visit plan_path(plan, tab: "references") - find("summary", text: "+ Add Reference").click - - fill_in "reference[url]", with: "https://github.com/org/repo/pull/42" - fill_in "reference[title]", with: "Fix PR" - click_button "Add" - - expect(page).to have_css(".badge--ref-type", text: /pull request/i) - end end - describe "removing references" do - it "removes an explicit reference inline" do + describe "removing references via Turbo Stream" do + it "removes reference from DOM with confirm dialog" do create(:reference, plan: plan, url: "https://example.com", title: "Doomed", source: "explicit") visit plan_path(plan, tab: "references") expect(page).to have_content("Doomed") expect(page).to have_css("#references-count", text: "1") + # data-turbo-confirm triggers a browser confirm dialog accept_confirm("Remove this reference?") do click_button "✕" end + # Turbo Stream removes the reference and updates count expect(page).not_to have_content("Doomed") expect(page).to have_css("#references-count", text: "0") expect(page).to have_content("No references yet") end - - it "does not show delete button for auto-extracted references" do - plan.current_plan_version.update!( - content_markdown: "See [Rails](https://rubyonrails.org) for details." - ) - CoPlan::References::ExtractFromContent.call(plan: plan) - - visit plan_path(plan, tab: "references") - expect(page).to have_content("Rails") - expect(page).not_to have_button("✕") - end end - describe "auto-extraction from content edits" do - it "extracts references when a new version is created" do - # Simulate an API edit creating a new version with links - CoPlan::PlanVersion.create!( - plan: plan, - revision: plan.current_revision + 1, - content_markdown: "See [Auth Service](https://github.com/org/auth) and [Design Doc](https://docs.google.com/document/d/abc).", - actor_type: "human", - actor_id: user.id - ) - - visit plan_path(plan, tab: "references") - expect(page).to have_content("Auth Service") - expect(page).to have_content("Design Doc") - expect(page).to have_css("#references-count", text: "2") - expect(page).to have_css(".badge--ref-type", text: /repository/i) - expect(page).to have_css(".badge--ref-type", text: /document/i) - end + describe "tab count updates across tab switches" do + it "updates the references count badge visible in the tab nav" do + visit plan_path(plan) - it "removes extracted references when links are removed from content" do - plan.current_plan_version.update!( - content_markdown: "See [Rails](https://rubyonrails.org) and [Ruby](https://ruby-lang.org)." - ) - CoPlan::References::ExtractFromContent.call(plan: plan) - expect(plan.references.count).to eq(2) + # Count starts at 0 + expect(page).to have_css("#references-count", text: "0") - # New version without the Ruby link - CoPlan::PlanVersion.create!( - plan: plan, - revision: plan.current_revision + 1, - content_markdown: "See [Rails](https://rubyonrails.org) only.", - actor_type: "human", - actor_id: user.id - ) + # Switch to references, add one + click_link "References" + find("summary", text: "+ Add Reference").click + fill_in "reference[url]", with: "https://github.com/org/repo" + fill_in "reference[title]", with: "My Repo" + click_button "Add" - visit plan_path(plan, tab: "references") - expect(page).to have_content("Rails") - expect(page).not_to have_content("Ruby") + # Count updated via Turbo Stream — visible even in the tab nav expect(page).to have_css("#references-count", text: "1") - end - - it "preserves explicit references when content changes" do - create(:reference, plan: plan, url: "https://example.com/important", title: "Important Link", source: "explicit") - - CoPlan::PlanVersion.create!( - plan: plan, - revision: plan.current_revision + 1, - content_markdown: "Completely new content with no links.", - actor_type: "human", - actor_id: user.id - ) - visit plan_path(plan, tab: "references") - expect(page).to have_content("Important Link") + # Switch back to content — count persists (it's outside the panels) + click_link "Content" expect(page).to have_css("#references-count", text: "1") end - - it "extracts keyed references from markdown reference-style links" do - CoPlan::PlanVersion.create!( - plan: plan, - revision: plan.current_revision + 1, - content_markdown: "See the [auth-repo] for details.\n\n[auth-repo]: https://github.com/org/auth \"Auth Service\"", - actor_type: "human", - actor_id: user.id - ) - - visit plan_path(plan, tab: "references") - expect(page).to have_content("Auth Service") - expect(page).to have_css(".badge--ref-key", text: /auth-repo/i) - end - end - - describe "display" do - it "shows key badge for keyed references" do - create(:reference, plan: plan, url: "https://github.com/org/repo", key: "main-repo", title: "Main Repo", source: "explicit") - - visit plan_path(plan, tab: "references") - expect(page).to have_css(".badge--ref-key", text: /main-repo/i) - end - - it "shows source label for auto-extracted references" do - create(:reference, plan: plan, url: "https://example.com", source: "extracted") - - visit plan_path(plan, tab: "references") - expect(page).to have_content("auto-extracted") - end - - it "links open in new tab" do - create(:reference, plan: plan, url: "https://example.com", title: "External", source: "explicit") - - visit plan_path(plan, tab: "references") - link = find("a", text: "External") - expect(link[:target]).to eq("_blank") - expect(link[:rel]).to include("noopener") - end - - it "truncates long URLs when no title is set" do - long_url = "https://github.com/organization/very-long-repository-name-that-goes-on-and-on/pull/12345" - create(:reference, plan: plan, url: long_url, title: nil, source: "explicit") - - visit plan_path(plan, tab: "references") - # Should show a truncated version, not the full URL - expect(page).to have_link(href: long_url) - end end end