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,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,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
|