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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/stylesheets/coplan/application.css +956 -0
  3. data/app/controllers/coplan/api/v1/base_controller.rb +75 -0
  4. data/app/controllers/coplan/api/v1/comments_controller.rb +135 -0
  5. data/app/controllers/coplan/api/v1/leases_controller.rb +62 -0
  6. data/app/controllers/coplan/api/v1/operations_controller.rb +298 -0
  7. data/app/controllers/coplan/api/v1/plans_controller.rb +129 -0
  8. data/app/controllers/coplan/api/v1/sessions_controller.rb +92 -0
  9. data/app/controllers/coplan/application_controller.rb +78 -0
  10. data/app/controllers/coplan/automated_reviews_controller.rb +29 -0
  11. data/app/controllers/coplan/comment_threads_controller.rb +148 -0
  12. data/app/controllers/coplan/comments_controller.rb +40 -0
  13. data/app/controllers/coplan/dashboard_controller.rb +6 -0
  14. data/app/controllers/coplan/plan_versions_controller.rb +29 -0
  15. data/app/controllers/coplan/plans_controller.rb +53 -0
  16. data/app/controllers/coplan/settings/tokens_controller.rb +37 -0
  17. data/app/helpers/coplan/application_helper.rb +4 -0
  18. data/app/helpers/coplan/comments_helper.rb +20 -0
  19. data/app/helpers/coplan/markdown_helper.rb +36 -0
  20. data/app/javascript/controllers/coplan/dropdown_controller.js +25 -0
  21. data/app/javascript/controllers/coplan/line_selection_controller.js +112 -0
  22. data/app/javascript/controllers/coplan/tabs_controller.js +18 -0
  23. data/app/javascript/controllers/coplan/text_selection_controller.js +376 -0
  24. data/app/jobs/coplan/application_job.rb +4 -0
  25. data/app/jobs/coplan/automated_review_job.rb +71 -0
  26. data/app/jobs/coplan/commit_expired_session_job.rb +29 -0
  27. data/app/jobs/coplan/notification_job.rb +10 -0
  28. data/app/models/coplan/api_token.rb +50 -0
  29. data/app/models/coplan/application_record.rb +14 -0
  30. data/app/models/coplan/automated_plan_reviewer.rb +62 -0
  31. data/app/models/coplan/comment.rb +24 -0
  32. data/app/models/coplan/comment_thread.rb +151 -0
  33. data/app/models/coplan/current.rb +5 -0
  34. data/app/models/coplan/edit_lease.rb +57 -0
  35. data/app/models/coplan/edit_session.rb +61 -0
  36. data/app/models/coplan/plan.rb +28 -0
  37. data/app/models/coplan/plan_collaborator.rb +12 -0
  38. data/app/models/coplan/plan_version.rb +23 -0
  39. data/app/models/coplan/user.rb +20 -0
  40. data/app/policies/coplan/application_policy.rb +14 -0
  41. data/app/policies/coplan/comment_thread_policy.rb +23 -0
  42. data/app/policies/coplan/plan_policy.rb +15 -0
  43. data/app/services/coplan/ai_providers/anthropic.rb +21 -0
  44. data/app/services/coplan/ai_providers/open_ai.rb +44 -0
  45. data/app/services/coplan/broadcaster.rb +32 -0
  46. data/app/services/coplan/plans/apply_operations.rb +128 -0
  47. data/app/services/coplan/plans/commit_session.rb +186 -0
  48. data/app/services/coplan/plans/create.rb +30 -0
  49. data/app/services/coplan/plans/operation_error.rb +5 -0
  50. data/app/services/coplan/plans/position_resolver.rb +191 -0
  51. data/app/services/coplan/plans/review_prompt_formatter.rb +25 -0
  52. data/app/services/coplan/plans/review_response_parser.rb +65 -0
  53. data/app/services/coplan/plans/transform_range.rb +99 -0
  54. data/app/services/coplan/plans/trigger_automated_reviews.rb +33 -0
  55. data/app/views/coplan/comment_threads/_new_comment_form.html.erb +17 -0
  56. data/app/views/coplan/comment_threads/_reply_form.html.erb +8 -0
  57. data/app/views/coplan/comment_threads/_thread.html.erb +47 -0
  58. data/app/views/coplan/comments/_comment.html.erb +9 -0
  59. data/app/views/coplan/dashboard/show.html.erb +8 -0
  60. data/app/views/coplan/plan_versions/index.html.erb +21 -0
  61. data/app/views/coplan/plan_versions/show.html.erb +29 -0
  62. data/app/views/coplan/plans/_header.html.erb +26 -0
  63. data/app/views/coplan/plans/edit.html.erb +15 -0
  64. data/app/views/coplan/plans/index.html.erb +35 -0
  65. data/app/views/coplan/plans/show.html.erb +56 -0
  66. data/app/views/coplan/settings/tokens/_form.html.erb +8 -0
  67. data/app/views/coplan/settings/tokens/_token_reveal.html.erb +7 -0
  68. data/app/views/coplan/settings/tokens/_token_row.html.erb +24 -0
  69. data/app/views/coplan/settings/tokens/create.turbo_stream.erb +11 -0
  70. data/app/views/coplan/settings/tokens/destroy.turbo_stream.erb +5 -0
  71. data/app/views/coplan/settings/tokens/index.html.erb +32 -0
  72. data/app/views/layouts/coplan/application.html.erb +42 -0
  73. data/config/importmap.rb +1 -0
  74. data/config/routes.rb +41 -0
  75. data/db/migrate/20260226200000_create_coplan_schema.rb +173 -0
  76. data/lib/coplan/configuration.rb +17 -0
  77. data/lib/coplan/engine.rb +34 -0
  78. data/lib/coplan/version.rb +3 -0
  79. data/lib/coplan.rb +15 -0
  80. data/lib/tasks/coplan.rake +4 -0
  81. data/prompts/reviewers/routing.md +14 -0
  82. data/prompts/reviewers/scalability.md +14 -0
  83. data/prompts/reviewers/security.md +14 -0
  84. 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