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..6c09a6d 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1872,6 +1872,193 @@ 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-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; + 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 */ @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..eed1bb8 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 @@ -33,12 +44,26 @@ def create end end - plan = Plans::Create.call( - title: params[:title], - content: params[:content] || "", - user: current_user, - plan_type_id: plan_type&.id - ) + 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 + render json: plan_json(plan).merge( current_content: plan.current_content, current_revision: plan.current_revision @@ -68,6 +93,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..d4db257 --- /dev/null +++ b/engine/app/controllers/coplan/references_controller.rb @@ -0,0 +1,70 @@ +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 + + 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! + + 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 + 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 + authorize!(@plan, :update?) + + ref = @plan.references.find(params[:id]) + ref.destroy! + + 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 + + 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 } + ), + turbo_stream.replace( + "references-count", + html: helpers.content_tag(:span, references.size, class: "plan-tabs__count", id: "references-count") + ) + ] + 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..4bd72b2 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/tabs_controller.js @@ -0,0 +1,28 @@ +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("#", "") + const tabName = event.currentTarget.dataset.tabName + + 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) + }) + + // 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/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..4bf71aa 100644 --- a/engine/app/models/coplan/plan_version.rb +++ b/engine/app/models/coplan/plan_version.rb @@ -13,9 +13,30 @@ 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) + 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 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..7fca729 --- /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+.*$/, "") + 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..9884cea --- /dev/null +++ b/engine/app/views/coplan/plans/_references.html.erb @@ -0,0 +1,52 @@ +
No references yet. Links in the plan content are auto-extracted, or add one manually.
+This plan has no content yet.
This plan has no content yet.
-