@in-the-loop-labs/pair-review 3.0.3 → 3.0.5
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.
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +10 -5
- package/public/js/components/ReviewModal.js +2 -2
- package/public/js/modules/comment-manager.js +149 -75
- package/public/js/modules/comment-minimizer.js +1 -1
- package/public/js/modules/file-comment-manager.js +3 -7
- package/public/js/modules/suggestion-manager.js +10 -2
- package/public/js/pr.js +109 -39
- package/src/database.js +5 -0
- package/src/git/base-branch.js +52 -1
- package/src/routes/local.js +83 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@in-the-loop-labs/pair-review",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.5",
|
|
4
4
|
"description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"@playwright/test": "^1.57.0",
|
|
78
78
|
"@vitest/coverage-v8": "^4.0.16",
|
|
79
79
|
"highlight.js": "^11.11.1",
|
|
80
|
+
"jsdom": "^29.0.1",
|
|
80
81
|
"supertest": "^7.1.4",
|
|
81
82
|
"vitest": "^4.0.16"
|
|
82
83
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.5",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.5",
|
|
4
4
|
"description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
package/public/css/pr.css
CHANGED
|
@@ -6000,12 +6000,8 @@ body::before {
|
|
|
6000
6000
|
color: var(--color-accent, #2f81f7);
|
|
6001
6001
|
}
|
|
6002
6002
|
|
|
6003
|
-
/* PR description popover */
|
|
6003
|
+
/* PR description popover - appended to document.body with fixed positioning */
|
|
6004
6004
|
.pr-description-popover {
|
|
6005
|
-
position: absolute;
|
|
6006
|
-
top: calc(100% + 8px);
|
|
6007
|
-
left: 50%;
|
|
6008
|
-
transform: translateX(-50%);
|
|
6009
6005
|
z-index: 1000;
|
|
6010
6006
|
width: min(500px, 90vw);
|
|
6011
6007
|
background: var(--color-bg-primary, #0d1117);
|
|
@@ -10881,6 +10877,15 @@ body.resizing * {
|
|
|
10881
10877
|
/* File-level AI suggestions now use the same classes as line-level (.ai-suggestion-*)
|
|
10882
10878
|
No custom button styles needed - they inherit from .ai-action classes defined earlier */
|
|
10883
10879
|
|
|
10880
|
+
/* File-level AI suggestions: override line-level viewport-based max-width.
|
|
10881
|
+
Line-level suggestions use calc(100vw - sidebar - panels) because they sit in table cells.
|
|
10882
|
+
File-level suggestions sit inside .file-comments-container which is already correctly sized,
|
|
10883
|
+
so they just need to fill their container. */
|
|
10884
|
+
.file-comment-card.ai-suggestion {
|
|
10885
|
+
max-width: 100%;
|
|
10886
|
+
margin: 0; /* Container handles spacing via gap; don't center with auto margins */
|
|
10887
|
+
}
|
|
10888
|
+
|
|
10884
10889
|
/* File-level AI suggestion collapsed state - uses same classes as line-level */
|
|
10885
10890
|
.file-comment-card.ai-suggestion.collapsed {
|
|
10886
10891
|
background: var(--color-bg-secondary, #f6f8fa);
|
|
@@ -371,7 +371,7 @@ class ReviewModal {
|
|
|
371
371
|
*/
|
|
372
372
|
updateCommentCount() {
|
|
373
373
|
// Count both line-level comments (.user-comment-row) and file-level comments (.file-comment-card.user-comment)
|
|
374
|
-
const lineComments = document.querySelectorAll('.user-comment-row').length;
|
|
374
|
+
const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
|
|
375
375
|
const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
|
|
376
376
|
const userComments = lineComments + fileComments;
|
|
377
377
|
const countElement = this.modal.querySelector('.review-comment-count');
|
|
@@ -470,7 +470,7 @@ class ReviewModal {
|
|
|
470
470
|
const reviewEvent = selectedOption ? selectedOption.value : 'COMMENT';
|
|
471
471
|
// Count BOTH line-level (.user-comment-row) and file-level (.file-comment-card.user-comment) comments
|
|
472
472
|
// This must match the counting logic in updateCommentCount() for consistency
|
|
473
|
-
const lineComments = document.querySelectorAll('.user-comment-row').length;
|
|
473
|
+
const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
|
|
474
474
|
const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
|
|
475
475
|
const commentCount = lineComments + fileComments;
|
|
476
476
|
|
|
@@ -580,12 +580,8 @@ class CommentManager {
|
|
|
580
580
|
|
|
581
581
|
// Choose icon based on comment origin (AI-adopted vs user-originated)
|
|
582
582
|
const commentIcon = comment.parent_id
|
|
583
|
-
?
|
|
584
|
-
|
|
585
|
-
</svg>`
|
|
586
|
-
: `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
|
|
587
|
-
<path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
|
|
588
|
-
</svg>`;
|
|
583
|
+
? CommentManager.AI_ICON_SVG
|
|
584
|
+
: CommentManager.PERSON_ICON_SVG;
|
|
589
585
|
|
|
590
586
|
// Build class list for comment styling
|
|
591
587
|
const baseClasses = ['user-comment'];
|
|
@@ -634,56 +630,51 @@ class CommentManager {
|
|
|
634
630
|
targetRow.parentNode.insertBefore(commentRow, targetRow.nextSibling);
|
|
635
631
|
}
|
|
636
632
|
|
|
633
|
+
/** SVG icons for comment origin display */
|
|
634
|
+
static AI_ICON_SVG = `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
|
|
635
|
+
<path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
|
|
636
|
+
</svg>`;
|
|
637
|
+
|
|
638
|
+
static PERSON_ICON_SVG = `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
|
|
639
|
+
<path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
|
|
640
|
+
</svg>`;
|
|
641
|
+
|
|
637
642
|
/**
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
643
|
+
* Shared builder for comment/suggestion edit form rows.
|
|
644
|
+
* Builds the DOM, inserts after targetRow, wires common event handling
|
|
645
|
+
* (autoResize, emoji, suggestion button), and returns { formRow, textarea, saveBtn, cancelBtn }.
|
|
646
|
+
* Callers wire their own save/cancel logic on the returned elements.
|
|
647
|
+
* NOTE: similar edit form in pr.js editUserComment — keep in sync
|
|
648
|
+
* @private
|
|
641
649
|
*/
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (comment.side) {
|
|
651
|
-
commentRow.dataset.side = comment.side;
|
|
652
|
-
}
|
|
650
|
+
_buildEditFormRow(targetRow, {
|
|
651
|
+
rowClassName, rowDataset, iconHtml, originClass, lineInfo,
|
|
652
|
+
type, title, body, bodyHtml, textareaId, placeholder,
|
|
653
|
+
dataAttrs, saveLabel
|
|
654
|
+
}) {
|
|
655
|
+
const formRow = document.createElement('tr');
|
|
656
|
+
formRow.className = rowClassName;
|
|
657
|
+
Object.assign(formRow.dataset, rowDataset);
|
|
653
658
|
|
|
654
659
|
const td = document.createElement('td');
|
|
655
660
|
td.colSpan = 4;
|
|
656
661
|
td.className = 'user-comment-cell';
|
|
657
662
|
|
|
658
|
-
const lineInfo = comment.line_end && comment.line_end !== comment.line_start
|
|
659
|
-
? `Lines ${comment.line_start}-${comment.line_end}`
|
|
660
|
-
: `Line ${comment.line_start}`;
|
|
661
|
-
|
|
662
663
|
const escapeHtml = this.prManager?.escapeHtml?.bind(this.prManager) || ((s) => s);
|
|
663
664
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
? `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
|
|
667
|
-
<path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
|
|
668
|
-
</svg>`
|
|
669
|
-
: `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
|
|
670
|
-
<path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
|
|
671
|
-
</svg>`;
|
|
672
|
-
|
|
673
|
-
const commentHTML = `
|
|
674
|
-
<div class="user-comment editing-mode ${comment.parent_id ? 'adopted-comment comment-ai-origin' : 'comment-user-origin'}">
|
|
665
|
+
const html = `
|
|
666
|
+
<div class="user-comment editing-mode ${originClass}">
|
|
675
667
|
<div class="user-comment-header">
|
|
676
668
|
<div class="user-comment-header-left">
|
|
677
669
|
<span class="comment-origin-icon">
|
|
678
|
-
${
|
|
670
|
+
${iconHtml}
|
|
679
671
|
</span>
|
|
680
672
|
<span class="user-comment-line-info">${lineInfo}</span>
|
|
681
|
-
${
|
|
682
|
-
${
|
|
673
|
+
${type === 'praise' ? `<span class="adopted-praise-badge" title="Nice Work"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>` : ''}
|
|
674
|
+
${title ? `<span class="adopted-title">${escapeHtml(title)}</span>` : ''}
|
|
683
675
|
</div>
|
|
684
676
|
</div>
|
|
685
|
-
|
|
686
|
-
<div class="user-comment-body" style="display: none;" data-original-markdown="${window.escapeHtmlAttribute(comment.body)}">${window.renderMarkdown ? window.renderMarkdown(comment.body) : escapeHtml(comment.body)}</div>
|
|
677
|
+
${bodyHtml || ''}
|
|
687
678
|
<div class="user-comment-edit-form">
|
|
688
679
|
<div class="comment-form-toolbar">
|
|
689
680
|
<button type="button" class="btn btn-sm suggestion-btn" title="Insert a suggestion (Ctrl+G)">
|
|
@@ -691,71 +682,55 @@ class CommentManager {
|
|
|
691
682
|
</button>
|
|
692
683
|
</div>
|
|
693
684
|
<textarea
|
|
694
|
-
id="
|
|
685
|
+
${textareaId ? `id="${textareaId}"` : ''}
|
|
695
686
|
class="comment-edit-textarea"
|
|
696
|
-
placeholder="
|
|
697
|
-
data-file="${
|
|
698
|
-
data-line="${
|
|
699
|
-
data-line-end="${
|
|
700
|
-
data-side="${
|
|
701
|
-
>${escapeHtml(
|
|
687
|
+
placeholder="${placeholder}"
|
|
688
|
+
data-file="${window.escapeHtmlAttribute ? window.escapeHtmlAttribute(dataAttrs.file) : dataAttrs.file}"
|
|
689
|
+
data-line="${dataAttrs.line}"
|
|
690
|
+
data-line-end="${dataAttrs.lineEnd}"
|
|
691
|
+
data-side="${dataAttrs.side}"
|
|
692
|
+
>${escapeHtml(body)}</textarea>
|
|
702
693
|
<div class="comment-edit-actions">
|
|
703
|
-
<button class="btn btn-sm btn-primary save-edit-btn">
|
|
704
|
-
|
|
705
|
-
</button>
|
|
706
|
-
<button class="btn btn-sm btn-secondary cancel-edit-btn">
|
|
707
|
-
Cancel
|
|
708
|
-
</button>
|
|
694
|
+
<button class="btn btn-sm btn-primary save-edit-btn">${saveLabel}</button>
|
|
695
|
+
<button class="btn btn-sm btn-secondary cancel-edit-btn">Cancel</button>
|
|
709
696
|
</div>
|
|
710
697
|
</div>
|
|
711
698
|
</div>
|
|
712
699
|
`;
|
|
713
700
|
|
|
714
|
-
td.innerHTML =
|
|
715
|
-
|
|
701
|
+
td.innerHTML = html;
|
|
702
|
+
formRow.appendChild(td);
|
|
716
703
|
|
|
717
|
-
// Insert comment immediately after the target row (suggestion row)
|
|
718
704
|
if (targetRow.nextSibling) {
|
|
719
|
-
targetRow.parentNode.insertBefore(
|
|
705
|
+
targetRow.parentNode.insertBefore(formRow, targetRow.nextSibling);
|
|
720
706
|
} else {
|
|
721
|
-
targetRow.parentNode.appendChild(
|
|
707
|
+
targetRow.parentNode.appendChild(formRow);
|
|
722
708
|
}
|
|
723
709
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const suggestionBtn =
|
|
728
|
-
const saveBtn =
|
|
729
|
-
const cancelBtn =
|
|
710
|
+
const textarea = textareaId
|
|
711
|
+
? document.getElementById(textareaId)
|
|
712
|
+
: formRow.querySelector('.comment-edit-textarea');
|
|
713
|
+
const suggestionBtn = formRow.querySelector('.suggestion-btn');
|
|
714
|
+
const saveBtn = formRow.querySelector('.save-edit-btn');
|
|
715
|
+
const cancelBtn = formRow.querySelector('.cancel-edit-btn');
|
|
730
716
|
|
|
731
717
|
if (textarea) {
|
|
732
|
-
// Auto-resize to fit content
|
|
733
718
|
this.autoResizeTextarea(textarea);
|
|
734
|
-
|
|
735
719
|
textarea.focus();
|
|
736
|
-
// Position cursor at end of text instead of selecting all
|
|
737
720
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
738
721
|
|
|
739
|
-
// Attach emoji picker for autocomplete
|
|
740
722
|
if (window.emojiPicker) {
|
|
741
723
|
window.emojiPicker.attach(textarea);
|
|
742
724
|
}
|
|
743
725
|
|
|
744
|
-
// Update suggestion button state based on content
|
|
745
726
|
this.updateSuggestionButtonState(textarea, suggestionBtn);
|
|
746
727
|
|
|
747
|
-
// Suggestion button handler
|
|
748
728
|
suggestionBtn.addEventListener('click', () => {
|
|
749
729
|
if (!suggestionBtn.disabled) {
|
|
750
730
|
this.insertSuggestionBlock(textarea, suggestionBtn);
|
|
751
731
|
}
|
|
752
732
|
});
|
|
753
733
|
|
|
754
|
-
// Save/cancel handlers - use prManager methods for consistency
|
|
755
|
-
saveBtn.addEventListener('click', () => this.prManager?.saveEditedUserComment(comment.id));
|
|
756
|
-
cancelBtn.addEventListener('click', () => this.prManager?.cancelEditUserComment(comment.id));
|
|
757
|
-
|
|
758
|
-
// Auto-resize on input and update suggestion button state
|
|
759
734
|
textarea.addEventListener('input', () => {
|
|
760
735
|
this.autoResizeTextarea(textarea);
|
|
761
736
|
this.updateSuggestionButtonState(textarea, suggestionBtn);
|
|
@@ -764,6 +739,105 @@ class CommentManager {
|
|
|
764
739
|
// Keyboard shortcuts (Escape, Cmd/Ctrl+Enter) are handled by delegated
|
|
765
740
|
// event listener in setupCommentFormDelegation() to avoid memory leaks
|
|
766
741
|
}
|
|
742
|
+
|
|
743
|
+
return { formRow, textarea, saveBtn, cancelBtn };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Display a user comment in edit mode (for adopted suggestions)
|
|
748
|
+
* @param {Object} comment - Comment data
|
|
749
|
+
* @param {HTMLElement} targetRow - Row to insert after
|
|
750
|
+
*/
|
|
751
|
+
displayUserCommentInEditMode(comment, targetRow) {
|
|
752
|
+
const lineInfo = comment.line_end && comment.line_end !== comment.line_start
|
|
753
|
+
? `Lines ${comment.line_start}-${comment.line_end}`
|
|
754
|
+
: `Line ${comment.line_start}`;
|
|
755
|
+
|
|
756
|
+
const escapeHtml = this.prManager?.escapeHtml?.bind(this.prManager) || ((s) => s);
|
|
757
|
+
const bodyHtml = `<div class="user-comment-body" style="display: none;" data-original-markdown="${window.escapeHtmlAttribute(comment.body)}">${window.renderMarkdown ? window.renderMarkdown(comment.body) : escapeHtml(comment.body)}</div>`;
|
|
758
|
+
|
|
759
|
+
const rowDataset = { commentId: comment.id, file: comment.file, lineStart: comment.line_start, lineEnd: comment.line_end || comment.line_start };
|
|
760
|
+
if (comment.side) rowDataset.side = comment.side;
|
|
761
|
+
|
|
762
|
+
const { saveBtn, cancelBtn } = this._buildEditFormRow(targetRow, {
|
|
763
|
+
rowClassName: 'user-comment-row',
|
|
764
|
+
rowDataset,
|
|
765
|
+
iconHtml: comment.parent_id ? CommentManager.AI_ICON_SVG : CommentManager.PERSON_ICON_SVG,
|
|
766
|
+
originClass: comment.parent_id ? 'adopted-comment comment-ai-origin' : 'comment-user-origin',
|
|
767
|
+
lineInfo,
|
|
768
|
+
type: comment.type,
|
|
769
|
+
title: comment.title,
|
|
770
|
+
body: comment.body,
|
|
771
|
+
bodyHtml,
|
|
772
|
+
textareaId: `edit-comment-${comment.id}`,
|
|
773
|
+
placeholder: 'Enter your comment...',
|
|
774
|
+
dataAttrs: {
|
|
775
|
+
file: comment.file,
|
|
776
|
+
line: comment.line_start,
|
|
777
|
+
lineEnd: comment.line_end || comment.line_start,
|
|
778
|
+
side: comment.side || 'RIGHT'
|
|
779
|
+
},
|
|
780
|
+
saveLabel: 'Save'
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
saveBtn.addEventListener('click', () => this.prManager?.saveEditedUserComment(comment.id));
|
|
784
|
+
cancelBtn.addEventListener('click', () => this.prManager?.cancelEditUserComment(comment.id));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Display an edit form for an AI suggestion that has NOT yet been adopted.
|
|
789
|
+
* Nothing is saved until the user clicks Save/Adopt.
|
|
790
|
+
* @param {Object} suggestion - { id, body, type, title, file, lineNumber, diffPosition, side }
|
|
791
|
+
* @param {HTMLElement} targetRow - The suggestion row to insert the form after
|
|
792
|
+
* @param {Function} onSave - Called with (editedText) when user clicks Save
|
|
793
|
+
* @param {Function} onCancel - Called when user clicks Cancel
|
|
794
|
+
*/
|
|
795
|
+
displaySuggestionEditForm(suggestion, targetRow, onSave, onCancel) {
|
|
796
|
+
// Remove any existing pending edit form (prevents stale multi-form state)
|
|
797
|
+
const existing = document.querySelector('.suggestion-edit-pending');
|
|
798
|
+
if (existing) existing.remove();
|
|
799
|
+
|
|
800
|
+
const { formRow, saveBtn, cancelBtn } = this._buildEditFormRow(targetRow, {
|
|
801
|
+
rowClassName: 'user-comment-row suggestion-edit-pending',
|
|
802
|
+
rowDataset: { suggestionId: suggestion.id },
|
|
803
|
+
iconHtml: CommentManager.AI_ICON_SVG,
|
|
804
|
+
originClass: 'adopted-comment comment-ai-origin',
|
|
805
|
+
lineInfo: suggestion.lineEnd && suggestion.lineEnd !== suggestion.lineNumber
|
|
806
|
+
? `Lines ${suggestion.lineNumber}-${suggestion.lineEnd}`
|
|
807
|
+
: `Line ${suggestion.lineNumber}`,
|
|
808
|
+
type: suggestion.type,
|
|
809
|
+
title: suggestion.title,
|
|
810
|
+
body: suggestion.body,
|
|
811
|
+
placeholder: 'Edit the suggestion...',
|
|
812
|
+
dataAttrs: {
|
|
813
|
+
file: suggestion.file,
|
|
814
|
+
line: suggestion.lineNumber,
|
|
815
|
+
lineEnd: suggestion.lineEnd || suggestion.lineNumber,
|
|
816
|
+
side: suggestion.side || 'RIGHT'
|
|
817
|
+
},
|
|
818
|
+
saveLabel: 'Save'
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
saveBtn.addEventListener('click', async () => {
|
|
822
|
+
const textarea = formRow.querySelector('.comment-edit-textarea');
|
|
823
|
+
const text = textarea?.value.trim();
|
|
824
|
+
if (text) {
|
|
825
|
+
saveBtn.disabled = true;
|
|
826
|
+
try {
|
|
827
|
+
await onSave(text);
|
|
828
|
+
formRow.remove();
|
|
829
|
+
} catch (err) {
|
|
830
|
+
saveBtn.disabled = false;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
cancelBtn.addEventListener('click', () => {
|
|
836
|
+
formRow.remove();
|
|
837
|
+
onCancel();
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
return formRow;
|
|
767
841
|
}
|
|
768
842
|
}
|
|
769
843
|
|
|
@@ -17,7 +17,7 @@ class CommentMinimizer {
|
|
|
17
17
|
/** Sparkles icon SVG (matches AI suggestion badge) */
|
|
18
18
|
static SPARKLES_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M9.6 2.279a.426.426 0 0 1 .8 0l.407 1.112a6.386 6.386 0 0 0 3.802 3.802l1.112.407a.426.426 0 0 1 0 .8l-1.112.407a6.386 6.386 0 0 0-3.802 3.802l-.407 1.112a.426.426 0 0 1-.8 0l-.407-1.112a6.386 6.386 0 0 0-3.802-3.802L4.279 8.4a.426.426 0 0 1 0-.8l1.112-.407a6.386 6.386 0 0 0 3.802-3.802L9.6 2.279Zm-4.267 8.837a.178.178 0 0 1 .334 0l.169.464a2.662 2.662 0 0 0 1.584 1.584l.464.169a.178.178 0 0 1 0 .334l-.464.169a2.662 2.662 0 0 0-1.584 1.584l-.169.464a.178.178 0 0 1-.334 0l-.169-.464a2.662 2.662 0 0 0-1.584-1.584l-.464-.169a.178.178 0 0 1 0-.334l.464-.169a2.662 2.662 0 0 0 1.584-1.584l.169-.464ZM2.8.14a.213.213 0 0 1 .4 0l.203.556a3.2 3.2 0 0 0 1.901 1.901l.556.203a.213.213 0 0 1 0 .4l-.556.203a3.2 3.2 0 0 0-1.901 1.901L3.2 5.86a.213.213 0 0 1-.4 0l-.203-.556A3.2 3.2 0 0 0 .696 3.403L.14 3.2a.213.213 0 0 1 0-.4l.556-.203A3.2 3.2 0 0 0 2.597.696L2.8.14Z"/></svg>`;
|
|
19
19
|
|
|
20
|
-
/** AI comment icon SVG — speech bubble with sparkles (
|
|
20
|
+
/** AI comment icon SVG — speech bubble with sparkles (matches CommentManager.AI_ICON_SVG, different size) */
|
|
21
21
|
static AI_COMMENT_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/></svg>`;
|
|
22
22
|
|
|
23
23
|
constructor() {
|
|
@@ -357,12 +357,8 @@ class FileCommentManager {
|
|
|
357
357
|
|
|
358
358
|
// Choose icon based on comment origin (AI-adopted vs user-originated) - matches line-level
|
|
359
359
|
const commentIcon = isAIOrigin
|
|
360
|
-
?
|
|
361
|
-
|
|
362
|
-
</svg>`
|
|
363
|
-
: `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
|
|
364
|
-
<path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
|
|
365
|
-
</svg>`;
|
|
360
|
+
? window.CommentManager.AI_ICON_SVG
|
|
361
|
+
: window.CommentManager.PERSON_ICON_SVG;
|
|
366
362
|
|
|
367
363
|
// Praise badge for "Nice Work" comments - matches line-level
|
|
368
364
|
const praiseBadge = comment.type === 'praise'
|
|
@@ -746,7 +742,7 @@ class FileCommentManager {
|
|
|
746
742
|
data-file="${window.escapeHtmlAttribute(suggestion.file)}"
|
|
747
743
|
>${this.escapeHtml(suggestion.formattedBody || suggestion.body)}</textarea>
|
|
748
744
|
<div class="file-comment-form-footer">
|
|
749
|
-
<button class="file-comment-form-btn submit submit-btn">
|
|
745
|
+
<button class="file-comment-form-btn submit submit-btn">Save</button>
|
|
750
746
|
<button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
|
|
751
747
|
</div>
|
|
752
748
|
`;
|
|
@@ -433,6 +433,7 @@ class SuggestionManager {
|
|
|
433
433
|
// Store original markdown body for adopt functionality
|
|
434
434
|
// Use JSON.stringify to preserve newlines and special characters
|
|
435
435
|
suggestionDiv.dataset.originalBody = JSON.stringify(suggestion.body || '');
|
|
436
|
+
suggestionDiv.dataset.formattedBody = JSON.stringify(suggestion.formattedBody || '');
|
|
436
437
|
|
|
437
438
|
// Store target info on the suggestion div for reliable retrieval in getFileAndLineInfo
|
|
438
439
|
// This avoids fragile DOM traversal that fails when gap rows are between suggestion and target
|
|
@@ -445,6 +446,9 @@ class SuggestionManager {
|
|
|
445
446
|
suggestionDiv.dataset.isFileLevel = targetInfo.isFileLevel ? 'true' : 'false';
|
|
446
447
|
}
|
|
447
448
|
|
|
449
|
+
// Store line_end from the suggestion itself (may differ from targetInfo.lineNumber for multi-line suggestions)
|
|
450
|
+
suggestionDiv.dataset.lineEnd = suggestion.line_end !== undefined ? String(suggestion.line_end) : (targetInfo?.lineNumber !== undefined ? String(targetInfo.lineNumber) : '');
|
|
451
|
+
|
|
448
452
|
// Convert suggestion.id to number for comparison since parent_id might be a number
|
|
449
453
|
const suggestionIdNum = parseInt(suggestion.id);
|
|
450
454
|
|
|
@@ -526,7 +530,7 @@ class SuggestionManager {
|
|
|
526
530
|
<svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>
|
|
527
531
|
Adopt
|
|
528
532
|
</button>
|
|
529
|
-
<button class="ai-action ai-action-edit" onclick="prManager.
|
|
533
|
+
<button class="ai-action ai-action-edit" onclick="prManager.editAndAdoptSuggestion(${suggestion.id})">
|
|
530
534
|
<svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path></svg>
|
|
531
535
|
Edit
|
|
532
536
|
</button>
|
|
@@ -556,6 +560,8 @@ class SuggestionManager {
|
|
|
556
560
|
extractSuggestionData(suggestionDiv) {
|
|
557
561
|
const suggestionText = suggestionDiv.dataset?.originalBody ?
|
|
558
562
|
JSON.parse(suggestionDiv.dataset.originalBody) : '';
|
|
563
|
+
const formattedBody = suggestionDiv.dataset?.formattedBody ?
|
|
564
|
+
JSON.parse(suggestionDiv.dataset.formattedBody) : '';
|
|
559
565
|
|
|
560
566
|
// Get type from ai-suggestion-badge data-type attribute or praise-badge
|
|
561
567
|
const badgeElement = suggestionDiv.querySelector('.ai-suggestion-badge, .praise-badge');
|
|
@@ -563,7 +569,7 @@ class SuggestionManager {
|
|
|
563
569
|
const suggestionType = badgeElement?.dataset?.type || (badgeElement?.classList?.contains('praise-badge') ? 'praise' : '');
|
|
564
570
|
const suggestionTitle = titleElement?.textContent?.trim() || '';
|
|
565
571
|
|
|
566
|
-
return { suggestionText, suggestionType, suggestionTitle };
|
|
572
|
+
return { suggestionText, formattedBody, suggestionType, suggestionTitle };
|
|
567
573
|
}
|
|
568
574
|
|
|
569
575
|
/**
|
|
@@ -609,6 +615,7 @@ class SuggestionManager {
|
|
|
609
615
|
// This is the reliable method that works even with gap rows between suggestion and target
|
|
610
616
|
const storedFileName = suggestionDiv.dataset.fileName;
|
|
611
617
|
const storedLineNumber = suggestionDiv.dataset.lineNumber;
|
|
618
|
+
const storedLineEnd = suggestionDiv.dataset.lineEnd;
|
|
612
619
|
const storedSide = suggestionDiv.dataset.side;
|
|
613
620
|
const storedDiffPosition = suggestionDiv.dataset.diffPosition;
|
|
614
621
|
const storedIsFileLevel = suggestionDiv.dataset.isFileLevel;
|
|
@@ -642,6 +649,7 @@ class SuggestionManager {
|
|
|
642
649
|
targetRow,
|
|
643
650
|
suggestionRow,
|
|
644
651
|
lineNumber: parseInt(storedLineNumber, 10),
|
|
652
|
+
lineEnd: storedLineEnd ? parseInt(storedLineEnd, 10) : null,
|
|
645
653
|
fileName: storedFileName,
|
|
646
654
|
diffPosition: storedDiffPosition || null,
|
|
647
655
|
side: storedSide || 'RIGHT',
|
package/public/js/pr.js
CHANGED
|
@@ -970,11 +970,8 @@ class PRManager {
|
|
|
970
970
|
const toggle = document.getElementById('pr-description-toggle');
|
|
971
971
|
if (!toggle) return;
|
|
972
972
|
|
|
973
|
-
const wrapper = toggle.closest('.pr-title-wrapper');
|
|
974
|
-
if (!wrapper) return;
|
|
975
|
-
|
|
976
973
|
const closePopover = () => {
|
|
977
|
-
const existing =
|
|
974
|
+
const existing = document.querySelector('.pr-description-popover');
|
|
978
975
|
if (existing) existing.remove();
|
|
979
976
|
toggle.classList.remove('active');
|
|
980
977
|
toggle.setAttribute('aria-expanded', 'false');
|
|
@@ -982,7 +979,7 @@ class PRManager {
|
|
|
982
979
|
|
|
983
980
|
toggle.addEventListener('click', (e) => {
|
|
984
981
|
e.stopPropagation();
|
|
985
|
-
const existing =
|
|
982
|
+
const existing = document.querySelector('.pr-description-popover');
|
|
986
983
|
if (existing) {
|
|
987
984
|
closePopover();
|
|
988
985
|
return;
|
|
@@ -1017,7 +1014,16 @@ class PRManager {
|
|
|
1017
1014
|
|
|
1018
1015
|
popover.append(arrow, header, content);
|
|
1019
1016
|
|
|
1020
|
-
|
|
1017
|
+
// Position relative to the toggle button
|
|
1018
|
+
const rect = toggle.getBoundingClientRect();
|
|
1019
|
+
popover.style.position = 'fixed';
|
|
1020
|
+
popover.style.top = `${rect.bottom + 8}px`;
|
|
1021
|
+
popover.style.left = `${rect.left + rect.width / 2}px`;
|
|
1022
|
+
popover.style.transform = 'translateX(-50%)';
|
|
1023
|
+
|
|
1024
|
+
// Append to document.body to escape overflow:hidden on .header-center
|
|
1025
|
+
document.body.appendChild(popover);
|
|
1026
|
+
|
|
1021
1027
|
toggle.classList.add('active');
|
|
1022
1028
|
toggle.setAttribute('aria-expanded', 'true');
|
|
1023
1029
|
|
|
@@ -2241,6 +2247,7 @@ class PRManager {
|
|
|
2241
2247
|
|
|
2242
2248
|
/**
|
|
2243
2249
|
* Edit user comment
|
|
2250
|
+
* NOTE: similar edit form in comment-manager.js _buildEditFormRow — keep in sync
|
|
2244
2251
|
*/
|
|
2245
2252
|
async editUserComment(commentId) {
|
|
2246
2253
|
try {
|
|
@@ -2536,7 +2543,7 @@ class PRManager {
|
|
|
2536
2543
|
*/
|
|
2537
2544
|
async clearAllUserComments() {
|
|
2538
2545
|
// Count both line-level and file-level user comments
|
|
2539
|
-
const lineCommentRows = document.querySelectorAll('.user-comment-row');
|
|
2546
|
+
const lineCommentRows = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)');
|
|
2540
2547
|
const fileCommentCards = document.querySelectorAll('.file-comment-card.user-comment');
|
|
2541
2548
|
const totalComments = lineCommentRows.length + fileCommentCards.length;
|
|
2542
2549
|
|
|
@@ -2654,7 +2661,7 @@ class PRManager {
|
|
|
2654
2661
|
});
|
|
2655
2662
|
|
|
2656
2663
|
// Clear existing comment rows before re-rendering
|
|
2657
|
-
document.querySelectorAll('.user-comment-row').forEach(row => row.remove());
|
|
2664
|
+
document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').forEach(row => row.remove());
|
|
2658
2665
|
|
|
2659
2666
|
// Before rendering, ensure all comment target lines are visible
|
|
2660
2667
|
// (expand hidden hunks so the line rows exist in the DOM)
|
|
@@ -2836,7 +2843,27 @@ class PRManager {
|
|
|
2836
2843
|
}
|
|
2837
2844
|
|
|
2838
2845
|
/**
|
|
2839
|
-
*
|
|
2846
|
+
* Build a user comment object from adoption/edit response data.
|
|
2847
|
+
* Single source of truth for comment shape — used by both adopt-as-is
|
|
2848
|
+
* and edit-then-adopt flows.
|
|
2849
|
+
*/
|
|
2850
|
+
_buildCommentObject({ userCommentId, formattedBody, fileName, lineNumber, suggestionType, suggestionTitle, suggestionId, diffPosition, side }) {
|
|
2851
|
+
return {
|
|
2852
|
+
id: userCommentId,
|
|
2853
|
+
file: fileName,
|
|
2854
|
+
line_start: parseInt(lineNumber),
|
|
2855
|
+
body: formattedBody,
|
|
2856
|
+
type: suggestionType,
|
|
2857
|
+
title: suggestionTitle,
|
|
2858
|
+
parent_id: suggestionId,
|
|
2859
|
+
diff_position: diffPosition ? parseInt(diffPosition) : null,
|
|
2860
|
+
side: side || 'RIGHT',
|
|
2861
|
+
created_at: new Date().toISOString()
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
/**
|
|
2866
|
+
* Helper for adoptSuggestion (adopt-as-is flow).
|
|
2840
2867
|
* Performs the /adopt fetch, collapses the suggestion, formats the comment,
|
|
2841
2868
|
* and builds the newComment object. Returns { newComment, suggestionRow }
|
|
2842
2869
|
* or null on failure. Throws on errors so the caller can handle them.
|
|
@@ -2865,20 +2892,12 @@ class PRManager {
|
|
|
2865
2892
|
// Collapse the suggestion in the UI
|
|
2866
2893
|
this.collapseSuggestionForAdoption(suggestionRow, suggestionId);
|
|
2867
2894
|
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
body: formattedText,
|
|
2875
|
-
type: suggestionType,
|
|
2876
|
-
title: suggestionTitle,
|
|
2877
|
-
parent_id: suggestionId,
|
|
2878
|
-
diff_position: diffPosition ? parseInt(diffPosition) : null,
|
|
2879
|
-
side: side || 'RIGHT',
|
|
2880
|
-
created_at: new Date().toISOString()
|
|
2881
|
-
};
|
|
2895
|
+
const newComment = this._buildCommentObject({
|
|
2896
|
+
userCommentId: adoptResult.userCommentId,
|
|
2897
|
+
formattedBody: adoptResult.formattedBody,
|
|
2898
|
+
fileName, lineNumber, suggestionType, suggestionTitle,
|
|
2899
|
+
suggestionId, diffPosition, side
|
|
2900
|
+
});
|
|
2882
2901
|
|
|
2883
2902
|
return { isFileLevel: false, newComment, suggestionRow };
|
|
2884
2903
|
}
|
|
@@ -2911,37 +2930,88 @@ class PRManager {
|
|
|
2911
2930
|
}
|
|
2912
2931
|
|
|
2913
2932
|
/**
|
|
2914
|
-
*
|
|
2933
|
+
* Open an AI suggestion in edit mode without adopting it yet.
|
|
2934
|
+
* The suggestion is only adopted when the user clicks Save/Adopt.
|
|
2915
2935
|
*/
|
|
2916
|
-
async
|
|
2936
|
+
async editAndAdoptSuggestion(suggestionId) {
|
|
2917
2937
|
try {
|
|
2918
2938
|
const suggestionDiv = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
|
|
2919
2939
|
if (!suggestionDiv) throw new Error('Suggestion element not found');
|
|
2920
2940
|
|
|
2921
|
-
const
|
|
2941
|
+
const { suggestionText, formattedBody, suggestionType, suggestionTitle } = this.extractSuggestionData(suggestionDiv);
|
|
2942
|
+
const { suggestionRow, lineNumber, lineEnd, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
|
|
2922
2943
|
|
|
2923
|
-
if (
|
|
2944
|
+
if (isFileLevel) {
|
|
2924
2945
|
if (!this.fileCommentManager) throw new Error('FileCommentManager not initialized');
|
|
2925
|
-
const zone = this.fileCommentManager.findZoneForFile(
|
|
2926
|
-
if (!zone) throw new Error(`Could not find file comments zone for ${
|
|
2946
|
+
const zone = this.fileCommentManager.findZoneForFile(fileName);
|
|
2947
|
+
if (!zone) throw new Error(`Could not find file comments zone for ${fileName}`);
|
|
2927
2948
|
|
|
2928
2949
|
const suggestion = {
|
|
2929
2950
|
id: suggestionId,
|
|
2930
|
-
file:
|
|
2931
|
-
body:
|
|
2932
|
-
|
|
2933
|
-
|
|
2951
|
+
file: fileName,
|
|
2952
|
+
body: suggestionText,
|
|
2953
|
+
formattedBody,
|
|
2954
|
+
type: suggestionType,
|
|
2955
|
+
title: suggestionTitle
|
|
2934
2956
|
};
|
|
2935
2957
|
|
|
2936
2958
|
this.fileCommentManager.editAndAdoptAISuggestion(zone, suggestion);
|
|
2937
2959
|
return;
|
|
2938
2960
|
}
|
|
2939
2961
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2962
|
+
// Line-level: show edit form WITHOUT adopting yet
|
|
2963
|
+
const suggestion = {
|
|
2964
|
+
id: suggestionId,
|
|
2965
|
+
file: fileName,
|
|
2966
|
+
body: formattedBody || suggestionText,
|
|
2967
|
+
type: suggestionType,
|
|
2968
|
+
title: suggestionTitle,
|
|
2969
|
+
lineNumber,
|
|
2970
|
+
lineEnd,
|
|
2971
|
+
diffPosition,
|
|
2972
|
+
side
|
|
2973
|
+
};
|
|
2974
|
+
|
|
2975
|
+
this.commentManager.displaySuggestionEditForm(
|
|
2976
|
+
suggestion,
|
|
2977
|
+
suggestionRow,
|
|
2978
|
+
async (editedText) => {
|
|
2979
|
+
// User clicked Save — now adopt via /edit endpoint
|
|
2980
|
+
try {
|
|
2981
|
+
const reviewId = this.currentPR?.id;
|
|
2982
|
+
const editResponse = await fetch(`/api/reviews/${reviewId}/suggestions/${suggestionId}/edit`, {
|
|
2983
|
+
method: 'POST',
|
|
2984
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2985
|
+
body: JSON.stringify({ action: 'adopt_edited', editedText })
|
|
2986
|
+
});
|
|
2987
|
+
|
|
2988
|
+
if (!editResponse.ok) throw new Error('Failed to adopt suggestion with edits');
|
|
2989
|
+
const editResult = await editResponse.json();
|
|
2990
|
+
|
|
2991
|
+
// Collapse the suggestion card
|
|
2992
|
+
this.collapseSuggestionForAdoption(suggestionRow, suggestionId);
|
|
2993
|
+
|
|
2994
|
+
const newComment = this._buildCommentObject({
|
|
2995
|
+
userCommentId: editResult.userCommentId,
|
|
2996
|
+
formattedBody: editResult.formattedBody,
|
|
2997
|
+
fileName, lineNumber, suggestionType, suggestionTitle,
|
|
2998
|
+
suggestionId, diffPosition, side
|
|
2999
|
+
});
|
|
3000
|
+
this.displayUserComment(newComment, suggestionRow);
|
|
3001
|
+
this._notifyAdoption(suggestionId, newComment);
|
|
3002
|
+
} catch (error) {
|
|
3003
|
+
console.error('Error saving edited suggestion:', error);
|
|
3004
|
+
alert(`Failed to save suggestion: ${error.message}`);
|
|
3005
|
+
throw error; // Re-throw so displaySuggestionEditForm can re-enable the save button
|
|
3006
|
+
}
|
|
3007
|
+
},
|
|
3008
|
+
() => {
|
|
3009
|
+
// User clicked Cancel — nothing to revert
|
|
3010
|
+
}
|
|
3011
|
+
);
|
|
2942
3012
|
} catch (error) {
|
|
2943
|
-
console.error('Error
|
|
2944
|
-
alert(`Failed to
|
|
3013
|
+
console.error('Error editing suggestion:', error);
|
|
3014
|
+
alert(`Failed to edit suggestion: ${error.message}`);
|
|
2945
3015
|
}
|
|
2946
3016
|
}
|
|
2947
3017
|
|
|
@@ -3262,7 +3332,7 @@ class PRManager {
|
|
|
3262
3332
|
*/
|
|
3263
3333
|
updateCommentCount() {
|
|
3264
3334
|
// Count both line-level comments (.user-comment-row) and file-level comments (.file-comment-card.user-comment)
|
|
3265
|
-
const lineComments = document.querySelectorAll('.user-comment-row').length;
|
|
3335
|
+
const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
|
|
3266
3336
|
const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
|
|
3267
3337
|
const userComments = lineComments + fileComments;
|
|
3268
3338
|
|
|
@@ -3299,7 +3369,7 @@ class PRManager {
|
|
|
3299
3369
|
const submitBtn = document.getElementById('submit-review-btn');
|
|
3300
3370
|
|
|
3301
3371
|
// Count BOTH line-level and file-level comments for validation
|
|
3302
|
-
const lineComments = document.querySelectorAll('.user-comment-row').length;
|
|
3372
|
+
const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
|
|
3303
3373
|
const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
|
|
3304
3374
|
const totalComments = lineComments + fileComments;
|
|
3305
3375
|
if (reviewEvent === 'REQUEST_CHANGES' && !reviewBody && totalComments === 0) {
|
package/src/database.js
CHANGED
|
@@ -2755,6 +2755,11 @@ class ReviewRepository {
|
|
|
2755
2755
|
params.push(updates.name);
|
|
2756
2756
|
}
|
|
2757
2757
|
|
|
2758
|
+
if (updates.local_base_branch !== undefined) {
|
|
2759
|
+
setClauses.push('local_base_branch = ?');
|
|
2760
|
+
params.push(updates.local_base_branch);
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2758
2763
|
if (updates.local_head_branch !== undefined) {
|
|
2759
2764
|
setClauses.push('local_head_branch = ?');
|
|
2760
2765
|
params.push(updates.local_head_branch);
|
package/src/git/base-branch.js
CHANGED
|
@@ -170,4 +170,55 @@ function tryDefaultBranch(repoPath, currentBranch, deps) {
|
|
|
170
170
|
return null;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Synchronously detect the default branch for a repository.
|
|
175
|
+
*
|
|
176
|
+
* Uses the same logic as tryDefaultBranch but returns just the branch name
|
|
177
|
+
* (or null). Suitable for call sites that need a quick, synchronous answer
|
|
178
|
+
* without the full detectBaseBranch priority chain.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} repoPath - Absolute path to the repository
|
|
181
|
+
* @param {Object} [_deps] - Dependency overrides for testing
|
|
182
|
+
* @returns {string|null} Default branch name, or null if it cannot be determined
|
|
183
|
+
*/
|
|
184
|
+
function getDefaultBranch(repoPath, _deps) {
|
|
185
|
+
const deps = { ...defaults, ..._deps };
|
|
186
|
+
|
|
187
|
+
// Try `git remote show origin`
|
|
188
|
+
try {
|
|
189
|
+
const output = deps.execSync('git remote show origin', {
|
|
190
|
+
cwd: repoPath,
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
193
|
+
timeout: 5000
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const match = output.match(/HEAD branch:\s*(.+)/);
|
|
197
|
+
if (match) {
|
|
198
|
+
const branch = match[1].trim();
|
|
199
|
+
if (branch && branch !== '(unknown)') {
|
|
200
|
+
return branch;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// No remote or network issue — try local refs
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Fallback: check if main or master exists locally
|
|
208
|
+
for (const candidate of ['main', 'master']) {
|
|
209
|
+
try {
|
|
210
|
+
deps.execSync(`git rev-parse --verify ${candidate}`, {
|
|
211
|
+
cwd: repoPath,
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
214
|
+
});
|
|
215
|
+
return candidate;
|
|
216
|
+
} catch {
|
|
217
|
+
// Branch doesn't exist
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { detectBaseBranch, getDefaultBranch };
|
package/src/routes/local.js
CHANGED
|
@@ -68,30 +68,24 @@ function deleteLocalReviewDiff(reviewId) {
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Check whether branch scope should be selectable in the scope range selector.
|
|
71
|
-
* Returns true when the branch
|
|
72
|
-
*
|
|
71
|
+
* Returns true when the current branch is a non-default, non-detached branch,
|
|
72
|
+
* or when the scope already includes branch.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} branchName - Current branch name
|
|
75
|
+
* @param {string} scopeStart - Current scope start stop
|
|
76
|
+
* @param {string} localPath - Absolute path to the repository (used to detect the actual default branch)
|
|
73
77
|
*/
|
|
74
|
-
|
|
78
|
+
function isBranchAvailable(branchName, scopeStart, localPath) {
|
|
75
79
|
if (includesBranch(scopeStart)) return true;
|
|
76
|
-
if (!branchName || branchName === 'HEAD' || branchName === 'unknown'
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
_deps: depsOverride
|
|
84
|
-
});
|
|
85
|
-
if (detection) {
|
|
86
|
-
// Lazy require to ensure testability via vi.spyOn on the module exports
|
|
87
|
-
const localReview = require('../local-review');
|
|
88
|
-
const commitCount = await localReview.getBranchCommitCount(localPath, detection.baseBranch);
|
|
89
|
-
return commitCount > 0;
|
|
90
|
-
}
|
|
91
|
-
} catch {
|
|
92
|
-
// Non-fatal — branch stop stays disabled
|
|
80
|
+
if (!branchName || branchName === 'HEAD' || branchName === 'unknown') return false;
|
|
81
|
+
|
|
82
|
+
const { getDefaultBranch } = require('../git/base-branch');
|
|
83
|
+
const defaultBranch = localPath ? getDefaultBranch(localPath) : null;
|
|
84
|
+
// If detection fails, fall back to checking main/master
|
|
85
|
+
if (defaultBranch) {
|
|
86
|
+
return branchName !== defaultBranch;
|
|
93
87
|
}
|
|
94
|
-
return
|
|
88
|
+
return branchName !== 'main' && branchName !== 'master';
|
|
95
89
|
}
|
|
96
90
|
|
|
97
91
|
/**
|
|
@@ -506,6 +500,7 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
506
500
|
* Get local review metadata
|
|
507
501
|
*/
|
|
508
502
|
router.get('/api/local/:reviewId', async (req, res) => {
|
|
503
|
+
const tEndpoint = Date.now();
|
|
509
504
|
try {
|
|
510
505
|
const reviewId = parseInt(req.params.reviewId);
|
|
511
506
|
|
|
@@ -602,13 +597,15 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
602
597
|
|
|
603
598
|
// Determine if Branch stop should be selectable in the scope range selector.
|
|
604
599
|
// This is independent of branchInfo (which guards on no uncommitted changes).
|
|
605
|
-
// Branch is available when: not detached HEAD, not on default branch
|
|
606
|
-
const branchAvailable = Boolean(branchInfo) ||
|
|
607
|
-
review.local_path, branchName, scopeStart, req.app.get('config') || {}, repositoryName
|
|
608
|
-
);
|
|
600
|
+
// Branch is available when: not detached HEAD, not on default branch.
|
|
601
|
+
const branchAvailable = Boolean(branchInfo) || isBranchAvailable(branchName, scopeStart, review.local_path);
|
|
609
602
|
|
|
610
603
|
// Compute SHA abbreviation length from the repo's git config
|
|
611
604
|
const shaAbbrevLength = getShaAbbrevLength(review.local_path);
|
|
605
|
+
const metadataElapsed = Date.now() - tEndpoint;
|
|
606
|
+
if (metadataElapsed > 200) {
|
|
607
|
+
logger.debug(`[perf] metadata#${reviewId} took ${metadataElapsed}ms (threshold: 200ms)`);
|
|
608
|
+
}
|
|
612
609
|
|
|
613
610
|
res.json({
|
|
614
611
|
id: review.id,
|
|
@@ -630,6 +627,29 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
630
627
|
updatedAt: review.updated_at
|
|
631
628
|
});
|
|
632
629
|
|
|
630
|
+
// Background: pre-cache base branch detection so set-scope is fast later
|
|
631
|
+
if (!includesBranch(scopeStart) && !review.local_base_branch
|
|
632
|
+
&& branchName && branchName !== 'HEAD' && branchName !== 'unknown'
|
|
633
|
+
&& repositoryName && repositoryName.includes('/')) {
|
|
634
|
+
const bgConfig = req.app.get('config') || {};
|
|
635
|
+
const bgToken = getGitHubToken(bgConfig);
|
|
636
|
+
const bgT0 = Date.now();
|
|
637
|
+
const { detectBaseBranch } = require('../git/base-branch');
|
|
638
|
+
detectBaseBranch(review.local_path, branchName, {
|
|
639
|
+
repository: repositoryName,
|
|
640
|
+
enableGraphite: bgConfig.enable_graphite === true,
|
|
641
|
+
_deps: bgToken ? { getGitHubToken: () => bgToken } : undefined
|
|
642
|
+
}).then(detection => {
|
|
643
|
+
if (detection && detection.baseBranch) {
|
|
644
|
+
return reviewRepo.updateReview(reviewId, { local_base_branch: detection.baseBranch });
|
|
645
|
+
}
|
|
646
|
+
}).then(() => {
|
|
647
|
+
logger.debug(`[perf] metadata#${reviewId} background-detectBaseBranch: ${Date.now() - bgT0}ms`);
|
|
648
|
+
}).catch(err => {
|
|
649
|
+
logger.warn(`Background base branch detection failed: ${err.message}`);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
633
653
|
// Fire review.loaded hook (session already exists to be fetched by ID)
|
|
634
654
|
const hookConfig = req.app.get('config') || {};
|
|
635
655
|
if (hasHooks('review.loaded', hookConfig)) {
|
|
@@ -704,6 +724,7 @@ router.patch('/api/local/:reviewId/name', async (req, res) => {
|
|
|
704
724
|
* Get local diff
|
|
705
725
|
*/
|
|
706
726
|
router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
727
|
+
const tEndpoint = Date.now();
|
|
707
728
|
try {
|
|
708
729
|
const reviewId = parseInt(req.params.reviewId);
|
|
709
730
|
|
|
@@ -781,6 +802,10 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
781
802
|
}
|
|
782
803
|
}
|
|
783
804
|
|
|
805
|
+
const diffElapsed = Date.now() - tEndpoint;
|
|
806
|
+
if (diffElapsed > 200) {
|
|
807
|
+
logger.debug(`[perf] diff#${reviewId} took ${diffElapsed}ms (threshold: 200ms)`);
|
|
808
|
+
}
|
|
784
809
|
res.json({
|
|
785
810
|
diff: diffContent || '',
|
|
786
811
|
generated_files: generatedFiles,
|
|
@@ -805,6 +830,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
805
830
|
* Uses a digest of the diff content for accurate change detection
|
|
806
831
|
*/
|
|
807
832
|
router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
833
|
+
const tEndpoint = Date.now();
|
|
808
834
|
try {
|
|
809
835
|
const reviewId = parseInt(req.params.reviewId);
|
|
810
836
|
|
|
@@ -861,6 +887,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
861
887
|
|
|
862
888
|
// When branch is in scope and HEAD changed, early return (existing behavior)
|
|
863
889
|
if (includesBranch(scopeStart) && headShaChanged) {
|
|
890
|
+
const staleEarlyElapsed = Date.now() - tEndpoint;
|
|
891
|
+
if (staleEarlyElapsed > 200) {
|
|
892
|
+
logger.debug(`[perf] check-stale#${reviewId} took ${staleEarlyElapsed}ms (threshold: 200ms)`);
|
|
893
|
+
}
|
|
864
894
|
return res.json({
|
|
865
895
|
isStale: true,
|
|
866
896
|
headShaChanged,
|
|
@@ -918,6 +948,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
918
948
|
|
|
919
949
|
const isStale = storedDiffData.digest !== currentDigest;
|
|
920
950
|
|
|
951
|
+
const staleElapsed = Date.now() - tEndpoint;
|
|
952
|
+
if (staleElapsed > 200) {
|
|
953
|
+
logger.debug(`[perf] check-stale#${reviewId} took ${staleElapsed}ms (threshold: 200ms)`);
|
|
954
|
+
}
|
|
921
955
|
res.json({
|
|
922
956
|
isStale,
|
|
923
957
|
storedDigest: storedDiffData.digest,
|
|
@@ -1377,12 +1411,10 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1377
1411
|
|
|
1378
1412
|
// Recompute branchAvailable so the frontend can update the scope selector
|
|
1379
1413
|
// (e.g. after a commit creates the first branch-ahead commit).
|
|
1380
|
-
|
|
1414
|
+
// Lazy require to ensure testability via vi.spyOn on the module exports.
|
|
1381
1415
|
let branchName;
|
|
1382
|
-
try { branchName = await getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
|
|
1383
|
-
const branchAvailable =
|
|
1384
|
-
localPath, branchName, scopeStart, config, review.repository
|
|
1385
|
-
);
|
|
1416
|
+
try { branchName = await require('../local-review').getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
|
|
1417
|
+
const branchAvailable = isBranchAvailable(branchName, scopeStart, localPath);
|
|
1386
1418
|
|
|
1387
1419
|
// Non-branch HEAD change: skip diff computation entirely — the old diff is
|
|
1388
1420
|
// preserved until the user decides (via resolve-head-change) what to do.
|
|
@@ -1491,8 +1523,8 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1491
1523
|
|
|
1492
1524
|
// Persist SHA and branch together in a single write so SQLite only
|
|
1493
1525
|
// ever sees the final identity tuple — no transient intermediate state.
|
|
1494
|
-
await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch });
|
|
1495
|
-
logger.log('API', `Updated HEAD SHA and branch on session ${reviewId}`, 'cyan');
|
|
1526
|
+
await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch, local_base_branch: null });
|
|
1527
|
+
logger.log('API', `Updated HEAD SHA and branch on session ${reviewId} (cleared cached base branch)`, 'cyan');
|
|
1496
1528
|
|
|
1497
1529
|
// Recompute and persist diff
|
|
1498
1530
|
const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
|
|
@@ -1506,10 +1538,7 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1506
1538
|
|
|
1507
1539
|
// Recompute branchAvailable — the commit may have created the first
|
|
1508
1540
|
// branch-ahead commit, making the Branch scope stop selectable.
|
|
1509
|
-
const
|
|
1510
|
-
const branchAvailable = await checkBranchAvailable(
|
|
1511
|
-
localPath, headBranch, scopeStart, config, review.repository
|
|
1512
|
-
);
|
|
1541
|
+
const branchAvailable = isBranchAvailable(headBranch, scopeStart, localPath);
|
|
1513
1542
|
|
|
1514
1543
|
return res.json({ success: true, action: 'updated', branchAvailable });
|
|
1515
1544
|
}
|
|
@@ -1593,20 +1622,26 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
|
|
|
1593
1622
|
let baseBranch = requestBaseBranch || null;
|
|
1594
1623
|
let currentBranch = null;
|
|
1595
1624
|
if (includesBranch(scopeStart)) {
|
|
1596
|
-
currentBranch = await getCurrentBranch(localPath);
|
|
1625
|
+
currentBranch = await require('../local-review').getCurrentBranch(localPath);
|
|
1597
1626
|
if (!baseBranch) {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1627
|
+
// Use cached base branch from background detection if available
|
|
1628
|
+
if (review.local_base_branch && review.local_head_branch === currentBranch) {
|
|
1629
|
+
baseBranch = review.local_base_branch;
|
|
1630
|
+
logger.debug(`[perf] set-scope#${reviewId} using cached base branch: ${baseBranch}`);
|
|
1631
|
+
} else {
|
|
1632
|
+
const { detectBaseBranch } = require('../git/base-branch');
|
|
1633
|
+
const config = req.app.get('config') || {};
|
|
1634
|
+
const token = getGitHubToken(config);
|
|
1635
|
+
const detection = await detectBaseBranch(localPath, currentBranch, {
|
|
1636
|
+
repository: review.repository,
|
|
1637
|
+
enableGraphite: config.enable_graphite === true,
|
|
1638
|
+
_deps: token ? { getGitHubToken: () => token } : undefined
|
|
1639
|
+
});
|
|
1640
|
+
if (!detection) {
|
|
1641
|
+
return res.status(400).json({ error: 'Could not detect base branch' });
|
|
1642
|
+
}
|
|
1643
|
+
baseBranch = detection.baseBranch;
|
|
1608
1644
|
}
|
|
1609
|
-
baseBranch = detection.baseBranch;
|
|
1610
1645
|
}
|
|
1611
1646
|
|
|
1612
1647
|
// Validate branch name to prevent shell injection
|