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,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,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,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? ? " ".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
|
+
}
|