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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 83 additions & 16 deletions engine/app/controllers/coplan/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ module CoPlan
module Api
module V1
class PlansController < BaseController
before_action :set_plan, only: [:show, :update, :versions, :comments]
before_action :authorize_plan_access!, only: [:show, :update, :versions, :comments]
before_action :set_plan, only: [:show, :update, :versions, :comments, :snapshot]
before_action :authorize_plan_access!, only: [:show, :update, :versions, :comments, :snapshot]

def index
plans = Plan
Expand All @@ -19,17 +19,7 @@ def show
render json: plan_json(@plan).merge(
current_content: @plan.current_content,
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
}
}
references: @plan.references.map { |r| reference_json(r) }
)
end

Expand Down Expand Up @@ -121,6 +111,20 @@ def comments
render json: threads.map { |t| thread_json(t) }
end

def snapshot
threads = @plan.comment_threads.includes(:comments, :created_by_user).order(created_at: :desc)
references = @plan.references.order(created_at: :desc)
collaborators = @plan.plan_collaborators.includes(:user)

render json: plan_json(@plan).merge(
current_content: @plan.current_content,
current_revision: @plan.current_revision,
comment_threads: snapshot_threads_json(threads),
references: references.map { |r| reference_json(r) },
collaborators: collaborators.map { |c| collaborator_json(c) }
)
end

private

def plan_json(plan)
Expand All @@ -132,7 +136,8 @@ def plan_json(plan)
tags: plan.tag_names,
plan_type_id: plan.plan_type_id,
plan_type_name: plan.plan_type&.name,
created_by: plan.created_by_user.name,
created_by: plan.created_by_user&.name,
created_by_user: user_json(plan.created_by_user),
created_at: plan.created_at,
updated_at: plan.updated_at
}
Expand All @@ -149,6 +154,58 @@ def version_json(version)
}
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
}
end

def collaborator_json(collaborator)
{
id: collaborator.id,
user: user_json(collaborator.user),
role: collaborator.role
}
end

def snapshot_threads_json(threads)
content = @plan.current_content
stripped_data = if content.present?
stripped, pos_map = CoPlan::CommentThread.strip_markdown(content)
{ stripped: stripped, pos_map: pos_map }
end

threads.map do |t|
occurrence = compute_anchor_occurrence(t, content, stripped_data)
thread_json(t).merge(anchor_occurrence: occurrence)
end
end

def compute_anchor_occurrence(thread, content, stripped_data)
return nil unless thread.anchored?
return 0 unless content.present? && thread.anchor_start.present? && stripped_data

stripped = stripped_data[:stripped]
pos_map = stripped_data[:pos_map]
stripped_start = pos_map.index { |raw_idx| raw_idx >= thread.anchor_start }
return nil if stripped_start.nil?

normalized_anchor = thread.anchor_text.gsub("\t", " ")
ranges = []
start_pos = 0
while (idx = stripped.index(normalized_anchor, start_pos))
ranges << idx
start_pos = idx + normalized_anchor.length
end
ranges.index { |s| s >= stripped_start } || 0
end

