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,92 @@
1
+ module CoPlan
2
+ module Api
3
+ module V1
4
+ class SessionsController < BaseController
5
+ before_action :set_plan
6
+ before_action :authorize_plan_access!
7
+ before_action :set_session, only: [:show, :commit]
8
+
9
+ # POST /api/v1/plans/:plan_id/sessions
10
+ # Cloud personas create sessions via direct Ruby service calls, not this endpoint.
11
+ def create
12
+ actor_type = params[:actor_type].presence || ApiToken::HOLDER_TYPE
13
+ unless EditSession::ACTOR_TYPES.include?(actor_type)
14
+ render json: { error: "Invalid actor_type" }, status: :unprocessable_entity
15
+ return
16
+ end
17
+ ttl = actor_type == "cloud_persona" ? EditSession::CLOUD_PERSONA_TTL : EditSession::LOCAL_AGENT_TTL
18
+
19
+ session = EditSession.create!(
20
+ plan: @plan,
21
+ actor_type: actor_type,
22
+ actor_id: api_actor_id,
23
+ base_revision: @plan.current_revision,
24
+ expires_at: ttl.from_now
25
+ )
26
+
27
+ render json: session_json(session), status: :created
28
+ end
29
+
30
+ # GET /api/v1/plans/:plan_id/sessions/:id
31
+ def show
32
+ render json: session_json(@session).merge(
33
+ operations_count: @session.operations_json.length,
34
+ has_draft: @session.draft_content.present?
35
+ )
36
+ end
37
+
38
+ # POST /api/v1/plans/:plan_id/sessions/:id/commit
39
+ def commit
40
+ result = Plans::CommitSession.call(
41
+ session: @session,
42
+ change_summary: params[:change_summary]
43
+ )
44
+
45
+ response = {
46
+ session_id: @session.id,
47
+ status: @session.status,
48
+ committed_at: @session.committed_at
49
+ }
50
+
51
+ if result[:version]
52
+ response[:revision] = result[:version].revision
53
+ response[:version_id] = result[:version].id
54
+ response[:content_sha256] = result[:version].content_sha256
55
+ end
56
+
57
+ render json: response
58
+ rescue Plans::CommitSession::SessionNotOpenError => e
59
+ render json: { error: e.message }, status: :unprocessable_entity
60
+ rescue Plans::CommitSession::StaleSessionError => e
61
+ render json: { error: e.message }, status: :conflict
62
+ rescue Plans::CommitSession::SessionConflictError => e
63
+ render json: {
64
+ error: e.message,
65
+ current_revision: @plan.reload.current_revision
66
+ }, status: :conflict
67
+ end
68
+
69
+ private
70
+
71
+ def set_session
72
+ @session = @plan.edit_sessions.find_by(id: params[:id], actor_id: api_actor_id)
73
+ unless @session
74
+ render json: { error: "Edit session not found" }, status: :not_found
75
+ end
76
+ end
77
+
78
+ def session_json(session)
79
+ {
80
+ id: session.id,
81
+ plan_id: session.plan_id,
82
+ status: session.status,
83
+ actor_type: session.actor_type,
84
+ base_revision: session.base_revision,
85
+ expires_at: session.expires_at,
86
+ created_at: session.created_at
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,78 @@
1
+ module CoPlan
2
+ class ApplicationController < ::ApplicationController
3
+ layout "coplan/application"
4
+
5
+ # CoPlan.underscore produces "co_plan", but our views/templates use "coplan/"
6
+ def self.controller_path
7
+ super.sub(/\Aco_plan\//, "coplan/")
8
+ end
9
+
10
+ helper CoPlan::ApplicationHelper
11
+ helper CoPlan::MarkdownHelper
12
+ helper CoPlan::CommentsHelper
13
+
14
+ # Skip host auth — CoPlan handles authentication internally via config.authenticate
15
+ skip_before_action :authenticate_user!, raise: false
16
+
17
+ before_action :authenticate_coplan_user!
18
+ before_action :set_coplan_current
19
+
20
+ helper_method :current_user, :signed_in?
21
+
22
+ class NotAuthorizedError < StandardError; end
23
+
24
+ rescue_from NotAuthorizedError do
25
+ head :not_found
26
+ end
27
+
28
+ private
29
+
30
+ def current_user
31
+ @current_coplan_user
32
+ end
33
+
34
+ def signed_in?
35
+ current_user.present?
36
+ end
37
+
38
+ def authenticate_coplan_user!
39
+ callback = CoPlan.configuration.authenticate
40
+ unless callback
41
+ raise "CoPlan.configure { |c| c.authenticate = ->(request) { ... } } is required"
42
+ end
43
+
44
+ attrs = callback.call(request)
45
+ unless attrs && attrs[:external_id].present?
46
+ if CoPlan.configuration.sign_in_path
47
+ redirect_to CoPlan.configuration.sign_in_path, alert: "Please sign in."
48
+ else
49
+ head :unauthorized
50
+ end
51
+ return
52
+ end
53
+
54
+ external_id = attrs[:external_id].to_s
55
+ @current_coplan_user = CoPlan::User.find_or_initialize_by(external_id: external_id)
56
+ @current_coplan_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact)
57
+ if @current_coplan_user.new_record? || @current_coplan_user.changed?
58
+ @current_coplan_user.save!
59
+ end
60
+ rescue ActiveRecord::RecordNotUnique
61
+ @current_coplan_user = CoPlan::User.find_by!(external_id: external_id)
62
+ @current_coplan_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact)
63
+ @current_coplan_user.save! if @current_coplan_user.changed?
64
+ end
65
+
66
+ def set_coplan_current
67
+ CoPlan::Current.user = current_user
68
+ end
69
+
70
+ def authorize!(record, action)
71
+ policy_class = "CoPlan::#{record.class.name.demodulize}Policy".constantize
72
+ policy = policy_class.new(current_user, record)
73
+ unless policy.public_send(action)
74
+ raise NotAuthorizedError
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,29 @@
1
+ module CoPlan
2
+ class AutomatedReviewsController < ApplicationController
3
+ before_action :set_plan
4
+ before_action :set_reviewer, only: [:create]
5
+
6
+ def create
7
+ authorize!(@plan, :update?)
8
+
9
+ AutomatedReviewJob.perform_later(
10
+ plan_id: @plan.id,
11
+ reviewer_id: @reviewer.id,
12
+ plan_version_id: @plan.current_plan_version_id,
13
+ triggered_by: current_user
14
+ )
15
+
16
+ redirect_to plan_path(@plan), notice: "#{@reviewer.name} review queued."
17
+ end
18
+
19
+ private
20
+
21
+ def set_plan
22
+ @plan = Plan.find(params[:plan_id])
23
+ end
24
+
25
+ def set_reviewer
26
+ @reviewer = AutomatedPlanReviewer.enabled.find(params[:reviewer_id])
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,148 @@
1
+ module CoPlan
2
+ class CommentThreadsController < ApplicationController
3
+ include ActionView::RecordIdentifier
4
+
5
+ before_action :set_plan
6
+ before_action :set_thread, only: [:resolve, :accept, :dismiss, :reopen]
7
+
8
+ def create
9
+ authorize!(@plan, :show?)
10
+
11
+ thread = @plan.comment_threads.new(
12
+ plan_version: @plan.current_plan_version,
13
+ anchor_text: params[:comment_thread][:anchor_text].presence,
14
+ anchor_context: params[:comment_thread][:anchor_context].presence,
15
+ start_line: params[:comment_thread][:start_line].presence,
16
+ end_line: params[:comment_thread][:end_line].presence,
17
+ created_by_user: current_user
18
+ )
19
+
20
+ thread.save!
21
+
22
+ comment = thread.comments.create!(
23
+ author_type: "human",
24
+ author_id: current_user.id,
25
+ body_markdown: params[:comment_thread][:body_markdown]
26
+ )
27
+
28
+ broadcast_new_thread(thread)
29
+ broadcast_tab_counts
30
+
31
+ respond_with_stream_or_redirect("Comment added.")
32
+ end
33
+
34
+ def resolve
35
+ authorize!(@thread, :resolve?)
36
+ @thread.resolve!(current_user)
37
+ broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
38
+ respond_with_stream_or_redirect("Thread resolved.")
39
+ end
40
+
41
+ def accept
42
+ authorize!(@thread, :accept?)
43
+ @thread.accept!(current_user)
44
+ broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
45
+ respond_with_stream_or_redirect("Thread accepted.")
46
+ end
47
+
48
+ def dismiss
49
+ authorize!(@thread, :dismiss?)
50
+ @thread.dismiss!(current_user)
51
+ broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
52
+ respond_with_stream_or_redirect("Thread dismissed.")
53
+ end
54
+
55
+ def reopen
56
+ authorize!(@thread, :reopen?)
57
+ @thread.update!(status: "open", resolved_by_user: nil)
58
+ # Out-of-date threads stay in the archived list even when reopened,
59
+ # since the active scope excludes out_of_date rows.
60
+ if @thread.out_of_date?
61
+ broadcast_thread_replace(@thread)
62
+ else
63
+ broadcast_thread_move(@thread, from: "resolved-comment-threads", to: "comment-threads")
64
+ end
65
+ respond_with_stream_or_redirect("Thread reopened.")
66
+ end
67
+
68
+ private
69
+
70
+ def set_plan
71
+ @plan = Plan.find(params[:plan_id])
72
+ end
73
+
74
+ def set_thread
75
+ @thread = @plan.comment_threads.find(params[:id])
76
+ end
77
+
78
+ def broadcast_new_thread(thread)
79
+ Broadcaster.prepend_to(
80
+ @plan,
81
+ target: "comment-threads",
82
+ partial: "coplan/comment_threads/thread",
83
+ locals: { thread: thread, plan: @plan }
84
+ )
85
+ end
86
+
87
+ # Broadcasts update all clients (including the submitter) via WebSocket.
88
+ # The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
89
+ def respond_with_stream_or_redirect(message)
90
+ respond_to do |format|
91
+ format.turbo_stream { render turbo_stream: [] }
92
+ format.html { redirect_to plan_path(@plan), notice: message }
93
+ end
94
+ end
95
+
96
+ # Replaces a thread in place (status changed but stays in the same list).
97
+ def broadcast_thread_replace(thread)
98
+ Broadcaster.replace_to(
99
+ @plan,
100
+ target: dom_id(thread),
101
+ partial: "coplan/comment_threads/thread",
102
+ locals: { thread: thread, plan: @plan }
103
+ )
104
+ broadcast_tab_counts
105
+ end
106
+
107
+ # Moves a thread between Open/Resolved lists and updates tab counts.
108
+ def broadcast_thread_move(thread, from:, to:)
109
+ Broadcaster.remove_to(@plan, target: dom_id(thread))
110
+ Broadcaster.append_to(
111
+ @plan,
112
+ target: to,
113
+ partial: "coplan/comment_threads/thread",
114
+ locals: { thread: thread, plan: @plan }
115
+ )
116
+ broadcast_tab_counts
117
+ end
118
+
119
+ def broadcast_tab_counts
120
+ threads = @plan.comment_threads
121
+ open_count = threads.active.count
122
+ resolved_count = threads.archived.count
123
+
124
+ Broadcaster.update_to(
125
+ @plan,
126
+ target: "open-thread-count",
127
+ html: open_count > 0 ? open_count.to_s : ""
128
+ )
129
+ Broadcaster.update_to(
130
+ @plan,
131
+ target: "resolved-thread-count",
132
+ html: resolved_count > 0 ? resolved_count.to_s : ""
133
+ )
134
+
135
+ # Toggle empty-state placeholders
136
+ Broadcaster.replace_to(
137
+ @plan,
138
+ target: "open-threads-empty",
139
+ html: %(<p class="text-sm text-muted" id="open-threads-empty" #{'style="display: none;"' if open_count > 0}>No open comments.</p>)
140
+ )
141
+ Broadcaster.replace_to(
142
+ @plan,
143
+ target: "resolved-threads-empty",
144
+ html: %(<p class="text-sm text-muted" id="resolved-threads-empty" #{'style="display: none;"' if resolved_count > 0}>No resolved comments.</p>)
145
+ )
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,40 @@
1
+ module CoPlan
2
+ class CommentsController < ApplicationController
3
+ before_action :set_plan
4
+ before_action :set_thread
5
+
6
+ def create
7
+ authorize!(@plan, :show?)
8
+
9
+ comment = @thread.comments.create!(
10
+ author_type: "human",
11
+ author_id: current_user.id,
12
+ body_markdown: params[:comment][:body_markdown]
13
+ )
14
+
15
+ Broadcaster.append_to(
16
+ @plan,
17
+ target: ActionView::RecordIdentifier.dom_id(@thread, :comments),
18
+ partial: "coplan/comments/comment",
19
+ locals: { comment: comment }
20
+ )
21
+
22
+ # The broadcast above updates all clients (including the submitter) via WebSocket.
23
+ # The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
24
+ respond_to do |format|
25
+ format.turbo_stream { render turbo_stream: [] }
26
+ format.html { redirect_to plan_path(@plan), notice: "Reply added." }
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def set_plan
33
+ @plan = Plan.find(params[:plan_id])
34
+ end
35
+
36
+ def set_thread
37
+ @thread = @plan.comment_threads.find(params[:comment_thread_id])
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,6 @@
1
+ module CoPlan
2
+ class DashboardController < ApplicationController
3
+ def show
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,29 @@
1
+ module CoPlan
2
+ class PlanVersionsController < ApplicationController
3
+ before_action :set_plan
4
+ before_action :set_version, only: [:show]
5
+
6
+ def index
7
+ authorize!(@plan, :show?)
8
+ @versions = @plan.plan_versions.order(revision: :desc)
9
+ end
10
+
11
+ def show
12
+ authorize!(@plan, :show?)
13
+ @previous_version = @plan.plan_versions.find_by(revision: @version.revision - 1)
14
+ if @previous_version
15
+ @diff = Diffy::Diff.new(@previous_version.content_markdown, @version.content_markdown, include_plus_and_minus_in_html: true, context: 3)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def set_plan
22
+ @plan = Plan.find(params[:plan_id])
23
+ end
24
+
25
+ def set_version
26
+ @version = @plan.plan_versions.find(params[:id])
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,53 @@
1
+ module CoPlan
2
+ class PlansController < ApplicationController
3
+ before_action :set_plan, only: [:show, :edit, :update, :update_status]
4
+
5
+ def index
6
+ @plans = Plan.order(updated_at: :desc)
7
+ @plans = @plans.where(status: params[:status]) if params[:status].present?
8
+ @plans = @plans.where(created_by_user: current_user) if params[:scope] == "mine"
9
+ end
10
+
11
+ def show
12
+ authorize!(@plan, :show?)
13
+ threads = @plan.comment_threads.includes(:comments, :created_by_user, :plan_version).order(created_at: :asc)
14
+ @active_threads = threads.active
15
+ @archived_threads = threads.archived
16
+ end
17
+
18
+ def edit
19
+ authorize!(@plan, :update?)
20
+ end
21
+
22
+ def update
23
+ authorize!(@plan, :update?)
24
+ @plan.update!(title: params[:plan][:title])
25
+ broadcast_plan_update(@plan)
26
+ redirect_to plan_path(@plan), notice: "Plan updated."
27
+ end
28
+
29
+ def update_status
30
+ authorize!(@plan, :update_status?)
31
+ new_status = params[:status]
32
+ if Plan::STATUSES.include?(new_status) && @plan.update(status: new_status)
33
+ broadcast_plan_update(@plan)
34
+ if @plan.saved_change_to_status?
35
+ Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: new_status, triggered_by: current_user)
36
+ end
37
+ redirect_to plan_path(@plan), notice: "Status updated to #{new_status}."
38
+ else
39
+ redirect_to plan_path(@plan), alert: "Invalid status."
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def set_plan
46
+ @plan = Plan.find(params[:id])
47
+ end
48
+
49
+ def broadcast_plan_update(plan)
50
+ Broadcaster.replace_to(plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: plan })
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ module CoPlan
2
+ module Settings
3
+ class TokensController < ApplicationController
4
+ def index
5
+ @api_tokens = current_user.api_tokens.order(created_at: :desc)
6
+ end
7
+
8
+ def create
9
+ @api_token, @raw_token = ApiToken.create_with_raw_token(user: current_user, name: params[:api_token][:name])
10
+ @api_tokens = current_user.api_tokens.order(created_at: :desc)
11
+
12
+ respond_to do |format|
13
+ format.turbo_stream
14
+ format.html do
15
+ flash[:raw_token] = @raw_token
16
+ flash[:notice] = "Token created. Copy it now — it won't be shown again."
17
+ redirect_to settings_tokens_path, status: :see_other
18
+ end
19
+ end
20
+ rescue ActiveRecord::RecordInvalid => e
21
+ @api_tokens = current_user.api_tokens.order(created_at: :desc)
22
+ flash.now[:alert] = e.message
23
+ render :index, status: :unprocessable_entity
24
+ end
25
+
26
+ def destroy
27
+ @token = current_user.api_tokens.find(params[:id])
28
+ @token.revoke!
29
+
30
+ respond_to do |format|
31
+ format.turbo_stream
32
+ format.html { redirect_to settings_tokens_path, notice: "Token revoked.", status: :see_other }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,4 @@
1
+ module CoPlan
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,20 @@
1
+ module CoPlan
2
+ module CommentsHelper
3
+ def comment_author_name(comment)
4
+ case comment.author_type
5
+ when "human"
6
+ CoPlan::User.find_by(id: comment.author_id)&.name || "Unknown"
7
+ when "local_agent"
8
+ user_name = CoPlan::User
9
+ .joins(:api_tokens)
10
+ .where(coplan_api_tokens: { id: comment.author_id })
11
+ .pick(:name) || "Agent"
12
+ comment.agent_name.present? ? "#{user_name} (#{comment.agent_name})" : user_name
13
+ when "cloud_persona"
14
+ AutomatedPlanReviewer.find_by(id: comment.author_id)&.name || "Reviewer"
15
+ else
16
+ comment.author_type
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,36 @@
1
+ module CoPlan
2
+ module MarkdownHelper
3
+ ALLOWED_TAGS = %w[
4
+ h1 h2 h3 h4 h5 h6
5
+ p div span
6
+ ul ol li
7
+ table thead tbody tfoot tr th td
8
+ pre code
9
+ a img
10
+ strong em b i u s del
11
+ blockquote hr br
12
+ dd dt dl
13
+ sup sub
14
+ ].freeze
15
+
16
+ ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze
17
+
18
+ def render_markdown(content)
19
+ html = Commonmarker.to_html(content.to_s.encode("UTF-8"), plugins: { syntax_highlighter: nil })
20
+ sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
21
+ tag.div(sanitized, class: "markdown-rendered")
22
+ end
23
+
24
+ def render_line_view(content)
25
+ lines = content.to_s.split("\n", -1)
26
+ line_divs = lines.each_with_index.map do |line, index|
27
+ n = index + 1
28
+ escaped = ERB::Util.html_escape(line)
29
+ inner = escaped.blank? ? "&nbsp;".html_safe : escaped
30
+ tag.div(inner, class: "line-view__line", id: "L#{n}", data: { line: n })
31
+ end
32
+
33
+ tag.div(safe_join(line_divs), class: "line-view", data: { controller: "line-selection" })
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu"]
5
+
6
+ toggle() {
7
+ const menu = this.menuTarget
8
+ menu.style.display = menu.style.display === "none" ? "" : "none"
9
+ }
10
+
11
+ close(event) {
12
+ if (!this.element.contains(event.target)) {
13
+ this.menuTarget.style.display = "none"
14
+ }
15
+ }
16
+
17
+ connect() {
18
+ this._closeHandler = this.close.bind(this)
19
+ document.addEventListener("click", this._closeHandler)
20
+ }
21
+
22
+ disconnect() {
23
+ document.removeEventListener("click", this._closeHandler)
24
+ }
25
+ }