coplan-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/app/assets/stylesheets/coplan/application.css +956 -0
- data/app/controllers/coplan/api/v1/base_controller.rb +75 -0
- data/app/controllers/coplan/api/v1/comments_controller.rb +135 -0
- data/app/controllers/coplan/api/v1/leases_controller.rb +62 -0
- data/app/controllers/coplan/api/v1/operations_controller.rb +298 -0
- data/app/controllers/coplan/api/v1/plans_controller.rb +129 -0
- data/app/controllers/coplan/api/v1/sessions_controller.rb +92 -0
- data/app/controllers/coplan/application_controller.rb +78 -0
- data/app/controllers/coplan/automated_reviews_controller.rb +29 -0
- data/app/controllers/coplan/comment_threads_controller.rb +148 -0
- data/app/controllers/coplan/comments_controller.rb +40 -0
- data/app/controllers/coplan/dashboard_controller.rb +6 -0
- data/app/controllers/coplan/plan_versions_controller.rb +29 -0
- data/app/controllers/coplan/plans_controller.rb +53 -0
- data/app/controllers/coplan/settings/tokens_controller.rb +37 -0
- data/app/helpers/coplan/application_helper.rb +4 -0
- data/app/helpers/coplan/comments_helper.rb +20 -0
- data/app/helpers/coplan/markdown_helper.rb +36 -0
- data/app/javascript/controllers/coplan/dropdown_controller.js +25 -0
- data/app/javascript/controllers/coplan/line_selection_controller.js +112 -0
- data/app/javascript/controllers/coplan/tabs_controller.js +18 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +376 -0
- data/app/jobs/coplan/application_job.rb +4 -0
- data/app/jobs/coplan/automated_review_job.rb +71 -0
- data/app/jobs/coplan/commit_expired_session_job.rb +29 -0
- data/app/jobs/coplan/notification_job.rb +10 -0
- data/app/models/coplan/api_token.rb +50 -0
- data/app/models/coplan/application_record.rb +14 -0
- data/app/models/coplan/automated_plan_reviewer.rb +62 -0
- data/app/models/coplan/comment.rb +24 -0
- data/app/models/coplan/comment_thread.rb +151 -0
- data/app/models/coplan/current.rb +5 -0
- data/app/models/coplan/edit_lease.rb +57 -0
- data/app/models/coplan/edit_session.rb +61 -0
- data/app/models/coplan/plan.rb +28 -0
- data/app/models/coplan/plan_collaborator.rb +12 -0
- data/app/models/coplan/plan_version.rb +23 -0
- data/app/models/coplan/user.rb +20 -0
- data/app/policies/coplan/application_policy.rb +14 -0
- data/app/policies/coplan/comment_thread_policy.rb +23 -0
- data/app/policies/coplan/plan_policy.rb +15 -0
- data/app/services/coplan/ai_providers/anthropic.rb +21 -0
- data/app/services/coplan/ai_providers/open_ai.rb +44 -0
- data/app/services/coplan/broadcaster.rb +32 -0
- data/app/services/coplan/plans/apply_operations.rb +128 -0
- data/app/services/coplan/plans/commit_session.rb +186 -0
- data/app/services/coplan/plans/create.rb +30 -0
- data/app/services/coplan/plans/operation_error.rb +5 -0
- data/app/services/coplan/plans/position_resolver.rb +191 -0
- data/app/services/coplan/plans/review_prompt_formatter.rb +25 -0
- data/app/services/coplan/plans/review_response_parser.rb +65 -0
- data/app/services/coplan/plans/transform_range.rb +99 -0
- data/app/services/coplan/plans/trigger_automated_reviews.rb +33 -0
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +17 -0
- data/app/views/coplan/comment_threads/_reply_form.html.erb +8 -0
- data/app/views/coplan/comment_threads/_thread.html.erb +47 -0
- data/app/views/coplan/comments/_comment.html.erb +9 -0
- data/app/views/coplan/dashboard/show.html.erb +8 -0
- data/app/views/coplan/plan_versions/index.html.erb +21 -0
- data/app/views/coplan/plan_versions/show.html.erb +29 -0
- data/app/views/coplan/plans/_header.html.erb +26 -0
- data/app/views/coplan/plans/edit.html.erb +15 -0
- data/app/views/coplan/plans/index.html.erb +35 -0
- data/app/views/coplan/plans/show.html.erb +56 -0
- data/app/views/coplan/settings/tokens/_form.html.erb +8 -0
- data/app/views/coplan/settings/tokens/_token_reveal.html.erb +7 -0
- data/app/views/coplan/settings/tokens/_token_row.html.erb +24 -0
- data/app/views/coplan/settings/tokens/create.turbo_stream.erb +11 -0
- data/app/views/coplan/settings/tokens/destroy.turbo_stream.erb +5 -0
- data/app/views/coplan/settings/tokens/index.html.erb +32 -0
- data/app/views/layouts/coplan/application.html.erb +42 -0
- data/config/importmap.rb +1 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20260226200000_create_coplan_schema.rb +173 -0
- data/lib/coplan/configuration.rb +17 -0
- data/lib/coplan/engine.rb +34 -0
- data/lib/coplan/version.rb +3 -0
- data/lib/coplan.rb +15 -0
- data/lib/tasks/coplan.rake +4 -0
- data/prompts/reviewers/routing.md +14 -0
- data/prompts/reviewers/scalability.md +14 -0
- data/prompts/reviewers/security.md +14 -0
- metadata +245 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class BaseController < ActionController::API
|
|
5
|
+
before_action :authenticate_api!
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def authenticate_api!
|
|
10
|
+
token = request.headers["Authorization"]&.delete_prefix("Bearer ")
|
|
11
|
+
if token.present?
|
|
12
|
+
authenticate_via_token!(token)
|
|
13
|
+
return if @api_token
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if CoPlan.configuration.api_authenticate
|
|
17
|
+
attrs = CoPlan.configuration.api_authenticate.call(request)
|
|
18
|
+
if attrs && attrs[:external_id].present?
|
|
19
|
+
provision_user_from_hook!(attrs)
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
render json: { error: "Unauthorized" }, status: :unauthorized
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def provision_user_from_hook!(attrs)
|
|
28
|
+
external_id = attrs[:external_id].to_s
|
|
29
|
+
@current_api_user = CoPlan::User.find_or_initialize_by(external_id: external_id)
|
|
30
|
+
@current_api_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact)
|
|
31
|
+
if @current_api_user.new_record? || @current_api_user.changed?
|
|
32
|
+
@current_api_user.save!
|
|
33
|
+
end
|
|
34
|
+
rescue ActiveRecord::RecordNotUnique
|
|
35
|
+
@current_api_user = CoPlan::User.find_by!(external_id: external_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def authenticate_via_token!(token)
|
|
39
|
+
@api_token = CoPlan::ApiToken.authenticate(token)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def current_user
|
|
43
|
+
@current_api_user || @api_token&.user
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Unique identifier for the API caller — used as actor_id, holder_id, author_id.
|
|
47
|
+
# With token auth this is the token's ID; with hook auth it's the user's ID.
|
|
48
|
+
def api_actor_id
|
|
49
|
+
@api_token&.id || @current_api_user&.id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The type of actor making the API call.
|
|
53
|
+
# Token auth → "local_agent"; hook auth → "human".
|
|
54
|
+
def api_author_type
|
|
55
|
+
@api_token ? ApiToken::HOLDER_TYPE : "human"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def set_plan
|
|
59
|
+
@plan = CoPlan::Plan.find_by(id: params[:plan_id] || params[:id])
|
|
60
|
+
unless @plan
|
|
61
|
+
render json: { error: "Plan not found" }, status: :not_found
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def authorize_plan_access!
|
|
66
|
+
return unless @plan
|
|
67
|
+
policy = CoPlan::PlanPolicy.new(current_user, @plan)
|
|
68
|
+
unless policy.show?
|
|
69
|
+
render json: { error: "Plan not found" }, status: :not_found
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class CommentsController < BaseController
|
|
5
|
+
before_action :set_plan
|
|
6
|
+
before_action :authorize_plan_access!
|
|
7
|
+
|
|
8
|
+
def create
|
|
9
|
+
thread = @plan.comment_threads.new(
|
|
10
|
+
plan_version: @plan.current_plan_version,
|
|
11
|
+
anchor_text: params[:anchor_text].presence,
|
|
12
|
+
anchor_occurrence: params[:anchor_occurrence]&.to_i,
|
|
13
|
+
start_line: params[:start_line].presence,
|
|
14
|
+
end_line: params[:end_line].presence,
|
|
15
|
+
created_by_user: current_user
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
thread.save!
|
|
19
|
+
|
|
20
|
+
comment = thread.comments.create!(
|
|
21
|
+
author_type: api_author_type,
|
|
22
|
+
author_id: api_actor_id,
|
|
23
|
+
body_markdown: params[:body_markdown],
|
|
24
|
+
agent_name: params[:agent_name]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
broadcast_new_thread(thread)
|
|
28
|
+
|
|
29
|
+
render json: {
|
|
30
|
+
thread_id: thread.id,
|
|
31
|
+
comment_id: comment.id,
|
|
32
|
+
status: thread.status,
|
|
33
|
+
created_at: thread.created_at
|
|
34
|
+
}, status: :created
|
|
35
|
+
|
|
36
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
37
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve
|
|
41
|
+
thread = @plan.comment_threads.find_by(id: params[:id])
|
|
42
|
+
unless thread
|
|
43
|
+
render json: { error: "Comment thread not found" }, status: :not_found
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
policy = CommentThreadPolicy.new(current_user, thread)
|
|
48
|
+
unless policy.resolve?
|
|
49
|
+
render json: { error: "Not authorized" }, status: :forbidden
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
thread.resolve!(current_user)
|
|
54
|
+
broadcast_thread_update(thread)
|
|
55
|
+
|
|
56
|
+
render json: { thread_id: thread.id, status: thread.status }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def dismiss
|
|
60
|
+
thread = @plan.comment_threads.find_by(id: params[:id])
|
|
61
|
+
unless thread
|
|
62
|
+
render json: { error: "Comment thread not found" }, status: :not_found
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
policy = CommentThreadPolicy.new(current_user, thread)
|
|
67
|
+
unless policy.dismiss?
|
|
68
|
+
render json: { error: "Not authorized" }, status: :forbidden
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
thread.dismiss!(current_user)
|
|
73
|
+
broadcast_thread_update(thread)
|
|
74
|
+
|
|
75
|
+
render json: { thread_id: thread.id, status: thread.status }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reply
|
|
79
|
+
thread = @plan.comment_threads.find_by(id: params[:id])
|
|
80
|
+
unless thread
|
|
81
|
+
render json: { error: "Comment thread not found" }, status: :not_found
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
comment = thread.comments.create!(
|
|
86
|
+
author_type: api_author_type,
|
|
87
|
+
author_id: api_actor_id,
|
|
88
|
+
body_markdown: params[:body_markdown],
|
|
89
|
+
agent_name: params[:agent_name]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
broadcast_new_comment(thread, comment)
|
|
93
|
+
|
|
94
|
+
render json: {
|
|
95
|
+
comment_id: comment.id,
|
|
96
|
+
thread_id: thread.id,
|
|
97
|
+
created_at: comment.created_at
|
|
98
|
+
}, status: :created
|
|
99
|
+
|
|
100
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
101
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def broadcast_new_thread(thread)
|
|
107
|
+
Broadcaster.prepend_to(
|
|
108
|
+
@plan,
|
|
109
|
+
target: "comment-threads",
|
|
110
|
+
partial: "coplan/comment_threads/thread",
|
|
111
|
+
locals: { thread: thread, plan: @plan }
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def broadcast_thread_update(thread)
|
|
116
|
+
Broadcaster.replace_to(
|
|
117
|
+
@plan,
|
|
118
|
+
target: ActionView::RecordIdentifier.dom_id(thread),
|
|
119
|
+
partial: "coplan/comment_threads/thread",
|
|
120
|
+
locals: { thread: thread, plan: @plan }
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def broadcast_new_comment(thread, comment)
|
|
125
|
+
Broadcaster.append_to(
|
|
126
|
+
@plan,
|
|
127
|
+
target: ActionView::RecordIdentifier.dom_id(thread, :comments),
|
|
128
|
+
partial: "coplan/comments/comment",
|
|
129
|
+
locals: { comment: comment }
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class LeasesController < BaseController
|
|
5
|
+
before_action :set_plan
|
|
6
|
+
before_action :authorize_plan_access!
|
|
7
|
+
|
|
8
|
+
def create
|
|
9
|
+
lease_token = params[:lease_token] || SecureRandom.hex(32)
|
|
10
|
+
|
|
11
|
+
lease = EditLease.acquire!(
|
|
12
|
+
plan: @plan,
|
|
13
|
+
holder_type: ApiToken::HOLDER_TYPE,
|
|
14
|
+
holder_id: api_actor_id,
|
|
15
|
+
lease_token: lease_token
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
render json: {
|
|
19
|
+
lease_token: lease_token,
|
|
20
|
+
expires_at: lease.expires_at,
|
|
21
|
+
plan_id: @plan.id
|
|
22
|
+
}, status: :created
|
|
23
|
+
|
|
24
|
+
rescue EditLease::Conflict => e
|
|
25
|
+
render json: { error: e.message }, status: :conflict
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update
|
|
29
|
+
lease = @plan.edit_lease
|
|
30
|
+
unless lease
|
|
31
|
+
render json: { error: "No active lease for this plan" }, status: :not_found
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
lease.renew!(lease_token: params[:lease_token])
|
|
36
|
+
render json: {
|
|
37
|
+
lease_token: params[:lease_token],
|
|
38
|
+
expires_at: lease.expires_at,
|
|
39
|
+
plan_id: @plan.id
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
rescue EditLease::Conflict => e
|
|
43
|
+
render json: { error: e.message }, status: :conflict
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def destroy
|
|
47
|
+
lease = @plan.edit_lease
|
|
48
|
+
unless lease
|
|
49
|
+
render json: { error: "No active lease for this plan" }, status: :not_found
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
lease.release!(lease_token: params[:lease_token])
|
|
54
|
+
head :no_content
|
|
55
|
+
|
|
56
|
+
rescue EditLease::Conflict => e
|
|
57
|
+
render json: { error: e.message }, status: :conflict
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class OperationsController < BaseController
|
|
5
|
+
before_action :set_plan
|
|
6
|
+
before_action :authorize_plan_access!
|
|
7
|
+
|
|
8
|
+
def create
|
|
9
|
+
operations = params[:operations]
|
|
10
|
+
base_revision = params[:base_revision]&.to_i
|
|
11
|
+
|
|
12
|
+
unless base_revision.present?
|
|
13
|
+
render json: { error: "base_revision is required" }, status: :unprocessable_entity
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
unless operations.is_a?(Array) && operations.any?
|
|
18
|
+
render json: { error: "operations must be a non-empty array" }, status: :unprocessable_entity
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if params[:session_id].present?
|
|
23
|
+
apply_with_session(operations, base_revision)
|
|
24
|
+
elsif params[:lease_token].present?
|
|
25
|
+
apply_with_lease(operations, base_revision)
|
|
26
|
+
else
|
|
27
|
+
apply_direct(operations, base_revision)
|
|
28
|
+
end
|
|
29
|
+
rescue Plans::OperationError => e
|
|
30
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def apply_with_session(operations, base_revision)
|
|
36
|
+
session = @plan.edit_sessions.find_by(id: params[:session_id], actor_id: api_actor_id)
|
|
37
|
+
unless session&.active?
|
|
38
|
+
render json: { error: "Edit session not found, expired, or not open" }, status: :not_found
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
applied_count = nil
|
|
43
|
+
ActiveRecord::Base.transaction do
|
|
44
|
+
session.lock!
|
|
45
|
+
|
|
46
|
+
unless session.active?
|
|
47
|
+
render json: { error: "Edit session is no longer active" }, status: :conflict
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Use draft_content if we've already applied ops, otherwise use the
|
|
52
|
+
# session's base revision snapshot so resolved ranges stay consistent
|
|
53
|
+
# with base_revision (not the potentially-advanced current content).
|
|
54
|
+
working_content = session.draft_content
|
|
55
|
+
unless working_content
|
|
56
|
+
base_version = @plan.plan_versions.find_by(revision: session.base_revision)
|
|
57
|
+
working_content = base_version&.content_markdown || @plan.current_content || ""
|
|
58
|
+
end
|
|
59
|
+
result = Plans::ApplyOperations.call(content: working_content, operations: operations)
|
|
60
|
+
|
|
61
|
+
session.update!(
|
|
62
|
+
operations_json: session.operations_json + result[:applied],
|
|
63
|
+
draft_content: result[:content]
|
|
64
|
+
)
|
|
65
|
+
applied_count = result[:applied].length
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return if performed?
|
|
69
|
+
|
|
70
|
+
render json: {
|
|
71
|
+
session_id: session.id,
|
|
72
|
+
applied: applied_count,
|
|
73
|
+
operations_pending: session.reload.operations_json.length
|
|
74
|
+
}, status: :created
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def apply_with_lease(operations, base_revision)
|
|
78
|
+
lease_token = params[:lease_token]
|
|
79
|
+
|
|
80
|
+
lease = @plan.edit_lease
|
|
81
|
+
unless lease&.held_by?(lease_token: lease_token)
|
|
82
|
+
render json: { error: "You do not hold a valid edit lease for this plan" }, status: :conflict
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if @plan.current_revision != base_revision
|
|
87
|
+
render json: {
|
|
88
|
+
error: "Stale revision. Expected #{@plan.current_revision}, got #{base_revision}",
|
|
89
|
+
current_revision: @plan.current_revision
|
|
90
|
+
}, status: :conflict
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
create_version_from_operations(operations, base_revision: base_revision)
|
|
95
|
+
rescue EditLease::Conflict => e
|
|
96
|
+
render json: { error: e.message }, status: :conflict
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def apply_direct(operations, base_revision)
|
|
100
|
+
ActiveRecord::Base.transaction do
|
|
101
|
+
@plan.lock!
|
|
102
|
+
@plan.reload
|
|
103
|
+
|
|
104
|
+
current_content = @plan.current_content || ""
|
|
105
|
+
|
|
106
|
+
final_ops = if @plan.current_revision != base_revision
|
|
107
|
+
rebase_and_resolve(operations, base_revision, current_content)
|
|
108
|
+
else
|
|
109
|
+
operations
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return if performed? # rebase_and_resolve may have rendered a conflict
|
|
113
|
+
|
|
114
|
+
result = Plans::ApplyOperations.call(content: current_content, operations: final_ops)
|
|
115
|
+
commit_version(current_content, result)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Rebase stale operations via OT: resolve each op to character ranges
|
|
120
|
+
# against the base_revision snapshot, transform those ranges through all
|
|
121
|
+
# intervening versions' positional metadata, verify the target text still
|
|
122
|
+
# matches at the transformed position, and return ops with pre-resolved
|
|
123
|
+
# ranges. All versions MUST have positional metadata in operations_json;
|
|
124
|
+
# TransformRange raises Conflict if any operation lacks it.
|
|
125
|
+
def rebase_and_resolve(operations, base_revision, current_content)
|
|
126
|
+
stale_gap = @plan.current_revision - base_revision
|
|
127
|
+
if stale_gap > 20
|
|
128
|
+
render json: {
|
|
129
|
+
error: "Too stale — #{stale_gap} revisions behind (max 20). Re-read the plan.",
|
|
130
|
+
current_revision: @plan.current_revision
|
|
131
|
+
}, status: :conflict
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
base_version = @plan.plan_versions.find_by(revision: base_revision)
|
|
136
|
+
unless base_version
|
|
137
|
+
render json: { error: "Base revision #{base_revision} not found" }, status: :conflict
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
intervening_versions = @plan.plan_versions
|
|
142
|
+
.where("revision > ? AND revision <= ?", base_revision, @plan.current_revision)
|
|
143
|
+
.order(revision: :asc)
|
|
144
|
+
.to_a
|
|
145
|
+
|
|
146
|
+
working_base = base_version.content_markdown
|
|
147
|
+
verification_content = current_content.dup
|
|
148
|
+
rebased_ops = []
|
|
149
|
+
|
|
150
|
+
operations.each do |op|
|
|
151
|
+
op = op.respond_to?(:to_unsafe_h) ? op.to_unsafe_h.transform_keys(&:to_s) : op.transform_keys(&:to_s)
|
|
152
|
+
begin
|
|
153
|
+
resolution = Plans::PositionResolver.call(content: working_base, operation: op)
|
|
154
|
+
transformed_ranges = resolution.ranges.map do |range|
|
|
155
|
+
Plans::TransformRange.transform_through_versions(range, intervening_versions)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
verify_transformed_ranges!(op, transformed_ranges, verification_content)
|
|
159
|
+
return if performed?
|
|
160
|
+
|
|
161
|
+
# Advance the working base snapshot so the next op resolves
|
|
162
|
+
# against the result of this one (sequential semantics).
|
|
163
|
+
apply_result = Plans::ApplyOperations.call(content: working_base, operations: [op])
|
|
164
|
+
working_base = apply_result[:content]
|
|
165
|
+
|
|
166
|
+
rebased_op = op.dup
|
|
167
|
+
rebased_op["_pre_resolved_ranges"] = transformed_ranges
|
|
168
|
+
rebased_ops << rebased_op
|
|
169
|
+
|
|
170
|
+
# Advance verification content so the next op's conflict check
|
|
171
|
+
# runs against the incrementally updated snapshot.
|
|
172
|
+
verify_step = Plans::ApplyOperations.call(content: verification_content, operations: [rebased_op])
|
|
173
|
+
verification_content = verify_step[:content]
|
|
174
|
+
rescue Plans::TransformRange::Conflict => e
|
|
175
|
+
render json: {
|
|
176
|
+
error: "Conflict: #{e.message}",
|
|
177
|
+
current_revision: @plan.current_revision
|
|
178
|
+
}, status: :conflict
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
rebased_ops
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def create_version_from_operations(operations, base_revision:)
|
|
187
|
+
ActiveRecord::Base.transaction do
|
|
188
|
+
@plan.lock!
|
|
189
|
+
@plan.reload
|
|
190
|
+
|
|
191
|
+
if @plan.current_revision != base_revision
|
|
192
|
+
render json: {
|
|
193
|
+
error: "Stale revision. Expected #{@plan.current_revision}, got #{base_revision}",
|
|
194
|
+
current_revision: @plan.current_revision
|
|
195
|
+
}, status: :conflict
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
current_content = @plan.current_content || ""
|
|
200
|
+
result = Plans::ApplyOperations.call(content: current_content, operations: operations)
|
|
201
|
+
commit_version(current_content, result)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def commit_version(current_content, result)
|
|
206
|
+
new_revision = @plan.current_revision + 1
|
|
207
|
+
diff = Diffy::Diff.new(current_content, result[:content]).to_s
|
|
208
|
+
|
|
209
|
+
version = PlanVersion.create!(
|
|
210
|
+
plan: @plan,
|
|
211
|
+
revision: new_revision,
|
|
212
|
+
content_markdown: result[:content],
|
|
213
|
+
actor_type: api_author_type,
|
|
214
|
+
actor_id: api_actor_id,
|
|
215
|
+
change_summary: params[:change_summary],
|
|
216
|
+
diff_unified: diff.presence,
|
|
217
|
+
operations_json: result[:applied],
|
|
218
|
+
base_revision: params[:base_revision]&.to_i,
|
|
219
|
+
reason: params[:reason]
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@plan.update!(
|
|
223
|
+
current_plan_version: version,
|
|
224
|
+
current_revision: new_revision
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@plan.comment_threads.mark_out_of_date_for_new_version!(version)
|
|
228
|
+
|
|
229
|
+
broadcast_plan_update
|
|
230
|
+
|
|
231
|
+
render json: {
|
|
232
|
+
revision: new_revision,
|
|
233
|
+
content_sha256: version.content_sha256,
|
|
234
|
+
applied: result[:applied].length,
|
|
235
|
+
version_id: version.id
|
|
236
|
+
}, status: :created
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def verify_transformed_ranges!(op, transformed_ranges, content)
|
|
240
|
+
case op["op"]
|
|
241
|
+
when "replace_exact"
|
|
242
|
+
return unless op["old_text"]
|
|
243
|
+
transformed_ranges.each do |tr|
|
|
244
|
+
actual = content[tr[0]...tr[1]]
|
|
245
|
+
unless actual == op["old_text"]
|
|
246
|
+
render json: {
|
|
247
|
+
error: "Conflict: text at target position has changed",
|
|
248
|
+
current_revision: @plan.current_revision,
|
|
249
|
+
expected: op["old_text"],
|
|
250
|
+
found: actual
|
|
251
|
+
}, status: :conflict
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
when "insert_under_heading"
|
|
256
|
+
return unless op["heading"]
|
|
257
|
+
transformed_ranges.each do |tr|
|
|
258
|
+
line_start = tr[0] > 0 ? (content.rindex("\n", tr[0] - 1) || -1) + 1 : 0
|
|
259
|
+
line_text = content[line_start...tr[0]]
|
|
260
|
+
unless line_text&.match?(/\A#{Regexp.escape(op["heading"])}\s*\z/)
|
|
261
|
+
render json: {
|
|
262
|
+
error: "Conflict: heading at target position has changed",
|
|
263
|
+
current_revision: @plan.current_revision,
|
|
264
|
+
expected: op["heading"],
|
|
265
|
+
found: line_text
|
|
266
|
+
}, status: :conflict
|
|
267
|
+
return
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
when "delete_paragraph_containing"
|
|
271
|
+
return unless op["needle"]
|
|
272
|
+
transformed_ranges.each do |tr|
|
|
273
|
+
actual = content[tr[0]...tr[1]]
|
|
274
|
+
unless actual&.include?(op["needle"])
|
|
275
|
+
render json: {
|
|
276
|
+
error: "Conflict: paragraph no longer contains the expected text",
|
|
277
|
+
current_revision: @plan.current_revision,
|
|
278
|
+
expected_needle: op["needle"],
|
|
279
|
+
found: actual
|
|
280
|
+
}, status: :conflict
|
|
281
|
+
return
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def broadcast_plan_update
|
|
288
|
+
Broadcaster.replace_to(
|
|
289
|
+
@plan,
|
|
290
|
+
target: "plan-header",
|
|
291
|
+
partial: "coplan/plans/header",
|
|
292
|
+
locals: { plan: @plan }
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class PlansController < BaseController
|
|
5
|
+
before_action :set_plan, only: [:show, :update, :versions, :comments]
|
|
6
|
+
before_action :authorize_plan_access!, only: [:show, :update, :versions, :comments]
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
plans = Plan
|
|
10
|
+
.where.not(status: "brainstorm")
|
|
11
|
+
.or(Plan.where(created_by_user: current_user))
|
|
12
|
+
.order(updated_at: :desc)
|
|
13
|
+
plans = plans.where(status: params[:status]) if params[:status].present?
|
|
14
|
+
render json: plans.map { |p| plan_json(p) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
render json: plan_json(@plan).merge(
|
|
19
|
+
current_content: @plan.current_content,
|
|
20
|
+
current_revision: @plan.current_revision
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
plan = Plans::Create.call(
|
|
26
|
+
title: params[:title],
|
|
27
|
+
content: params[:content] || "",
|
|
28
|
+
user: current_user
|
|
29
|
+
)
|
|
30
|
+
render json: plan_json(plan).merge(
|
|
31
|
+
current_content: plan.current_content,
|
|
32
|
+
current_revision: plan.current_revision
|
|
33
|
+
), status: :created
|
|
34
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
35
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update
|
|
39
|
+
policy = PlanPolicy.new(current_user, @plan)
|
|
40
|
+
unless policy.update?
|
|
41
|
+
return render json: { error: "Not authorized" }, status: :forbidden
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
permitted = {}
|
|
45
|
+
permitted[:title] = params[:title] if params.key?(:title)
|
|
46
|
+
permitted[:status] = params[:status] if params.key?(:status)
|
|
47
|
+
permitted[:tags] = params[:tags] if params.key?(:tags)
|
|
48
|
+
|
|
49
|
+
@plan.update!(permitted)
|
|
50
|
+
|
|
51
|
+
if @plan.saved_changes?
|
|
52
|
+
Broadcaster.replace_to(@plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: @plan })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if permitted.key?(:status) && @plan.saved_change_to_status?
|
|
56
|
+
Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: permitted[:status], triggered_by: current_user)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
render json: plan_json(@plan).merge(
|
|
60
|
+
current_content: @plan.current_content,
|
|
61
|
+
current_revision: @plan.current_revision
|
|
62
|
+
)
|
|
63
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
64
|
+
render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def versions
|
|
68
|
+
versions = @plan.plan_versions.order(revision: :desc)
|
|
69
|
+
render json: versions.map { |v| version_json(v) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def comments
|
|
73
|
+
threads = @plan.comment_threads.includes(:comments, :created_by_user).order(created_at: :desc)
|
|
74
|
+
render json: threads.map { |t| thread_json(t) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def plan_json(plan)
|
|
80
|
+
{
|
|
81
|
+
id: plan.id,
|
|
82
|
+
title: plan.title,
|
|
83
|
+
status: plan.status,
|
|
84
|
+
current_revision: plan.current_revision,
|
|
85
|
+
tags: plan.tags,
|
|
86
|
+
created_by: plan.created_by_user.name,
|
|
87
|
+
created_at: plan.created_at,
|
|
88
|
+
updated_at: plan.updated_at
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def version_json(version)
|
|
93
|
+
{
|
|
94
|
+
id: version.id,
|
|
95
|
+
revision: version.revision,
|
|
96
|
+
content_sha256: version.content_sha256,
|
|
97
|
+
actor_type: version.actor_type,
|
|
98
|
+
change_summary: version.change_summary,
|
|
99
|
+
created_at: version.created_at
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def thread_json(thread)
|
|
104
|
+
{
|
|
105
|
+
id: thread.id,
|
|
106
|
+
status: thread.status,
|
|
107
|
+
anchor_text: thread.anchor_text,
|
|
108
|
+
anchor_context: thread.anchor_context_with_highlight,
|
|
109
|
+
anchor_valid: thread.anchor_valid?,
|
|
110
|
+
start_line: thread.start_line,
|
|
111
|
+
end_line: thread.end_line,
|
|
112
|
+
out_of_date: thread.out_of_date,
|
|
113
|
+
created_by: thread.created_by_user.name,
|
|
114
|
+
created_at: thread.created_at,
|
|
115
|
+
comments: thread.comments.order(created_at: :asc).map { |c|
|
|
116
|
+
{
|
|
117
|
+
id: c.id,
|
|
118
|
+
author_type: c.author_type,
|
|
119
|
+
agent_name: c.agent_name,
|
|
120
|
+
body_markdown: c.body_markdown,
|
|
121
|
+
created_at: c.created_at
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|