def thread_json(thread)
{
id: thread.id,
Expand All @@ -159,19 +216,29 @@ def thread_json(thread)
start_line: thread.start_line,
end_line: thread.end_line,
out_of_date: thread.out_of_date,
created_by: thread.created_by_user.name,
created_by: thread.created_by_user&.name,
created_by_user: user_json(thread.created_by_user),
created_at: thread.created_at,
comments: thread.comments.order(created_at: :asc).map { |c|
comments: thread.comments.sort_by(&:created_at).map { |c|
{
id: c.id,
author_type: c.author_type,
author_id: c.author_id,
agent_name: c.agent_name,
body_markdown: c.body_markdown,
created_at: c.created_at
}
}
}
end

def user_json(user)
return nil unless user
{
id: user.id,
name: user.name
}
end
end
end
end
Expand Down
20 changes: 16 additions & 4 deletions engine/app/views/coplan/agent_instructions/show.text.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ Optional query param: `?status=considering`

Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`, `references` (array of linked resources).

### Get Plan Snapshot (Recommended)

Returns everything about a plan in one request — use this instead of calling multiple endpoints.

```bash
<%= @curl %> \
"<%= @base %>/api/v1/plans/$PLAN_ID/snapshot" | jq .
```

Returns: plan metadata, `current_content`, `current_revision`, `comment_threads` (with nested comments), `references`, and `collaborators`. This replaces the need to call GET plan + GET comments + GET references separately.

### Create Plan

```bash
Expand Down Expand Up @@ -317,13 +328,14 @@ Dismiss a comment thread (plan author only — for comments that are out of scop

When asked to review a plan (given a plan URL or ID), follow this workflow:

### 1. Read the Plan and Comments
### 1. Read the Plan Snapshot

```bash
<%= @curl %> "<%= @base %>/api/v1/plans/$PLAN_ID" | jq .
<%= @curl %> "<%= @base %>/api/v1/plans/$PLAN_ID/comments" | jq .
<%= @curl %> "<%= @base %>/api/v1/plans/$PLAN_ID/snapshot" | jq .
```

This returns the plan content, all comment threads with comments, references, and collaborators in one call.

### 2. Triage Comments

Review each open comment thread and categorize it:
Expand All @@ -338,7 +350,7 @@ For approved changes: acquire lease → apply operations → release lease → r

## Typical Workflow

1. **Read** the plan: `GET /api/v1/plans/:id`
1. **Read** the plan: `GET /api/v1/plans/:id/snapshot`
2. **Acquire lease**: `POST /api/v1/plans/:id/lease`
3. **Apply operations**: `POST /api/v1/plans/:id/operations` (can call multiple times while lease is held)
4. **Release lease**: `DELETE /api/v1/plans/:id/lease`
Expand Down
1 change: 1 addition & 0 deletions engine/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
resources :plans, only: [:index, :show, :create, :update] do
get :versions, on: :member
get :comments, on: :member
get :snapshot, on: :member
resource :lease, only: [:create, :update, :destroy], controller: "leases"
resources :operations, only: [:create]
resources :sessions, only: [:create, :show], controller: "sessions" do
Expand Down
55 changes: 55 additions & 0 deletions spec/requests/api/v1/plans_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,59 @@
matching = threads.find { |t| t["id"] == thread.id }
expect(matching["anchor_text"]).to eq("original roadmap text")
end

describe "GET /api/v1/plans/:id/snapshot" do
it "returns plan with all nested data in one response" do
thread = create(:comment_thread, :with_positioned_anchor, plan: plan,
plan_version: plan.current_plan_version, created_by_user: alice)
comment = create(:comment, comment_thread: thread, body_markdown: "Snapshot comment")
ref = create(:reference, plan: plan, url: "https://example.com/snapshot", title: "Snapshot Ref")
collaborator = create(:plan_collaborator, plan: plan, user: carol, role: "reviewer")

get snapshot_api_v1_plan_path(plan), headers: headers

expect(response).to have_http_status(:success)
body = JSON.parse(response.body)

# Plan metadata — created_by preserved as string, created_by_user added as object
expect(body["id"]).to eq(plan.id)
expect(body["title"]).to eq("Acme Roadmap")
expect(body["current_content"]).to be_present
expect(body["current_revision"]).to be_present
expect(body["created_by"]).to eq(alice.name)
expect(body["created_by_user"]).to eq({ "id" => alice.id, "name" => alice.name })

# Comment threads with anchor_occurrence and structured created_by_user
expect(body["comment_threads"]).to be_a(Array)
matching_thread = body["comment_threads"].find { |t| t["id"] == thread.id }
expect(matching_thread["anchor_text"]).to eq("some anchor text")
expect(matching_thread).to have_key("anchor_occurrence")
expect(matching_thread["created_by"]).to eq(alice.name)
expect(matching_thread["created_by_user"]).to eq({ "id" => alice.id, "name" => alice.name })
expect(matching_thread["comments"]).to be_a(Array)
matching_comment = matching_thread["comments"].find { |c| c["body_markdown"] == "Snapshot comment" }
expect(matching_comment).to be_present
expect(matching_comment).to have_key("author_id")

# References
expect(body["references"]).to be_a(Array)
expect(body["references"].any? { |r| r["url"] == "https://example.com/snapshot" }).to be true

# Collaborators with structured user
expect(body["collaborators"]).to be_a(Array)
matching_collab = body["collaborators"].find { |c| c.dig("user", "id") == carol.id }
expect(matching_collab["role"]).to eq("reviewer")
expect(matching_collab["user"]["name"]).to eq(carol.name)
end

it "requires auth" do
get snapshot_api_v1_plan_path(plan)
expect(response).to have_http_status(:unauthorized)
end

it "returns 404 for nonexistent plan" do
get snapshot_api_v1_plan_path(id: "nonexistent"), headers: headers
expect(response).to have_http_status(:not_found)
end
end
end
Loading