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,112 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.anchorLine = null
6
+ this.startLine = null
7
+ this.endLine = null
8
+ this.element.addEventListener("click", this.handleClick.bind(this))
9
+ }
10
+
11
+ disconnect() {
12
+ this.element.removeEventListener("click", this.handleClick.bind(this))
13
+ }
14
+
15
+ handleClick(event) {
16
+ const lineEl = event.target.closest(".line-view__line")
17
+ if (!lineEl) return
18
+
19
+ const lineNum = parseInt(lineEl.dataset.line, 10)
20
+
21
+ if (event.shiftKey && this.anchorLine !== null) {
22
+ this.startLine = Math.min(this.anchorLine, lineNum)
23
+ this.endLine = Math.max(this.anchorLine, lineNum)
24
+ } else if (this.anchorLine === lineNum && this.startLine === lineNum && this.endLine === lineNum) {
25
+ // Toggle off single selected line
26
+ this.anchorLine = null
27
+ this.startLine = null
28
+ this.endLine = null
29
+ } else {
30
+ this.anchorLine = lineNum
31
+ this.startLine = lineNum
32
+ this.endLine = lineNum
33
+ }
34
+
35
+ this.updateSelection()
36
+ }
37
+
38
+ updateSelection() {
39
+ this.element.querySelectorAll(".line-view__line").forEach(el => {
40
+ const num = parseInt(el.dataset.line, 10)
41
+ if (this.startLine !== null && num >= this.startLine && num <= this.endLine) {
42
+ el.classList.add("line-view__line--selected")
43
+ } else {
44
+ el.classList.remove("line-view__line--selected")
45
+ }
46
+ })
47
+
48
+ this.updateActionBar()
49
+ }
50
+
51
+ updateActionBar() {
52
+ let bar = this.element.querySelector(".line-selection-bar")
53
+
54
+ if (this.startLine === null) {
55
+ if (bar) bar.remove()
56
+ return
57
+ }
58
+
59
+ if (!bar) {
60
+ bar = document.createElement("div")
61
+ bar.classList.add("line-selection-bar")
62
+ this.element.appendChild(bar)
63
+ }
64
+
65
+ const rangeText = this.startLine === this.endLine
66
+ ? `Line ${this.startLine} selected`
67
+ : `Lines ${this.startLine}–${this.endLine} selected`
68
+
69
+ bar.innerHTML = `
70
+ <span class="line-selection-bar__info">${rangeText}</span>
71
+ <span class="line-selection-bar__actions">
72
+ <a href="#" class="btn btn--primary btn--sm" data-action="line-selection#addComment">Add Comment</a>
73
+ <button type="button" class="btn btn--secondary btn--sm" data-action="line-selection#clearSelection">Clear</button>
74
+ </span>
75
+ `
76
+ }
77
+
78
+ addComment(event) {
79
+ event.preventDefault()
80
+ const startInput = document.getElementById("comment_start_line")
81
+ const endInput = document.getElementById("comment_end_line")
82
+ const indicator = document.getElementById("comment-line-indicator")
83
+ const indicatorText = document.getElementById("comment-line-text")
84
+ const textarea = document.getElementById("comment_thread_body_markdown")
85
+
86
+ if (startInput && endInput) {
87
+ startInput.value = this.startLine
88
+ endInput.value = this.endLine
89
+
90
+ if (indicator && indicatorText) {
91
+ const text = this.startLine === this.endLine
92
+ ? `Commenting on Line ${this.startLine}`
93
+ : `Commenting on Lines ${this.startLine}–${this.endLine}`
94
+ indicatorText.textContent = text
95
+ indicator.style.display = "block"
96
+ }
97
+
98
+ if (textarea) {
99
+ textarea.focus()
100
+ textarea.scrollIntoView({ behavior: "smooth", block: "center" })
101
+ }
102
+ }
103
+ }
104
+
105
+ clearSelection(event) {
106
+ if (event) event.preventDefault()
107
+ this.anchorLine = null
108
+ this.startLine = null
109
+ this.endLine = null
110
+ this.updateSelection()
111
+ }
112
+ }
@@ -0,0 +1,18 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["tab", "panel"]
5
+ static classes = ["active"]
6
+
7
+ switch(event) {
8
+ const index = parseInt(event.currentTarget.dataset.index)
9
+
10
+ this.tabTargets.forEach((tab, i) => {
11
+ tab.classList.toggle(this.activeClass, i === index)
12
+ })
13
+
14
+ this.panelTargets.forEach((panel, i) => {
15
+ panel.style.display = i === index ? "" : "none"
16
+ })
17
+ }
18
+ }
@@ -0,0 +1,376 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["content", "popover", "form", "anchorInput", "contextInput", "anchorPreview", "anchorQuote"]
5
+ static values = { planId: String }
6
+
7
+ connect() {
8
+ this.selectedText = null
9
+ this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
10
+ document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
11
+ this.highlightAnchors()
12
+ this.observeThreadLists()
13
+ }
14
+
15
+ disconnect() {
16
+ this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
17
+ document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
18
+ if (this.threadListObserver) this.threadListObserver.disconnect()
19
+ }
20
+
21
+ handleMouseUp(event) {
22
+ // Small delay to let the selection finalize
23
+ setTimeout(() => this.checkSelection(event), 10)
24
+ }
25
+
26
+ handleDocumentMouseDown(event) {
27
+ // Hide popover if clicking outside it
28
+ if (this.hasPopoverTarget && !this.popoverTarget.contains(event.target)) {
29
+ this.popoverTarget.style.display = "none"
30
+ }
31
+ }
32
+
33
+ checkSelection(event) {
34
+ const selection = window.getSelection()
35
+ const text = selection.toString().trim()
36
+
37
+ if (text.length < 3) {
38
+ this.popoverTarget.style.display = "none"
39
+ return
40
+ }
41
+
42
+ // Make sure selection is within the content area
43
+ if (!selection.rangeCount) return
44
+ const range = selection.getRangeAt(0)
45
+ if (!this.contentTarget.contains(range.commonAncestorContainer)) {
46
+ return
47
+ }
48
+
49
+ this.selectedText = text
50
+ this.selectedContext = this.extractContext(range, text)
51
+
52
+ // Position popover near the selection
53
+ const rect = range.getBoundingClientRect()
54
+ const contentRect = this.contentTarget.getBoundingClientRect()
55
+
56
+ this.popoverTarget.style.display = "block"
57
+ this.popoverTarget.style.top = `${rect.bottom - contentRect.top + 8}px`
58
+ this.popoverTarget.style.left = `${rect.left - contentRect.left}px`
59
+ }
60
+
61
+ openCommentForm(event) {
62
+ event.preventDefault()
63
+ if (!this.selectedText) return
64
+
65
+ // Set the anchor text and surrounding context
66
+ this.anchorInputTarget.value = this.selectedText
67
+ this.contextInputTarget.value = this.selectedContext || ""
68
+ this.anchorQuoteTarget.textContent = this.selectedText.length > 120
69
+ ? this.selectedText.substring(0, 120) + "…"
70
+ : this.selectedText
71
+ this.anchorPreviewTarget.style.display = "block"
72
+
73
+ // Position the form in the sidebar at the same vertical level as the selection
74
+ const layoutRect = this.element.getBoundingClientRect()
75
+ const popoverRect = this.popoverTarget.getBoundingClientRect()
76
+ const offsetTop = popoverRect.top - layoutRect.top
77
+ this.formTarget.style.position = "absolute"
78
+ this.formTarget.style.top = `${offsetTop}px`
79
+
80
+ // Show form, hide popover
81
+ this.formTarget.style.display = "block"
82
+ this.popoverTarget.style.display = "none"
83
+
84
+ // Clear browser selection
85
+ window.getSelection().removeAllRanges()
86
+
87
+ // Focus textarea without scrolling the page
88
+ const textarea = this.formTarget.querySelector("textarea")
89
+ if (textarea) {
90
+ textarea.focus({ preventScroll: true })
91
+ }
92
+ }
93
+
94
+ cancelComment(event) {
95
+ event.preventDefault()
96
+ this.hideAndResetForm()
97
+ }
98
+
99
+ resetCommentForm(event) {
100
+ if (event.detail.success) {
101
+ this.hideAndResetForm()
102
+ }
103
+ }
104
+
105
+ resetReplyForm(event) {
106
+ if (event.detail.success) {
107
+ const form = event.target
108
+ const textarea = form.querySelector("textarea")
109
+ if (textarea) textarea.value = ""
110
+ }
111
+ }
112
+
113
+ hideAndResetForm() {
114
+ this.formTarget.style.display = "none"
115
+ this.anchorInputTarget.value = ""
116
+ this.contextInputTarget.value = ""
117
+ this.anchorPreviewTarget.style.display = "none"
118
+ const textarea = this.formTarget.querySelector("textarea")
119
+ if (textarea) textarea.value = ""
120
+ this.selectedText = null
121
+ this.selectedContext = null
122
+ }
123
+
124
+ scrollToAnchor(event) {
125
+ const anchor = event.currentTarget.dataset.anchor
126
+ if (!anchor) return
127
+
128
+ const context = event.currentTarget.closest("[data-anchor-context]")?.dataset.anchorContext || ""
129
+
130
+ // Remove existing highlights first
131
+ this.contentTarget.querySelectorAll(".anchor-highlight--active").forEach(el => {
132
+ el.classList.remove("anchor-highlight--active")
133
+ })
134
+
135
+ // Find and highlight the anchor text using context for disambiguation
136
+ const highlighted = context
137
+ ? this.findAndHighlightWithContext(anchor, context, "anchor-highlight--active")
138
+ : this.findAndHighlight(anchor, "anchor-highlight--active")
139
+ if (highlighted) {
140
+ highlighted.scrollIntoView({ behavior: "smooth", block: "center" })
141
+ }
142
+ }
143
+
144
+ extractContext(range, selectedText) {
145
+ // Grab surrounding text for disambiguation
146
+ const fullText = this.contentTarget.textContent
147
+ const selIndex = fullText.indexOf(selectedText)
148
+ if (selIndex === -1) return ""
149
+
150
+ // Find ALL occurrences — if unique, no context needed
151
+ let count = 0
152
+ let pos = -1
153
+ while ((pos = fullText.indexOf(selectedText, pos + 1)) !== -1) count++
154
+ if (count === 1) return ""
155
+
156
+ // Multiple occurrences — find which one by using the range's position
157
+ // Grab ~100 chars before and after for a unique context
158
+ const contextBefore = 100
159
+ const contextAfter = 100
160
+
161
+ // Use a DOM-based walk to figure out the offset in the text content
162
+ const offset = this.getSelectionOffset(range)
163
+
164
+ const start = Math.max(0, offset - contextBefore)
165
+ const end = Math.min(fullText.length, offset + selectedText.length + contextAfter)
166
+ return fullText.slice(start, end)
167
+ }
168
+
169
+ getSelectionOffset(range) {
170
+ if (!range || !this.contentTarget) return 0
171
+
172
+ const walker = document.createTreeWalker(this.contentTarget, NodeFilter.SHOW_TEXT, null)
173
+ let offset = 0
174
+ let node
175
+
176
+ while ((node = walker.nextNode())) {
177
+ if (range.startContainer === node) {
178
+ offset += range.startOffset
179
+ break
180
+ }
181
+ offset += node.textContent.length
182
+ }
183
+
184
+ return offset
185
+ }
186
+
187
+ highlightAnchors() {
188
+ // Remove existing anchor highlights before re-highlighting
189
+ this.contentTarget.querySelectorAll("mark.anchor-highlight").forEach(mark => {
190
+ const parent = mark.parentNode
191
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark)
192
+ parent.removeChild(mark)
193
+ })
194
+ this.contentTarget.normalize()
195
+
196
+ // Build full text once for position lookups
197
+ this.fullText = this.contentTarget.textContent
198
+
199
+ const threads = this.element.querySelectorAll("[data-anchor-text]")
200
+ threads.forEach(thread => {
201
+ const anchor = thread.dataset.anchorText
202
+ const context = thread.dataset.anchorContext
203
+ if (anchor && anchor.length > 0) {
204
+ this.findAndHighlightWithContext(anchor, context, "anchor-highlight")
205
+ }
206
+ })
207
+
208
+ this.positionThreads()
209
+ }
210
+
211
+ // Re-highlight and reposition when threads are added/removed via turbo stream broadcasts
212
+ observeThreadLists() {
213
+ this.threadListObserver = new MutationObserver(() => {
214
+ // Debounce — multiple mutations may fire in quick succession
215
+ clearTimeout(this._repositionTimer)
216
+ this._repositionTimer = setTimeout(() => this.highlightAnchors(), 50)
217
+ })
218
+
219
+ this.element.querySelectorAll(".comment-threads-list").forEach(list => {
220
+ this.threadListObserver.observe(list, { childList: true })
221
+ })
222
+ }
223
+
224
+ repositionThreads() {
225
+ // Small delay to let the tab panel become visible before measuring positions
226
+ setTimeout(() => this.positionThreads(), 10)
227
+ }
228
+
229
+ positionThreads() {
230
+ const sidebar = this.element.querySelector(".plan-layout__sidebar")
231
+ if (!sidebar) return
232
+
233
+ // Pause observer while reordering DOM to avoid triggering a rehighlight loop
234
+ if (this.threadListObserver) this.threadListObserver.disconnect()
235
+
236
+ this.positionThreadList("#comment-threads", sidebar)
237
+ this.positionThreadList("#resolved-comment-threads", sidebar)
238
+
239
+ // Re-observe after reordering
240
+ if (this.threadListObserver) {
241
+ this.element.querySelectorAll(".comment-threads-list").forEach(list => {
242
+ this.threadListObserver.observe(list, { childList: true })
243
+ })
244
+ }
245
+ }
246
+
247
+ positionThreadList(selector, sidebar) {
248
+ const threadList = this.element.querySelector(selector)
249
+ if (!threadList) return
250
+
251
+ const threads = Array.from(threadList.querySelectorAll(".comment-thread"))
252
+ if (threads.length === 0) return
253
+
254
+ const sidebarRect = sidebar.getBoundingClientRect()
255
+
256
+ // Sort threads by their anchor's vertical position in the document
257
+ threads.sort((a, b) => {
258
+ const markA = a._highlightMark
259
+ const markB = b._highlightMark
260
+ const yA = markA ? markA.getBoundingClientRect().top : Infinity
261
+ const yB = markB ? markB.getBoundingClientRect().top : Infinity
262
+ return yA - yB
263
+ })
264
+
265
+ // Reorder DOM within this list only
266
+ threads.forEach(thread => threadList.appendChild(thread))
267
+
268
+ // Position threads vertically
269
+ const gap = 8
270
+ let cursor = 0
271
+
272
+ threads.forEach(thread => {
273
+ const mark = thread._highlightMark
274
+ let desiredY = cursor
275
+
276
+ if (mark) {
277
+ desiredY = mark.getBoundingClientRect().top - sidebarRect.top + sidebar.scrollTop
278
+ }
279
+
280
+ const y = Math.max(desiredY, cursor)
281
+ thread.style.marginTop = `${y - cursor}px`
282
+ cursor = y + thread.offsetHeight + gap
283
+ })
284
+ }
285
+
286
+ findAndHighlightWithContext(text, context, className) {
287
+ // Use context to find the right occurrence of the anchor text
288
+ const fullText = this.fullText
289
+ let targetIndex
290
+
291
+ if (context && context.length > 0) {
292
+ const contextIndex = fullText.indexOf(context)
293
+ if (contextIndex !== -1) {
294
+ // Find the anchor text within the context region
295
+ targetIndex = fullText.indexOf(text, contextIndex)
296
+ if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
297
+ targetIndex = fullText.indexOf(text) // fallback
298
+ }
299
+ } else {
300
+ targetIndex = fullText.indexOf(text)
301
+ }
302
+ } else {
303
+ targetIndex = fullText.indexOf(text)
304
+ }
305
+
306
+ if (targetIndex === -1) return null
307
+
308
+ // Find the thread element to store the mark reference
309
+ const threads = this.element.querySelectorAll(".comment-thread[data-anchor-text]")
310
+ let threadEl = null
311
+ for (const t of threads) {
312
+ if (t.dataset.anchorText === text && t.dataset.anchorContext === (context || "")) {
313
+ threadEl = t
314
+ break
315
+ }
316
+ }
317
+
318
+ const mark = this.highlightAtIndex(targetIndex, text.length, className)
319
+ if (mark && threadEl) threadEl._highlightMark = mark
320
+ return mark
321
+ }
322
+
323
+ highlightAtIndex(startIndex, length, className) {
324
+ if (startIndex < 0 || length <= 0) return null
325
+
326
+ const walker = document.createTreeWalker(
327
+ this.contentTarget,
328
+ NodeFilter.SHOW_TEXT,
329
+ null,
330
+ false
331
+ )
332
+
333
+ const textNodes = []
334
+ let fullText = ""
335
+ let node
336
+ while (node = walker.nextNode()) {
337
+ textNodes.push({ node, start: fullText.length })
338
+ fullText += node.textContent
339
+ }
340
+
341
+ const matchEnd = startIndex + length
342
+ let firstHighlighted = null
343
+
344
+ for (let i = 0; i < textNodes.length; i++) {
345
+ const tn = textNodes[i]
346
+ const nodeEnd = tn.start + tn.node.textContent.length
347
+
348
+ if (nodeEnd <= startIndex) continue
349
+ if (tn.start >= matchEnd) break
350
+
351
+ const localStart = Math.max(0, startIndex - tn.start)
352
+ const localEnd = Math.min(tn.node.textContent.length, matchEnd - tn.start)
353
+
354
+ const range = document.createRange()
355
+ range.setStart(tn.node, localStart)
356
+ range.setEnd(tn.node, localEnd)
357
+
358
+ const mark = document.createElement("mark")
359
+ mark.className = className
360
+ range.surroundContents(mark)
361
+
362
+ if (!firstHighlighted) firstHighlighted = mark
363
+ }
364
+
365
+ return firstHighlighted
366
+ }
367
+
368
+ // Keep legacy method for scrollToAnchor (single-use highlight)
369
+ findAndHighlight(text, className) {
370
+ if (!text || text.length === 0) return null
371
+ const fullText = this.contentTarget.textContent
372
+ const index = fullText.indexOf(text)
373
+ if (index === -1) return null
374
+ return this.highlightAtIndex(index, text.length, className)
375
+ }
376
+ }
@@ -0,0 +1,4 @@
1
+ module CoPlan
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,71 @@
1
+ module CoPlan
2
+ class AutomatedReviewJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ discard_on AiProviders::OpenAi::Error
6
+ discard_on AiProviders::Anthropic::Error
7
+
8
+ def perform(plan_id:, reviewer_id:, plan_version_id:, triggered_by: nil)
9
+ plan = Plan.find(plan_id)
10
+ reviewer = AutomatedPlanReviewer.find(reviewer_id)
11
+ version = PlanVersion.find(plan_version_id)
12
+
13
+ return unless reviewer.enabled?
14
+
15
+ response = call_ai_provider(reviewer, version.content_markdown)
16
+ feedback_items = Plans::ReviewResponseParser.call(response, plan_content: version.content_markdown)
17
+
18
+ create_review_comments(plan, version, reviewer, feedback_items, triggered_by)
19
+ end
20
+
21
+ private
22
+
23
+ def call_ai_provider(reviewer, content)
24
+ system_prompt = Plans::ReviewPromptFormatter.call(reviewer_prompt: reviewer.prompt_text)
25
+ provider_class = resolve_provider(reviewer.ai_provider)
26
+ provider_class.call(
27
+ system_prompt: system_prompt,
28
+ user_content: content,
29
+ model: reviewer.ai_model
30
+ )
31
+ end
32
+
33
+ def resolve_provider(provider_name)
34
+ case provider_name
35
+ when "openai" then AiProviders::OpenAi
36
+ when "anthropic" then AiProviders::Anthropic
37
+ else raise ArgumentError, "Unknown AI provider: #{provider_name}"
38
+ end
39
+ end
40
+
41
+ def create_review_comments(plan, version, reviewer, feedback_items, triggered_by)
42
+ created_by = triggered_by || plan.created_by_user
43
+
44
+ feedback_items.each do |item|
45
+ thread = plan.comment_threads.create!(
46
+ plan_version: version,
47
+ created_by_user: created_by,
48
+ anchor_text: item[:anchor_text],
49
+ status: "open"
50
+ )
51
+
52
+ thread.comments.create!(
53
+ author_type: AutomatedPlanReviewer::ACTOR_TYPE,
54
+ author_id: reviewer.id,
55
+ body_markdown: item[:comment]
56
+ )
57
+
58
+ broadcast_new_thread(plan, thread)
59
+ end
60
+ end
61
+
62
+ def broadcast_new_thread(plan, thread)
63
+ Broadcaster.prepend_to(
64
+ plan,
65
+ target: "comment-threads",
66
+ partial: "coplan/comment_threads/thread",
67
+ locals: { thread: thread, plan: plan }
68
+ )
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,29 @@
1
+ module CoPlan
2
+ class CommitExpiredSessionJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(session_id:)
6
+ session = EditSession.find_by(id: session_id)
7
+ return unless session # Session was deleted
8
+
9
+ # Only auto-commit if still open
10
+ return unless session.open?
11
+
12
+ if session.has_operations?
13
+ Plans::CommitSession.call(
14
+ session: session,
15
+ change_summary: session.change_summary || "Auto-committed expired session"
16
+ )
17
+ else
18
+ session.update!(status: "expired", committed_at: Time.current)
19
+ end
20
+ rescue Plans::CommitSession::SessionNotOpenError
21
+ # Session was closed concurrently (manual commit/cancel) — nothing to do
22
+ Rails.logger.info("CommitExpiredSessionJob: session #{session_id} already closed, skipping")
23
+ rescue Plans::CommitSession::SessionConflictError, Plans::CommitSession::StaleSessionError, Plans::OperationError => e
24
+ # Conflict during auto-commit — mark session as failed
25
+ session.update!(status: "failed", change_summary: "Auto-commit failed: #{e.message}")
26
+ Rails.logger.warn("CommitExpiredSessionJob failed for session #{session_id}: #{e.message}")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,10 @@
1
+ module CoPlan
2
+ class NotificationJob < ApplicationJob
3
+ queue_as :default
4
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
5
+
6
+ def perform(event, payload)
7
+ CoPlan.configuration.notification_handler&.call(event.to_sym, payload.symbolize_keys)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,50 @@
1
+ module CoPlan
2
+ class ApiToken < ApplicationRecord
3
+ HOLDER_TYPE = "local_agent"
4
+
5
+ belongs_to :user, class_name: "CoPlan::User"
6
+
7
+ validates :name, presence: true
8
+ validates :token_digest, presence: true, uniqueness: true
9
+
10
+ scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
11
+
12
+ def self.authenticate(raw_token)
13
+ return nil if raw_token.blank?
14
+ digest = Digest::SHA256.hexdigest(raw_token)
15
+ token = active.find_by(token_digest: digest)
16
+ token&.touch(:last_used_at)
17
+ token
18
+ end
19
+
20
+ def self.generate_token
21
+ SecureRandom.hex(32)
22
+ end
23
+
24
+ def self.create_with_raw_token(**attributes)
25
+ raw_token = generate_token
26
+ api_token = create!(
27
+ **attributes,
28
+ token_digest: Digest::SHA256.hexdigest(raw_token),
29
+ token_prefix: raw_token[0, 8]
30
+ )
31
+ [api_token, raw_token]
32
+ end
33
+
34
+ def revoked?
35
+ revoked_at.present?
36
+ end
37
+
38
+ def expired?
39
+ expires_at.present? && expires_at <= Time.current
40
+ end
41
+
42
+ def active?
43
+ !revoked? && !expired?
44
+ end
45
+
46
+ def revoke!
47
+ update!(revoked_at: Time.current)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,14 @@
1
+ module CoPlan
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = "coplan_"
5
+
6
+ before_create :assign_uuid, if: -> { id.blank? }
7
+
8
+ private
9
+
10
+ def assign_uuid
11
+ self.id = SecureRandom.uuid_v7
12
+ end
13
+ end
14
+ end