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,99 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
# Operational Transform engine for character-range transformations.
|
|
4
|
+
#
|
|
5
|
+
# INVARIANT: Every PlanVersion's operations_json entries MUST contain
|
|
6
|
+
# positional metadata (resolved_range + new_range/delta, or replacements).
|
|
7
|
+
# All content edits flow through Plans::ApplyOperations which stores this
|
|
8
|
+
# data. There are no legacy or non-positional edit paths.
|
|
9
|
+
#
|
|
10
|
+
# If an operation lacks positional metadata, transform_through_versions
|
|
11
|
+
# raises Conflict rather than silently skipping it.
|
|
12
|
+
class TransformRange
|
|
13
|
+
class Conflict < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Transform a range [s, e] through an edit that replaced [s2, e2] with text of length new_length.
|
|
16
|
+
# Returns the transformed [s, e] or raises Conflict if ranges overlap.
|
|
17
|
+
#
|
|
18
|
+
# edit_data is a hash with:
|
|
19
|
+
# resolved_range: [s2, e2] — the range that was replaced
|
|
20
|
+
# delta: integer — the net character change (new_length - old_length)
|
|
21
|
+
# or new_range: [s2, s2 + new_length] — can derive delta from this
|
|
22
|
+
def self.transform(range, edit_data)
|
|
23
|
+
s, e = range
|
|
24
|
+
edit_data = edit_data.transform_keys(&:to_s)
|
|
25
|
+
|
|
26
|
+
s2, e2 = edit_data["resolved_range"]
|
|
27
|
+
|
|
28
|
+
# Calculate delta from the edit
|
|
29
|
+
if edit_data.key?("new_range")
|
|
30
|
+
new_s2, new_e2 = edit_data["new_range"]
|
|
31
|
+
delta = (new_e2 - new_s2) - (e2 - s2)
|
|
32
|
+
elsif edit_data.key?("delta")
|
|
33
|
+
delta = edit_data["delta"].to_i
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "edit_data must contain 'new_range' or 'delta'"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Zero-width insert point: special handling
|
|
39
|
+
if s == e
|
|
40
|
+
# Insert point: shift if edit is strictly before
|
|
41
|
+
if e2 <= s
|
|
42
|
+
return [s + delta, e + delta]
|
|
43
|
+
elsif s2 > s
|
|
44
|
+
return [s, e]
|
|
45
|
+
else
|
|
46
|
+
raise Conflict, "Edit overlaps with insert point"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Case 1: Edit is entirely before our range (e2 <= s)
|
|
51
|
+
if e2 <= s
|
|
52
|
+
return [s + delta, e + delta]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Case 2: Edit is entirely after our range (s2 >= e)
|
|
56
|
+
if s2 >= e
|
|
57
|
+
return [s, e]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Case 3: Overlap — conflict
|
|
61
|
+
raise Conflict, "Ranges overlap: [#{s}, #{e}] conflicts with edit at [#{s2}, #{e2}]"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Transform a range through a sequence of edits from a PlanVersion.
|
|
65
|
+
# Each version has operations_json with resolved position data.
|
|
66
|
+
#
|
|
67
|
+
# versions: array of PlanVersion records (or hashes with operations_json),
|
|
68
|
+
# ordered by revision ascending
|
|
69
|
+
# Returns the transformed range or raises Conflict.
|
|
70
|
+
def self.transform_through_versions(range, versions)
|
|
71
|
+
current_range = range.dup
|
|
72
|
+
|
|
73
|
+
versions.each do |version|
|
|
74
|
+
ops = version.is_a?(Hash) ? version[:operations_json] || version["operations_json"] : version.operations_json
|
|
75
|
+
next if ops.blank?
|
|
76
|
+
|
|
77
|
+
ops.each do |op_data|
|
|
78
|
+
op_data = op_data.transform_keys(&:to_s)
|
|
79
|
+
|
|
80
|
+
if op_data.key?("replacements")
|
|
81
|
+
# replace_all: multiple ranges, each shifts independently
|
|
82
|
+
# Process in reverse order (highest position first) since each was
|
|
83
|
+
# already adjusted for previous replacements during application
|
|
84
|
+
op_data["replacements"].sort_by { |r| -r["resolved_range"][0] }.each do |replacement|
|
|
85
|
+
current_range = transform(current_range, replacement)
|
|
86
|
+
end
|
|
87
|
+
elsif op_data.key?("resolved_range")
|
|
88
|
+
current_range = transform(current_range, op_data)
|
|
89
|
+
else
|
|
90
|
+
raise Conflict, "Operation lacks positional metadata (resolved_range or replacements)"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
current_range
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
class TriggerAutomatedReviews
|
|
4
|
+
def self.call(plan:, new_status:, triggered_by:)
|
|
5
|
+
new(plan:, new_status:, triggered_by:).call
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(plan:, new_status:, triggered_by:)
|
|
9
|
+
@plan = plan
|
|
10
|
+
@new_status = new_status
|
|
11
|
+
@triggered_by = triggered_by
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
version_id = @plan.current_plan_version_id
|
|
16
|
+
return unless version_id
|
|
17
|
+
|
|
18
|
+
reviewers = CoPlan::AutomatedPlanReviewer.enabled
|
|
19
|
+
|
|
20
|
+
reviewers.each do |reviewer|
|
|
21
|
+
next unless reviewer.triggers_on_status?(@new_status)
|
|
22
|
+
|
|
23
|
+
AutomatedReviewJob.perform_later(
|
|
24
|
+
plan_id: @plan.id,
|
|
25
|
+
reviewer_id: reviewer.id,
|
|
26
|
+
plan_version_id: version_id,
|
|
27
|
+
triggered_by: @triggered_by
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div class="comment-form card" id="new-comment-form" data-coplan--text-selection-target="form" style="display: none;">
|
|
2
|
+
<%= form_with url: plan_comment_threads_path(plan), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetCommentForm" } do |f| %>
|
|
3
|
+
<input type="hidden" name="comment_thread[anchor_text]" data-coplan--text-selection-target="anchorInput" value="">
|
|
4
|
+
<input type="hidden" name="comment_thread[anchor_context]" data-coplan--text-selection-target="contextInput" value="">
|
|
5
|
+
<div class="comment-form__anchor" data-coplan--text-selection-target="anchorPreview" style="display: none;">
|
|
6
|
+
<span class="text-sm text-muted">Commenting on:</span>
|
|
7
|
+
<blockquote class="comment-form__quote" data-coplan--text-selection-target="anchorQuote"></blockquote>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="form-group">
|
|
10
|
+
<textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required></textarea>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="comment-form__actions">
|
|
13
|
+
<button type="submit" class="btn btn--primary btn--sm">Comment</button>
|
|
14
|
+
<button type="button" class="btn btn--secondary btn--sm" data-action="coplan--text-selection#cancelComment">Cancel</button>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
|
|
2
|
+
<%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
|
|
3
|
+
<div class="form-group">
|
|
4
|
+
<textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required></textarea>
|
|
5
|
+
</div>
|
|
6
|
+
<button type="submit" class="btn btn--secondary btn--sm">Reply</button>
|
|
7
|
+
<% end %>
|
|
8
|
+
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<div class="comment-thread card" id="<%= dom_id(thread) %>"
|
|
2
|
+
data-anchor-text="<%= thread.anchor_text %>"
|
|
3
|
+
data-anchor-context="<%= thread.anchor_context %>"
|
|
4
|
+
data-thread-id="<%= thread.id %>">
|
|
5
|
+
<div class="comment-thread__header">
|
|
6
|
+
<div class="comment-thread__meta">
|
|
7
|
+
<span class="badge badge--<%= thread.status == 'open' ? 'live' : 'abandoned' %>"><%= thread.status %></span>
|
|
8
|
+
<% if thread.out_of_date? %>
|
|
9
|
+
<span class="badge badge--abandoned">out of date</span>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<% if thread.anchored? %>
|
|
13
|
+
<blockquote class="comment-thread__anchor-quote" data-action="click->coplan--text-selection#scrollToAnchor" data-anchor="<%= thread.anchor_text %>" style="cursor: pointer;"><%= thread.anchor_preview %></blockquote>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="comment-thread__comments" id="<%= dom_id(thread, :comments) %>">
|
|
18
|
+
<% thread.comments.each do |comment| %>
|
|
19
|
+
<%= render partial: "coplan/comments/comment", locals: { comment: comment } %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<%# current_user may not be available during Turbo Stream broadcasts — show all actions in that case %>
|
|
24
|
+
<% viewer = local_assigns.fetch(:current_user, nil) %>
|
|
25
|
+
<% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer.id %>
|
|
26
|
+
<% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer.id %>
|
|
27
|
+
|
|
28
|
+
<% if thread.status == "open" %>
|
|
29
|
+
<%= render partial: "coplan/comment_threads/reply_form", locals: { thread: thread, plan: plan } %>
|
|
30
|
+
|
|
31
|
+
<div class="comment-thread__actions">
|
|
32
|
+
<% if is_plan_author || is_thread_author %>
|
|
33
|
+
<%= link_to "Resolve", resolve_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% if is_plan_author %>
|
|
36
|
+
<%= link_to "Accept", accept_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
37
|
+
<%= link_to "Dismiss", dismiss_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
<% else %>
|
|
41
|
+
<% if is_plan_author || is_thread_author %>
|
|
42
|
+
<div class="comment-thread__actions">
|
|
43
|
+
<%= link_to "Reopen", reopen_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
44
|
+
</div>
|
|
45
|
+
<% end %>
|
|
46
|
+
<% end %>
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<div class="comment" id="<%= dom_id(comment) %>">
|
|
2
|
+
<div class="comment__header text-sm text-muted">
|
|
3
|
+
<strong><%= comment_author_name(comment) %></strong>
|
|
4
|
+
· <%= time_ago_in_words(comment.created_at) %> ago
|
|
5
|
+
</div>
|
|
6
|
+
<div class="comment__body">
|
|
7
|
+
<%= render_markdown(comment.body_markdown) %>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1>Version History: <%= @plan.title %></h1>
|
|
3
|
+
<%= link_to "Back to Plan", plan_path(@plan), class: "btn btn--secondary" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="versions-list">
|
|
7
|
+
<% @versions.each do |version| %>
|
|
8
|
+
<div class="card versions-list__item">
|
|
9
|
+
<div class="versions-list__header">
|
|
10
|
+
<%= link_to "v#{version.revision}", plan_version_path(@plan, version), class: "versions-list__link" %>
|
|
11
|
+
<span class="badge badge--<%= version.actor_type == 'human' ? 'developing' : 'considering' %>"><%= version.actor_type %></span>
|
|
12
|
+
</div>
|
|
13
|
+
<% if version.change_summary.present? %>
|
|
14
|
+
<p class="text-sm"><%= version.change_summary %></p>
|
|
15
|
+
<% end %>
|
|
16
|
+
<div class="text-sm text-muted">
|
|
17
|
+
<%= time_ago_in_words(version.created_at) %> ago
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1><%= @plan.title %> — v<%= @version.revision %></h1>
|
|
4
|
+
<div class="text-sm text-muted mt-md">
|
|
5
|
+
<%= @version.actor_type %> · <%= time_ago_in_words(@version.created_at) %> ago
|
|
6
|
+
<% if @version.change_summary.present? %>
|
|
7
|
+
· <%= @version.change_summary %>
|
|
8
|
+
<% end %>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="page-header__actions">
|
|
12
|
+
<%= link_to "All Versions", plan_versions_path(@plan), class: "btn btn--secondary" %>
|
|
13
|
+
<%= link_to "Current", plan_path(@plan), class: "btn btn--secondary" %>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<% if @diff %>
|
|
18
|
+
<div class="card mb-md">
|
|
19
|
+
<h3>Changes from v<%= @version.revision - 1 %></h3>
|
|
20
|
+
<div class="diff-view">
|
|
21
|
+
<%= @diff.to_s(:html).html_safe %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<div class="plan-content card">
|
|
27
|
+
<h3>Full Content</h3>
|
|
28
|
+
<%= render_markdown(@version.content_markdown) %>
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<div class="page-header" id="plan-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1><%= plan.title %></h1>
|
|
4
|
+
<div class="text-sm text-muted mt-md">
|
|
5
|
+
by <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · <span class="badge badge--<%= plan.status %>"><%= plan.status %></span>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="page-header__actions">
|
|
9
|
+
<% if CoPlan::AutomatedPlanReviewer.enabled.any? %>
|
|
10
|
+
<div class="dropdown" data-controller="coplan--dropdown">
|
|
11
|
+
<button class="btn btn--secondary" data-action="coplan--dropdown#toggle">
|
|
12
|
+
🤖 Run Reviewer ▾
|
|
13
|
+
</button>
|
|
14
|
+
<div class="dropdown__menu" data-coplan--dropdown-target="menu" style="display: none;">
|
|
15
|
+
<% CoPlan::AutomatedPlanReviewer.enabled.order(:name).each do |reviewer| %>
|
|
16
|
+
<%= button_to reviewer.name,
|
|
17
|
+
plan_automated_reviews_path(plan, reviewer_id: reviewer.id),
|
|
18
|
+
method: :post,
|
|
19
|
+
class: "dropdown__item" %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
<%= link_to "History (v#{plan.current_revision})", plan_versions_path(plan), class: "btn btn--secondary" %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1>Edit: <%= @plan.title %></h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<%= form_with model: @plan, url: plan_path(@plan), method: :patch, class: "plan-form card" do |f| %>
|
|
6
|
+
<div class="form-group">
|
|
7
|
+
<%= f.label :title %>
|
|
8
|
+
<%= f.text_field :title, required: true %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="plan-form__actions">
|
|
12
|
+
<%= f.submit "Save Changes", class: "btn btn--primary" %>
|
|
13
|
+
<%= link_to "Cancel", plan_path(@plan), class: "btn btn--secondary" %>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1>Plans</h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="status-filters">
|
|
6
|
+
<%= link_to "All Plans", plans_path(params.permit(:status)), class: "status-filter #{'status-filter--active' if params[:scope].blank?}" %>
|
|
7
|
+
<%= link_to "My Plans", plans_path(params.permit(:status).merge(scope: "mine")), class: "status-filter #{'status-filter--active' if params[:scope] == 'mine'}" %>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="status-filters">
|
|
11
|
+
<%= link_to "All", plans_path(params.permit(:scope)), class: "status-filter #{'status-filter--active' if params[:status].blank?}" %>
|
|
12
|
+
<% CoPlan::Plan::STATUSES.each do |status| %>
|
|
13
|
+
<%= link_to status.titleize, plans_path(params.permit(:scope).merge(status: status)), class: "status-filter status-filter--#{status} #{'status-filter--active' if params[:status] == status}" %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<% if @plans.any? %>
|
|
18
|
+
<div class="plans-list">
|
|
19
|
+
<% @plans.each do |plan| %>
|
|
20
|
+
<div class="card plans-list__item">
|
|
21
|
+
<div class="plans-list__header">
|
|
22
|
+
<%= link_to plan.title, plan_path(plan), class: "plans-list__title" %>
|
|
23
|
+
<span class="badge badge--<%= plan.status %>"><%= plan.status %></span>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="plans-list__meta text-sm text-muted">
|
|
26
|
+
by <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · updated <%= time_ago_in_words(plan.updated_at) %> ago
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
<% else %>
|
|
32
|
+
<div class="empty-state card">
|
|
33
|
+
<p>No plans yet. Plans are created via the API.</p>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<%= turbo_stream_from @plan %>
|
|
2
|
+
|
|
3
|
+
<%= render partial: "coplan/plans/header", locals: { plan: @plan } %>
|
|
4
|
+
|
|
5
|
+
<div class="plan-content card">
|
|
6
|
+
<% if @plan.current_content.present? %>
|
|
7
|
+
<div class="plan-layout" data-controller="coplan--text-selection" data-coplan--text-selection-plan-id-value="<%= @plan.id %>">
|
|
8
|
+
<div class="plan-layout__content" data-coplan--text-selection-target="content">
|
|
9
|
+
<%= render_markdown(@plan.current_content) %>
|
|
10
|
+
|
|
11
|
+
<div class="comment-popover" data-coplan--text-selection-target="popover" style="display: none;">
|
|
12
|
+
<button type="button" class="btn btn--primary btn--sm" data-action="coplan--text-selection#openCommentForm">
|
|
13
|
+
💬 Comment
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="plan-layout__sidebar">
|
|
19
|
+
<%= render partial: "coplan/comment_threads/new_comment_form", locals: { plan: @plan } %>
|
|
20
|
+
|
|
21
|
+
<div class="comment-tabs" data-controller="coplan--tabs" data-coplan--tabs-active-class="comment-tab--active">
|
|
22
|
+
<div class="comment-tabs__nav">
|
|
23
|
+
<button class="comment-tab comment-tab--active" data-coplan--tabs-target="tab" data-action="coplan--tabs#switch coplan--text-selection#repositionThreads" data-index="0">
|
|
24
|
+
Open <span class="comment-tab__count" id="open-thread-count"><%= @active_threads.size if @active_threads.any? %></span>
|
|
25
|
+
</button>
|
|
26
|
+
<button class="comment-tab" data-coplan--tabs-target="tab" data-action="coplan--tabs#switch coplan--text-selection#repositionThreads" data-index="1">
|
|
27
|
+
Resolved <span class="comment-tab__count" id="resolved-thread-count"><%= @archived_threads.size if @archived_threads.any? %></span>
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="comment-tabs__panel" data-coplan--tabs-target="panel">
|
|
32
|
+
<div class="comment-threads-list" id="comment-threads">
|
|
33
|
+
<% @active_threads.each do |thread| %>
|
|
34
|
+
<%= render partial: "coplan/comment_threads/thread", locals: { thread: thread, plan: @plan, current_user: current_user } %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<p class="text-sm text-muted" id="open-threads-empty" <% if @active_threads.any? %>style="display: none;"<% end %>>No open comments.</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="comment-tabs__panel" data-coplan--tabs-target="panel" style="display: none;">
|
|
41
|
+
<div class="comment-threads-list" id="resolved-comment-threads">
|
|
42
|
+
<% @archived_threads.each do |thread| %>
|
|
43
|
+
<%= render partial: "coplan/comment_threads/thread", locals: { thread: thread, plan: @plan, current_user: current_user } %>
|
|
44
|
+
<% end %>
|
|
45
|
+
<p class="text-sm text-muted" id="resolved-threads-empty" <% if @archived_threads.any? %>style="display: none;"<% end %>>No resolved comments.</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<% else %>
|
|
52
|
+
<div class="empty-state">
|
|
53
|
+
<p>This plan has no content yet.</p>
|
|
54
|
+
</div>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<h2>Create New Token</h2>
|
|
2
|
+
<%= form_with url: settings_tokens_path, method: :post, class: "form-inline" do |f| %>
|
|
3
|
+
<div class="form-group">
|
|
4
|
+
<%= f.label :name, "Token Name" %>
|
|
5
|
+
<%= f.text_field :name, name: "api_token[name]", placeholder: "e.g. My Agent", required: true, class: "form-control" %>
|
|
6
|
+
</div>
|
|
7
|
+
<%= f.submit "Create Token", class: "btn btn--primary" %>
|
|
8
|
+
<% end %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<tr id="<%= dom_id(token) %>" class="<%= 'data-table__row--muted' if token.revoked? %>">
|
|
2
|
+
<td>
|
|
3
|
+
<strong><%= token.name %></strong>
|
|
4
|
+
<% if token.token_prefix.present? %>
|
|
5
|
+
<br><code><%= token.token_prefix %>…</code>
|
|
6
|
+
<% end %>
|
|
7
|
+
</td>
|
|
8
|
+
<td>
|
|
9
|
+
<% if token.revoked? %>
|
|
10
|
+
<span class="badge badge--danger">Revoked</span>
|
|
11
|
+
<% elsif token.expired? %>
|
|
12
|
+
<span class="badge badge--warning">Expired</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<span class="badge badge--success">Active</span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</td>
|
|
17
|
+
<td><%= token.last_used_at ? time_ago_in_words(token.last_used_at) + " ago" : "Never" %></td>
|
|
18
|
+
<td><%= token.created_at.strftime("%b %d, %Y") %></td>
|
|
19
|
+
<td>
|
|
20
|
+
<% unless token.revoked? %>
|
|
21
|
+
<%= button_to "Revoke", settings_token_path(token), method: :delete, class: "btn btn--danger btn--sm" %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</td>
|
|
24
|
+
</tr>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%= turbo_stream.update "token-reveal" do %>
|
|
2
|
+
<%= render "token_reveal", raw_token: @raw_token %>
|
|
3
|
+
<% end %>
|
|
4
|
+
|
|
5
|
+
<%= turbo_stream.update "create-token-form" do %>
|
|
6
|
+
<%= render "form" %>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<%= turbo_stream.prepend "tokens-list" do %>
|
|
10
|
+
<%= render "token_row", token: @api_token %>
|
|
11
|
+
<% end %>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1>Settings</h1>
|
|
3
|
+
<p class="page-header__subtitle">Manage API tokens for agent access</p>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div id="token-reveal">
|
|
7
|
+
<% if flash[:raw_token].present? %>
|
|
8
|
+
<%= render "token_reveal", raw_token: flash[:raw_token] %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="card" id="create-token-form">
|
|
13
|
+
<%= render "form" %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div class="card" id="tokens-card">
|
|
17
|
+
<h2>Your Tokens</h2>
|
|
18
|
+
<table class="data-table">
|
|
19
|
+
<thead>
|
|
20
|
+
<tr>
|
|
21
|
+
<th>Name</th>
|
|
22
|
+
<th>Status</th>
|
|
23
|
+
<th>Last Used</th>
|
|
24
|
+
<th>Created</th>
|
|
25
|
+
<th></th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody id="tokens-list">
|
|
29
|
+
<%= render partial: "token_row", collection: @api_tokens, as: :token %>
|
|
30
|
+
</tbody>
|
|
31
|
+
</table>
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= content_for(:title) || "CoPlan" %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
<%= stylesheet_link_tag "coplan/application", "data-turbo-track": "reload" %>
|
|
10
|
+
<%= javascript_importmap_tags %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<nav class="site-nav">
|
|
14
|
+
<div class="site-nav__inner">
|
|
15
|
+
<%= link_to "📋 CoPlan", coplan.root_path, class: "site-nav__brand" %>
|
|
16
|
+
<ul class="site-nav__links">
|
|
17
|
+
<% if signed_in? %>
|
|
18
|
+
<li><%= link_to "Plans", coplan.plans_path %></li>
|
|
19
|
+
<li><%= link_to "Settings", coplan.settings_tokens_path %></li>
|
|
20
|
+
<% end %>
|
|
21
|
+
</ul>
|
|
22
|
+
<% if signed_in? %>
|
|
23
|
+
<div class="site-nav__user">
|
|
24
|
+
<span><%= current_user.name %></span>
|
|
25
|
+
<% if main_app.respond_to?(:sign_out_path) %>
|
|
26
|
+
<%= link_to "Sign out", main_app.sign_out_path, data: { turbo_method: :delete } %>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
</nav>
|
|
32
|
+
<main class="main-content">
|
|
33
|
+
<% if notice.present? %>
|
|
34
|
+
<div class="flash flash--notice"><%= notice %></div>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% if alert.present? %>
|
|
37
|
+
<div class="flash flash--alert"><%= alert %></div>
|
|
38
|
+
<% end %>
|
|
39
|
+
<%= yield %>
|
|
40
|
+
</main>
|
|
41
|
+
</body>
|
|
42
|
+
</html>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pin_all_from CoPlan::Engine.root.join("app/javascript/controllers/coplan"), under: "controllers/coplan", preload: true
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
CoPlan::Engine.routes.draw do
|
|
2
|
+
resources :plans, only: [:index, :show, :edit, :update] do
|
|
3
|
+
patch :update_status, on: :member
|
|
4
|
+
resources :versions, controller: "plan_versions", only: [:index, :show]
|
|
5
|
+
resources :automated_reviews, only: [:create]
|
|
6
|
+
resources :comment_threads, only: [:create] do
|
|
7
|
+
member do
|
|
8
|
+
patch :resolve
|
|
9
|
+
patch :accept
|
|
10
|
+
patch :dismiss
|
|
11
|
+
patch :reopen
|
|
12
|
+
end
|
|
13
|
+
resources :comments, only: [:create]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
namespace :settings do
|
|
18
|
+
resources :tokens, only: [:index, :create, :destroy]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
namespace :api do
|
|
22
|
+
namespace :v1 do
|
|
23
|
+
resources :plans, only: [:index, :show, :create, :update] do
|
|
24
|
+
get :versions, on: :member
|
|
25
|
+
get :comments, on: :member
|
|
26
|
+
resource :lease, only: [:create, :update, :destroy], controller: "leases"
|
|
27
|
+
resources :operations, only: [:create]
|
|
28
|
+
resources :sessions, only: [:create, :show], controller: "sessions" do
|
|
29
|
+
post :commit, on: :member
|
|
30
|
+
end
|
|
31
|
+
resources :comments, only: [:create], controller: "comments" do
|
|
32
|
+
post :reply, on: :member
|
|
33
|
+
patch :resolve, on: :member
|
|
34
|
+
patch :dismiss, on: :member
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
root "plans#index"
|
|
41
|
+
end
|