@in-the-loop-labs/pair-review 2.6.1 → 2.6.3
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/bin/git-diff-lines +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
- package/public/css/pr.css +78 -10
- package/public/index.html +7 -0
- package/public/js/index.js +22 -4
- package/public/js/local.js +6 -0
- package/public/js/pr.js +143 -12
- package/public/pr.html +16 -0
- package/src/ai/analyzer.js +5 -5
- package/src/ai/cursor-agent-provider.js +21 -12
- package/src/chat/prompt-builder.js +3 -3
- package/src/config.js +2 -1
- package/src/git/worktree.js +81 -25
- package/src/local-review.js +1 -1
- package/src/main.js +2 -2
- package/src/routes/config.js +1 -0
- package/src/routes/github-collections.js +2 -2
- package/src/routes/pr.js +2 -2
- package/src/routes/worktrees.js +6 -3
- package/src/utils/diff-file-list.js +1 -1
package/bin/git-diff-lines
CHANGED
|
@@ -68,7 +68,7 @@ function executeGitDiff(args, cwd = null) {
|
|
|
68
68
|
if (cwd) {
|
|
69
69
|
spawnOptions.cwd = cwd;
|
|
70
70
|
}
|
|
71
|
-
const gitProcess = spawn('git', ['diff', ...args], spawnOptions);
|
|
71
|
+
const gitProcess = spawn('git', ['diff', '--no-ext-diff', ...args], spawnOptions);
|
|
72
72
|
|
|
73
73
|
let stdout = '';
|
|
74
74
|
let stderr = '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.3",
|
|
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": "2.6.
|
|
3
|
+
"version": "2.6.3",
|
|
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",
|
|
@@ -84,7 +84,7 @@ GIT_CMD=(git)
|
|
|
84
84
|
if [[ -n "$GIT_CWD" ]]; then
|
|
85
85
|
GIT_CMD+=(-C "$GIT_CWD")
|
|
86
86
|
fi
|
|
87
|
-
GIT_CMD+=(diff)
|
|
87
|
+
GIT_CMD+=(diff --no-ext-diff)
|
|
88
88
|
# Guard: expanding an empty array with set -u fails in Bash < 4.4
|
|
89
89
|
if [[ ${#GIT_ARGS[@]} -gt 0 ]]; then
|
|
90
90
|
GIT_CMD+=("${GIT_ARGS[@]}")
|
package/public/css/pr.css
CHANGED
|
@@ -99,6 +99,50 @@
|
|
|
99
99
|
color: var(--color-text-tertiary);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/* Stale Badge */
|
|
103
|
+
.stale-badge {
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 5px;
|
|
107
|
+
padding: 3px 10px;
|
|
108
|
+
border-radius: 12px;
|
|
109
|
+
font-size: 11px;
|
|
110
|
+
font-weight: 700;
|
|
111
|
+
letter-spacing: 0.5px;
|
|
112
|
+
color: #fff;
|
|
113
|
+
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
|
|
114
|
+
box-shadow: 0 2px 6px rgba(217, 119, 6, 0.35);
|
|
115
|
+
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
margin-left: 8px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
.stale-badge.pr-closed {
|
|
122
|
+
background: linear-gradient(135deg, #cf222e 0%, #a40e26 100%);
|
|
123
|
+
box-shadow: 0 2px 6px rgba(207, 34, 46, 0.35);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.stale-badge.pr-merged {
|
|
127
|
+
background: linear-gradient(135deg, #8250df 0%, #6639ba 100%);
|
|
128
|
+
box-shadow: 0 2px 6px rgba(130, 80, 223, 0.35);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
[data-theme="dark"] .stale-badge {
|
|
132
|
+
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
|
133
|
+
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
[data-theme="dark"] .stale-badge.pr-closed {
|
|
137
|
+
background: linear-gradient(135deg, #f85149 0%, #cf222e 100%);
|
|
138
|
+
box-shadow: 0 2px 8px rgba(248, 81, 73, 0.4);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
[data-theme="dark"] .stale-badge.pr-merged {
|
|
142
|
+
background: linear-gradient(135deg, #a371f7 0%, #8250df 100%);
|
|
143
|
+
box-shadow: 0 2px 8px rgba(163, 113, 247, 0.4);
|
|
144
|
+
}
|
|
145
|
+
|
|
102
146
|
.pr-title .github-link {
|
|
103
147
|
display: inline-flex;
|
|
104
148
|
align-items: center;
|
|
@@ -514,7 +558,7 @@
|
|
|
514
558
|
/* Theme toggle button specific styling */
|
|
515
559
|
#theme-toggle {
|
|
516
560
|
background-color: transparent;
|
|
517
|
-
border: 1px solid
|
|
561
|
+
border: 1px solid transparent;
|
|
518
562
|
color: var(--color-text-primary);
|
|
519
563
|
padding: 6px 10px;
|
|
520
564
|
display: inline-flex;
|
|
@@ -524,6 +568,7 @@
|
|
|
524
568
|
|
|
525
569
|
#theme-toggle:hover {
|
|
526
570
|
background-color: var(--color-bg-secondary);
|
|
571
|
+
border-color: var(--color-border-primary);
|
|
527
572
|
}
|
|
528
573
|
|
|
529
574
|
#theme-toggle svg {
|
|
@@ -1542,6 +1587,13 @@
|
|
|
1542
1587
|
color: #f85149;
|
|
1543
1588
|
}
|
|
1544
1589
|
|
|
1590
|
+
/* When collapsed, the wrapper is only as tall as the header, so the header's
|
|
1591
|
+
sticky positioning can't push it below the toolbar. Adding scroll-margin
|
|
1592
|
+
ensures scrollIntoView() lands the header below the sticky toolbar. */
|
|
1593
|
+
.d2h-file-wrapper.collapsed {
|
|
1594
|
+
scroll-margin-top: var(--toolbar-height, 0px);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1545
1597
|
/* Hide diff content when collapsed */
|
|
1546
1598
|
.d2h-file-wrapper.collapsed .d2h-file-body,
|
|
1547
1599
|
.d2h-file-wrapper.collapsed .d2h-diff-table {
|
|
@@ -6078,15 +6130,25 @@ body::before {
|
|
|
6078
6130
|
justify-content: center;
|
|
6079
6131
|
}
|
|
6080
6132
|
|
|
6081
|
-
/* Icon buttons within the icon group have special styling (no border, transparent bg)
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6133
|
+
/* Icon buttons within the icon group have special styling (no border, transparent bg).
|
|
6134
|
+
Uses high-specificity selector to override the generic .btn-icon rule (council selector)
|
|
6135
|
+
and the [data-theme="dark"] .btn-icon dark-mode override.
|
|
6136
|
+
NOTE: ID-based selectors (#theme-toggle, #refresh-pr) beat this rule regardless of
|
|
6137
|
+
how many classes are stacked, so those rules must also set border to transparent. */
|
|
6138
|
+
[data-theme] .header-icon-group .btn.btn-icon {
|
|
6139
|
+
border: 1px solid transparent;
|
|
6140
|
+
border-color: transparent;
|
|
6085
6141
|
background: transparent;
|
|
6142
|
+
color: var(--color-text-tertiary);
|
|
6143
|
+
width: 36px;
|
|
6144
|
+
height: 36px;
|
|
6145
|
+
box-sizing: border-box;
|
|
6086
6146
|
}
|
|
6087
6147
|
|
|
6088
|
-
.header-icon-group .btn-icon:hover {
|
|
6148
|
+
[data-theme] .header-icon-group .btn.btn-icon:hover {
|
|
6089
6149
|
background: var(--color-bg-tertiary);
|
|
6150
|
+
border-color: var(--color-border-primary);
|
|
6151
|
+
color: var(--color-text-primary);
|
|
6090
6152
|
}
|
|
6091
6153
|
|
|
6092
6154
|
.header .btn-secondary {
|
|
@@ -6111,27 +6173,33 @@ body::before {
|
|
|
6111
6173
|
box-shadow: 0 0 12px var(--ai-glow);
|
|
6112
6174
|
}
|
|
6113
6175
|
|
|
6114
|
-
/* Theme toggle in header
|
|
6176
|
+
/* Theme toggle in header — border is transparent at rest, visible on hover.
|
|
6177
|
+
Must set border-color explicitly here because this ID selector beats the
|
|
6178
|
+
class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
|
|
6115
6179
|
.header #theme-toggle {
|
|
6116
6180
|
background: transparent;
|
|
6117
|
-
border: 1px solid
|
|
6181
|
+
border: 1px solid transparent;
|
|
6118
6182
|
color: var(--color-text-secondary);
|
|
6119
6183
|
}
|
|
6120
6184
|
|
|
6121
6185
|
.header #theme-toggle:hover {
|
|
6122
6186
|
background: var(--color-bg-secondary);
|
|
6187
|
+
border-color: var(--color-border-primary);
|
|
6123
6188
|
color: var(--color-text-primary);
|
|
6124
6189
|
}
|
|
6125
6190
|
|
|
6126
|
-
/* Refresh button in header
|
|
6191
|
+
/* Refresh button in header — border is transparent at rest, visible on hover.
|
|
6192
|
+
Must set border-color explicitly here because this ID selector beats the
|
|
6193
|
+
class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
|
|
6127
6194
|
.header #refresh-pr {
|
|
6128
6195
|
background: transparent;
|
|
6129
|
-
border: 1px solid
|
|
6196
|
+
border: 1px solid transparent;
|
|
6130
6197
|
color: var(--color-text-secondary);
|
|
6131
6198
|
}
|
|
6132
6199
|
|
|
6133
6200
|
.header #refresh-pr:hover {
|
|
6134
6201
|
background: var(--color-bg-secondary);
|
|
6202
|
+
border-color: var(--color-border-primary);
|
|
6135
6203
|
color: var(--color-text-primary);
|
|
6136
6204
|
}
|
|
6137
6205
|
|
package/public/index.html
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
|
8
8
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
9
9
|
})();
|
|
10
|
+
window.__pairReview = window.__pairReview || {};
|
|
11
|
+
window.__pairReview.toGraphiteUrl = function(githubUrl) {
|
|
12
|
+
return githubUrl.replace(
|
|
13
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
|
|
14
|
+
'https://app.graphite.com/github/pr/$1/$2/$3'
|
|
15
|
+
);
|
|
16
|
+
};
|
|
10
17
|
</script>
|
|
11
18
|
<meta charset="UTF-8">
|
|
12
19
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
package/public/js/index.js
CHANGED
|
@@ -313,6 +313,16 @@
|
|
|
313
313
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
|
|
314
314
|
'</a>';
|
|
315
315
|
|
|
316
|
+
var graphiteLinkHtml = '';
|
|
317
|
+
if (window.__pairReview?.enableGraphite && pr.html_url) {
|
|
318
|
+
// Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
|
|
319
|
+
var graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
|
|
320
|
+
graphiteLinkHtml =
|
|
321
|
+
'<a href="' + escapeHtml(graphiteUrl) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on Graphite">' +
|
|
322
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/><polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/></svg>' +
|
|
323
|
+
'</a>';
|
|
324
|
+
}
|
|
325
|
+
|
|
316
326
|
var authorTd = collection === 'my-prs'
|
|
317
327
|
? ''
|
|
318
328
|
: '<td class="col-author">' + authorDisplay + '</td>';
|
|
@@ -324,7 +334,7 @@
|
|
|
324
334
|
'<td class="col-title" title="' + escapeHtml(pr.title || '') + '">' + escapeHtml(pr.title || '') + '</td>' +
|
|
325
335
|
authorTd +
|
|
326
336
|
'<td class="col-time">' + relativeTime + '</td>' +
|
|
327
|
-
'<td class="col-actions">' + githubLinkHtml + '</td>' +
|
|
337
|
+
'<td class="col-actions">' + githubLinkHtml + graphiteLinkHtml + '</td>' +
|
|
328
338
|
'</tr>';
|
|
329
339
|
}
|
|
330
340
|
|
|
@@ -750,6 +760,11 @@
|
|
|
750
760
|
'<a href="https://github.com/' + escapeHtml(worktree.repository) + '/pull/' + worktree.pr_number + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on GitHub">' +
|
|
751
761
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
|
|
752
762
|
'</a>' +
|
|
763
|
+
(window.__pairReview?.enableGraphite && worktree.html_url
|
|
764
|
+
? '<a href="' + escapeHtml(window.__pairReview.toGraphiteUrl(worktree.html_url)) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on Graphite">' +
|
|
765
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/><polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/></svg>' +
|
|
766
|
+
'</a>'
|
|
767
|
+
: '') +
|
|
753
768
|
'<a href="' + settingsLink + '" class="btn-repo-settings" title="Repository settings">' +
|
|
754
769
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
|
|
755
770
|
'<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>' +
|
|
@@ -1103,6 +1118,7 @@
|
|
|
1103
1118
|
window.__pairReview.chatProvider = config.chat_provider || 'pi';
|
|
1104
1119
|
const chatProviders = config.chat_providers || [];
|
|
1105
1120
|
window.__pairReview.chatProviders = chatProviders;
|
|
1121
|
+
window.__pairReview.enableGraphite = config.enable_graphite === true;
|
|
1106
1122
|
|
|
1107
1123
|
// Set chat feature state based on config and provider availability
|
|
1108
1124
|
let chatState = 'disabled';
|
|
@@ -1232,9 +1248,11 @@
|
|
|
1232
1248
|
|
|
1233
1249
|
// ─── DOMContentLoaded Initialization ────────────────────────────────────────
|
|
1234
1250
|
|
|
1235
|
-
document.addEventListener('DOMContentLoaded', function () {
|
|
1236
|
-
// Load config and update command examples based on npx detection
|
|
1237
|
-
|
|
1251
|
+
document.addEventListener('DOMContentLoaded', async function () {
|
|
1252
|
+
// Load config and update command examples based on npx detection.
|
|
1253
|
+
// Await so that window.__pairReview.enableGraphite (and other config
|
|
1254
|
+
// values) are available before any tab content is rendered.
|
|
1255
|
+
await loadConfigAndUpdateUI();
|
|
1238
1256
|
|
|
1239
1257
|
// Restore saved tab from localStorage (default: 'pr-tab')
|
|
1240
1258
|
const savedTab = localStorage.getItem(TAB_STORAGE_KEY) || 'pr-tab';
|
package/public/js/local.js
CHANGED
|
@@ -588,6 +588,12 @@ class LocalManager {
|
|
|
588
588
|
githubLink.style.display = 'none';
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
+
// Hide Graphite link (no PR to link to in local mode)
|
|
592
|
+
const graphiteLink = document.getElementById('graphite-link');
|
|
593
|
+
if (graphiteLink) {
|
|
594
|
+
graphiteLink.style.display = 'none';
|
|
595
|
+
}
|
|
596
|
+
|
|
591
597
|
// Hide refresh button (no remote to refresh from)
|
|
592
598
|
const refreshBtn = document.getElementById('refresh-pr');
|
|
593
599
|
if (refreshBtn) {
|
package/public/js/pr.js
CHANGED
|
@@ -136,6 +136,8 @@ class PRManager {
|
|
|
136
136
|
this.hideWhitespace = false;
|
|
137
137
|
// Diff options dropdown (gear icon popover)
|
|
138
138
|
this.diffOptionsDropdown = null;
|
|
139
|
+
// Cached staleness check promise — shared between on-load and triggerAIAnalysis
|
|
140
|
+
this._stalenessPromise = null;
|
|
139
141
|
// Unique client ID for self-echo suppression on WebSocket review events.
|
|
140
142
|
// Sent as X-Client-Id header on mutation requests; the server echoes
|
|
141
143
|
// it back in the WebSocket broadcast so this tab can skip its own events.
|
|
@@ -511,6 +513,9 @@ class PRManager {
|
|
|
511
513
|
// Listen for review mutation events via WebSocket pub/sub
|
|
512
514
|
this._initReviewEventListeners();
|
|
513
515
|
|
|
516
|
+
// Fire-and-forget staleness check — shows badge or auto-refreshes
|
|
517
|
+
this._stalenessPromise = this._checkStalenessOnLoad(owner, repo, number);
|
|
518
|
+
|
|
514
519
|
} catch (error) {
|
|
515
520
|
console.error('Error loading PR:', error);
|
|
516
521
|
this.showError(error.message);
|
|
@@ -902,6 +907,15 @@ class PRManager {
|
|
|
902
907
|
githubLink.href = pr.html_url;
|
|
903
908
|
}
|
|
904
909
|
|
|
910
|
+
// Update Graphite link (gated on enable_graphite config)
|
|
911
|
+
const graphiteLink = document.getElementById('graphite-link');
|
|
912
|
+
if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite) {
|
|
913
|
+
// Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
|
|
914
|
+
const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
|
|
915
|
+
graphiteLink.href = graphiteUrl;
|
|
916
|
+
graphiteLink.style.display = '';
|
|
917
|
+
}
|
|
918
|
+
|
|
905
919
|
// Update settings link
|
|
906
920
|
const settingsLink = document.getElementById('settings-link');
|
|
907
921
|
if (settingsLink && pr.owner && pr.repo) {
|
|
@@ -4080,19 +4094,13 @@ class PRManager {
|
|
|
4080
4094
|
return;
|
|
4081
4095
|
}
|
|
4082
4096
|
|
|
4083
|
-
// Run stale check and settings fetch in parallel to minimize dialog delay
|
|
4084
|
-
//
|
|
4085
|
-
// freeing the HTTP connection for subsequent requests.
|
|
4097
|
+
// Run stale check and settings fetch in parallel to minimize dialog delay.
|
|
4098
|
+
// Reuse the on-load staleness promise if still available, otherwise fetch fresh.
|
|
4086
4099
|
const _tParallel0 = performance.now();
|
|
4087
|
-
const
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
}, STALE_TIMEOUT);
|
|
4092
|
-
const staleCheckWithTimeout = fetch(`/api/pr/${owner}/${repo}/${number}/check-stale`, { signal: staleAbort.signal })
|
|
4093
|
-
.then(r => r.ok ? r.json() : null)
|
|
4094
|
-
.then(result => { clearTimeout(staleTimer); return result; })
|
|
4095
|
-
.catch(() => { clearTimeout(staleTimer); return null; });
|
|
4100
|
+
const staleCheckWithTimeout = this._stalenessPromise
|
|
4101
|
+
? this._stalenessPromise
|
|
4102
|
+
: this._fetchStaleness(owner, repo, number);
|
|
4103
|
+
this._stalenessPromise = null; // consume it
|
|
4096
4104
|
|
|
4097
4105
|
const [staleResult, repoSettings, reviewSettings] = await Promise.all([
|
|
4098
4106
|
staleCheckWithTimeout,
|
|
@@ -4330,6 +4338,127 @@ class PRManager {
|
|
|
4330
4338
|
this.resetButton();
|
|
4331
4339
|
}
|
|
4332
4340
|
|
|
4341
|
+
// ─── Staleness Badge ────────────────────────────────────────────
|
|
4342
|
+
|
|
4343
|
+
/**
|
|
4344
|
+
* Fire-and-forget staleness check on page load.
|
|
4345
|
+
* If stale and no active session data, silently refreshes.
|
|
4346
|
+
* If stale and session data exists, shows the badge.
|
|
4347
|
+
* Also shows badge variants for closed/merged PRs.
|
|
4348
|
+
* @returns {Promise<Object|null>} The parsed staleness result, or null on failure.
|
|
4349
|
+
*/
|
|
4350
|
+
async _checkStalenessOnLoad(owner, repo, number) {
|
|
4351
|
+
try {
|
|
4352
|
+
const result = await this._fetchStaleness(owner, repo, number);
|
|
4353
|
+
if (!result) return null;
|
|
4354
|
+
|
|
4355
|
+
// Show badge for closed/merged PRs regardless of staleness
|
|
4356
|
+
if (result.prState && result.prState !== 'open') {
|
|
4357
|
+
const type = result.merged ? 'merged' : 'closed';
|
|
4358
|
+
this._showStaleBadge(type);
|
|
4359
|
+
return result;
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
if (result.isStale !== true) return result;
|
|
4363
|
+
|
|
4364
|
+
// Stale — decide: silent refresh or show badge
|
|
4365
|
+
const hasData = await this._hasActiveSessionData();
|
|
4366
|
+
if (hasData) {
|
|
4367
|
+
this._showStaleBadge('stale');
|
|
4368
|
+
} else {
|
|
4369
|
+
// No user work to protect — refresh silently
|
|
4370
|
+
await this.refreshPR();
|
|
4371
|
+
}
|
|
4372
|
+
return result;
|
|
4373
|
+
} catch {
|
|
4374
|
+
// Fail silently — staleness badge is best-effort
|
|
4375
|
+
return null;
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
/**
|
|
4380
|
+
* Fetch staleness data from the server with a timeout.
|
|
4381
|
+
* @returns {Promise<Object|null>} The parsed staleness result, or null on failure/timeout.
|
|
4382
|
+
*/
|
|
4383
|
+
async _fetchStaleness(owner, repo, number) {
|
|
4384
|
+
try {
|
|
4385
|
+
const staleAbort = new AbortController();
|
|
4386
|
+
const staleTimer = setTimeout(() => staleAbort.abort(), STALE_TIMEOUT);
|
|
4387
|
+
const response = await fetch(
|
|
4388
|
+
`/api/pr/${owner}/${repo}/${number}/check-stale`,
|
|
4389
|
+
{ signal: staleAbort.signal }
|
|
4390
|
+
);
|
|
4391
|
+
clearTimeout(staleTimer);
|
|
4392
|
+
if (!response.ok) return null;
|
|
4393
|
+
return await response.json();
|
|
4394
|
+
} catch {
|
|
4395
|
+
return null;
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
/**
|
|
4400
|
+
* Check whether the current session has user work worth protecting
|
|
4401
|
+
* (analysis results or active user comments).
|
|
4402
|
+
* Returns true if uncertain (fail-safe: don't auto-refresh).
|
|
4403
|
+
*/
|
|
4404
|
+
async _hasActiveSessionData() {
|
|
4405
|
+
const reviewId = this.currentPR?.id;
|
|
4406
|
+
// No review record → no session data possible
|
|
4407
|
+
if (!reviewId) return false;
|
|
4408
|
+
|
|
4409
|
+
try {
|
|
4410
|
+
const [suggestionsRes, commentsRes] = await Promise.all([
|
|
4411
|
+
fetch(`/api/reviews/${reviewId}/suggestions/check`).then(r => r.ok ? r.json() : null),
|
|
4412
|
+
fetch(`/api/reviews/${reviewId}/comments`).then(r => r.ok ? r.json() : null)
|
|
4413
|
+
]);
|
|
4414
|
+
|
|
4415
|
+
const hasAnalysis = suggestionsRes?.analysisHasRun === true;
|
|
4416
|
+
const hasUserComments = (commentsRes?.comments || []).some(
|
|
4417
|
+
c => c.source === 'user' && c.status !== 'inactive'
|
|
4418
|
+
);
|
|
4419
|
+
|
|
4420
|
+
return hasAnalysis || hasUserComments;
|
|
4421
|
+
} catch {
|
|
4422
|
+
// Uncertain — fail safe
|
|
4423
|
+
return true;
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
/**
|
|
4428
|
+
* Show the stale badge with an optional variant class.
|
|
4429
|
+
* @param {'stale'|'closed'|'merged'} type
|
|
4430
|
+
*/
|
|
4431
|
+
_showStaleBadge(type) {
|
|
4432
|
+
const badge = document.getElementById('stale-badge');
|
|
4433
|
+
if (!badge) return;
|
|
4434
|
+
|
|
4435
|
+
// Reset variant classes
|
|
4436
|
+
badge.classList.remove('pr-closed', 'pr-merged');
|
|
4437
|
+
|
|
4438
|
+
const textEl = badge.querySelector('.stale-badge-text');
|
|
4439
|
+
if (type === 'merged') {
|
|
4440
|
+
badge.classList.add('pr-merged');
|
|
4441
|
+
if (textEl) textEl.textContent = 'MERGED';
|
|
4442
|
+
badge.title = 'This PR has been merged';
|
|
4443
|
+
} else if (type === 'closed') {
|
|
4444
|
+
badge.classList.add('pr-closed');
|
|
4445
|
+
if (textEl) textEl.textContent = 'CLOSED';
|
|
4446
|
+
badge.title = 'This PR has been closed';
|
|
4447
|
+
} else {
|
|
4448
|
+
if (textEl) textEl.textContent = 'STALE';
|
|
4449
|
+
badge.title = 'PR data is outdated';
|
|
4450
|
+
}
|
|
4451
|
+
badge.style.display = '';
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
/**
|
|
4455
|
+
* Hide the stale badge.
|
|
4456
|
+
*/
|
|
4457
|
+
_hideStaleBadge() {
|
|
4458
|
+
const badge = document.getElementById('stale-badge');
|
|
4459
|
+
if (badge) badge.style.display = 'none';
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4333
4462
|
/**
|
|
4334
4463
|
* Refresh the PR data
|
|
4335
4464
|
*/
|
|
@@ -4397,6 +4526,8 @@ class PRManager {
|
|
|
4397
4526
|
window.scrollTo(0, scrollPosition);
|
|
4398
4527
|
}, 100);
|
|
4399
4528
|
|
|
4529
|
+
this._hideStaleBadge();
|
|
4530
|
+
this._stalenessPromise = null;
|
|
4400
4531
|
console.log('PR refreshed successfully');
|
|
4401
4532
|
}
|
|
4402
4533
|
} catch (error) {
|
package/public/pr.html
CHANGED
|
@@ -17,9 +17,16 @@
|
|
|
17
17
|
state = anyAvailable ? 'available' : 'unavailable';
|
|
18
18
|
}
|
|
19
19
|
window.__pairReview = window.__pairReview || {};
|
|
20
|
+
window.__pairReview.toGraphiteUrl = function(githubUrl) {
|
|
21
|
+
return githubUrl.replace(
|
|
22
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
|
|
23
|
+
'https://app.graphite.com/github/pr/$1/$2/$3'
|
|
24
|
+
);
|
|
25
|
+
};
|
|
20
26
|
window.__pairReview.chatProvider = config.chat_provider || 'pi';
|
|
21
27
|
window.__pairReview.chatProviders = chatProviders;
|
|
22
28
|
window.__pairReview.share = config.share || null;
|
|
29
|
+
window.__pairReview.enableGraphite = config.enable_graphite === true;
|
|
23
30
|
document.documentElement.setAttribute('data-chat', state);
|
|
24
31
|
const shortcutsState = config.chat_enable_shortcuts === false ? 'disabled' : 'enabled';
|
|
25
32
|
document.documentElement.setAttribute('data-chat-shortcuts', shortcutsState);
|
|
@@ -91,6 +98,9 @@
|
|
|
91
98
|
<span class="breadcrumb-repo">--</span>
|
|
92
99
|
<span class="breadcrumb-pr">#--</span>
|
|
93
100
|
</div>
|
|
101
|
+
<span id="stale-badge" class="stale-badge" style="display: none" title="New version available">
|
|
102
|
+
<span class="stale-badge-text">STALE</span>
|
|
103
|
+
</span>
|
|
94
104
|
</div>
|
|
95
105
|
<div class="header-center">
|
|
96
106
|
<div class="pr-title-wrapper">
|
|
@@ -131,6 +141,12 @@
|
|
|
131
141
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/>
|
|
132
142
|
</svg>
|
|
133
143
|
</a>
|
|
144
|
+
<a class="btn btn-icon" id="graphite-link" href="#" target="_blank" title="View on Graphite" aria-label="View on Graphite" style="display: none;">
|
|
145
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16">
|
|
146
|
+
<path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/>
|
|
147
|
+
<polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/>
|
|
148
|
+
</svg>
|
|
149
|
+
</a>
|
|
134
150
|
</div>
|
|
135
151
|
<div id="split-button-placeholder"></div>
|
|
136
152
|
</div>
|
package/src/ai/analyzer.js
CHANGED
|
@@ -659,7 +659,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
|
|
|
659
659
|
async getChangedFilesList(worktreePath, prMetadata) {
|
|
660
660
|
try {
|
|
661
661
|
const { stdout } = await execPromise(
|
|
662
|
-
`git diff ${prMetadata.base_sha}...${prMetadata.head_sha} --name-only`,
|
|
662
|
+
`git diff --no-ext-diff ${prMetadata.base_sha}...${prMetadata.head_sha} --name-only`,
|
|
663
663
|
{ cwd: worktreePath }
|
|
664
664
|
);
|
|
665
665
|
return stdout.trim().split('\n').filter(f => f.length > 0);
|
|
@@ -685,7 +685,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
|
|
|
685
685
|
try {
|
|
686
686
|
// Get modified tracked files (unstaged only - staged files are excluded by design)
|
|
687
687
|
const { stdout: unstaged } = await execPromise(
|
|
688
|
-
'git diff --name-only',
|
|
688
|
+
'git diff --no-ext-diff --name-only',
|
|
689
689
|
{ cwd: localPath }
|
|
690
690
|
);
|
|
691
691
|
|
|
@@ -777,7 +777,7 @@ The following files are marked as generated in .gitattributes and should be SKIP
|
|
|
777
777
|
${generatedPatterns.map(p => `- ${p}`).join('\n')}
|
|
778
778
|
|
|
779
779
|
These are auto-generated files (like package-lock.json, build outputs, etc.) that should not be reviewed.
|
|
780
|
-
When running git diff, you can exclude these with: git diff ${'{base}'}...${'{head}'} -- ':!pattern' for each pattern.
|
|
780
|
+
When running git diff, you can exclude these with: git diff --no-ext-diff ${'{base}'}...${'{head}'} -- ':!pattern' for each pattern.
|
|
781
781
|
Or simply ignore any changes to files matching these patterns in your analysis.
|
|
782
782
|
`;
|
|
783
783
|
}
|
|
@@ -983,10 +983,10 @@ ${prMetadata.description || '(No description provided)'}
|
|
|
983
983
|
const isLocal = prMetadata.reviewType === 'local';
|
|
984
984
|
if (isLocal) {
|
|
985
985
|
// For local mode, diff against HEAD to see working directory changes
|
|
986
|
-
return suffix ? `git diff HEAD ${suffix}` : 'git diff HEAD';
|
|
986
|
+
return suffix ? `git diff --no-ext-diff HEAD ${suffix}` : 'git diff --no-ext-diff HEAD';
|
|
987
987
|
}
|
|
988
988
|
// For PR mode, diff between base and head commits
|
|
989
|
-
const baseCmd = `git diff ${prMetadata.base_sha}...${prMetadata.head_sha}`;
|
|
989
|
+
const baseCmd = `git diff --no-ext-diff ${prMetadata.base_sha}...${prMetadata.head_sha}`;
|
|
990
990
|
return suffix ? `${baseCmd} ${suffix}` : baseCmd;
|
|
991
991
|
}
|
|
992
992
|
|
|
@@ -30,9 +30,9 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
30
30
|
*
|
|
31
31
|
* Tier structure:
|
|
32
32
|
* - free (auto): Cursor's default auto-routing model
|
|
33
|
-
* - fast (composer-
|
|
33
|
+
* - fast (composer-2-fast, gpt-5.3-codex-fast, gemini-3-flash): Quick analysis
|
|
34
34
|
* - balanced (composer-1.5, sonnet-4.6-thinking, sonnet-4.5-thinking, gemini-3.1-pro): Recommended for most reviews
|
|
35
|
-
* - thorough (gpt-5.3-codex-high, gpt-5.3-codex-xhigh, opus-4.5-thinking, opus-4.6-thinking): Deep analysis for complex code
|
|
35
|
+
* - thorough (composer-2, gpt-5.3-codex-high, gpt-5.3-codex-xhigh, opus-4.5-thinking, opus-4.6-thinking): Deep analysis for complex code
|
|
36
36
|
*/
|
|
37
37
|
const CURSOR_AGENT_MODELS = [
|
|
38
38
|
{
|
|
@@ -45,23 +45,32 @@ const CURSOR_AGENT_MODELS = [
|
|
|
45
45
|
badgeClass: 'badge-speed'
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
|
-
id: 'composer-
|
|
49
|
-
name: 'Composer
|
|
50
|
-
tier: '
|
|
48
|
+
id: 'composer-2',
|
|
49
|
+
name: 'Composer 2',
|
|
50
|
+
tier: 'thorough',
|
|
51
51
|
tagline: 'Latest Composer',
|
|
52
|
-
description: '
|
|
53
|
-
badge: '
|
|
54
|
-
badgeClass: 'badge-
|
|
52
|
+
description: 'Frontier-level coding model trained with compaction-in-the-loop RL—strong on long-horizon tasks requiring hundreds of actions',
|
|
53
|
+
badge: 'Latest',
|
|
54
|
+
badgeClass: 'badge-power'
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
|
-
id: 'composer-
|
|
58
|
-
name: 'Composer
|
|
57
|
+
id: 'composer-2-fast',
|
|
58
|
+
name: 'Composer 2 Fast',
|
|
59
59
|
tier: 'fast',
|
|
60
|
-
tagline: '
|
|
61
|
-
description: '
|
|
60
|
+
tagline: 'Fast Composer',
|
|
61
|
+
description: 'Same intelligence as Composer 2 with lower latency—default for interactive Cursor sessions',
|
|
62
62
|
badge: 'Fast',
|
|
63
63
|
badgeClass: 'badge-speed'
|
|
64
64
|
},
|
|
65
|
+
{
|
|
66
|
+
id: 'composer-1.5',
|
|
67
|
+
name: 'Composer 1.5',
|
|
68
|
+
tier: 'balanced',
|
|
69
|
+
tagline: 'Previous Composer',
|
|
70
|
+
description: 'Previous generation Cursor Composer model—positioned between Sonnet and Opus for multi-file edits',
|
|
71
|
+
badge: 'Previous Gen',
|
|
72
|
+
badgeClass: 'badge-balanced'
|
|
73
|
+
},
|
|
65
74
|
{
|
|
66
75
|
id: 'gpt-5.3-codex-fast',
|
|
67
76
|
name: 'GPT-5.3 Codex Fast',
|
|
@@ -124,8 +124,8 @@ function buildReviewContext(review, prData) {
|
|
|
124
124
|
lines.push(`This is a local code review for: ${name}`);
|
|
125
125
|
lines.push('');
|
|
126
126
|
lines.push('## Viewing Code Changes');
|
|
127
|
-
lines.push('The changes under review are **unstaged and untracked local changes**. Staged changes (`git diff --cached`) are treated as already reviewed.');
|
|
128
|
-
lines.push('To see the diff under review: `git diff`');
|
|
127
|
+
lines.push('The changes under review are **unstaged and untracked local changes**. Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed.');
|
|
128
|
+
lines.push('To see the diff under review: `git diff --no-ext-diff`');
|
|
129
129
|
lines.push('Do NOT use `git diff HEAD~1` or `git log` — those show committed history, not the changes under review.');
|
|
130
130
|
} else {
|
|
131
131
|
const parts = [];
|
|
@@ -146,7 +146,7 @@ function buildReviewContext(review, prData) {
|
|
|
146
146
|
lines.push('');
|
|
147
147
|
lines.push('## Viewing Code Changes');
|
|
148
148
|
lines.push(`The changes under review are the diff between base commit \`${prData.base_sha.substring(0, 8)}\` and head commit \`${prData.head_sha.substring(0, 8)}\`.`);
|
|
149
|
-
lines.push(`To see the full diff: \`git diff ${prData.base_sha}...${prData.head_sha}\``);
|
|
149
|
+
lines.push(`To see the full diff: \`git diff --no-ext-diff ${prData.base_sha}...${prData.head_sha}\``);
|
|
150
150
|
lines.push('Do NOT use `git diff HEAD~1` or `git diff` without arguments — those do not show the PR changes.');
|
|
151
151
|
}
|
|
152
152
|
}
|
package/src/config.js
CHANGED
|
@@ -34,7 +34,8 @@ const DEFAULT_CONFIG = {
|
|
|
34
34
|
providers: {}, // Custom AI analysis provider configurations (overrides built-in defaults)
|
|
35
35
|
chat_providers: {}, // Custom chat provider configurations (overrides built-in defaults)
|
|
36
36
|
monorepos: {}, // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
|
|
37
|
-
assisted_by_url: "https://github.com/in-the-loop-labs/pair-review" // URL for "Review assisted by" footer link
|
|
37
|
+
assisted_by_url: "https://github.com/in-the-loop-labs/pair-review", // URL for "Review assisted by" footer link
|
|
38
|
+
enable_graphite: false // When true, shows Graphite links alongside GitHub links
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
/**
|
package/src/git/worktree.js
CHANGED
|
@@ -536,7 +536,8 @@ class GitWorktreeManager {
|
|
|
536
536
|
// This ensures we compare the exact commits from the PR, even if the base branch has moved
|
|
537
537
|
const diff = await git.diff([
|
|
538
538
|
`${prData.base_sha}...${prData.head_sha}`,
|
|
539
|
-
'--unified=3'
|
|
539
|
+
'--unified=3',
|
|
540
|
+
'--no-ext-diff'
|
|
540
541
|
]);
|
|
541
542
|
|
|
542
543
|
return diff;
|
|
@@ -559,7 +560,7 @@ class GitWorktreeManager {
|
|
|
559
560
|
|
|
560
561
|
// Get file changes with stats using base SHA and head SHA
|
|
561
562
|
// This ensures we get the exact files changed in the PR, even if the base branch has moved
|
|
562
|
-
const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}
|
|
563
|
+
const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`, '--no-ext-diff']);
|
|
563
564
|
|
|
564
565
|
// Parse .gitattributes to identify generated files
|
|
565
566
|
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
@@ -626,6 +627,29 @@ class GitWorktreeManager {
|
|
|
626
627
|
}
|
|
627
628
|
}
|
|
628
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Resolve the owning git repository for a worktree path.
|
|
632
|
+
* Uses `git rev-parse --git-common-dir` to find the main repo,
|
|
633
|
+
* which works even when worktrees are outside the parent repo directory.
|
|
634
|
+
* @param {string} worktreePath - Path to a worktree
|
|
635
|
+
* @returns {Promise<import('simple-git').SimpleGit|null>} simpleGit instance for the owning repo, or null
|
|
636
|
+
*/
|
|
637
|
+
async resolveOwningRepo(worktreePath) {
|
|
638
|
+
try {
|
|
639
|
+
const git = simpleGit(worktreePath);
|
|
640
|
+
const commonDir = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
|
|
641
|
+
// commonDir is either a .git subdirectory (regular repos) or the bare repo root itself.
|
|
642
|
+
// Only strip the last component when it's actually a .git directory.
|
|
643
|
+
const resolvedCommonDir = path.resolve(worktreePath, commonDir);
|
|
644
|
+
const repoRoot = path.basename(resolvedCommonDir) === '.git'
|
|
645
|
+
? path.dirname(resolvedCommonDir)
|
|
646
|
+
: resolvedCommonDir;
|
|
647
|
+
return simpleGit(repoRoot);
|
|
648
|
+
} catch {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
629
653
|
/**
|
|
630
654
|
* Cleanup a specific worktree
|
|
631
655
|
* @param {string} worktreePath - Path to worktree to cleanup
|
|
@@ -634,27 +658,30 @@ class GitWorktreeManager {
|
|
|
634
658
|
async cleanupWorktree(worktreePath) {
|
|
635
659
|
try {
|
|
636
660
|
// First try to prune any stale worktree registrations
|
|
637
|
-
await this.pruneWorktrees();
|
|
638
|
-
|
|
661
|
+
await this.pruneWorktrees(worktreePath);
|
|
662
|
+
|
|
639
663
|
// Check if worktree exists
|
|
640
664
|
const exists = await this.pathExists(worktreePath);
|
|
641
|
-
|
|
642
|
-
// Try to remove via git worktree remove first (handles both directory and registration)
|
|
643
|
-
try {
|
|
644
|
-
const parentGit = simpleGit(path.dirname(worktreePath));
|
|
645
|
-
await parentGit.raw(['worktree', 'remove', '--force', worktreePath]);
|
|
646
|
-
console.log(`Removed worktree via git: ${worktreePath}`);
|
|
647
|
-
return;
|
|
648
|
-
} catch (gitError) {
|
|
649
|
-
console.log('Git worktree remove failed, trying manual cleanup...');
|
|
650
|
-
}
|
|
651
665
|
|
|
652
|
-
// If directory exists, remove it manually
|
|
653
666
|
if (exists) {
|
|
667
|
+
// Try to remove via git worktree remove first (handles both directory and registration)
|
|
668
|
+
try {
|
|
669
|
+
const owningRepo = await this.resolveOwningRepo(worktreePath);
|
|
670
|
+
if (!owningRepo) {
|
|
671
|
+
throw new Error('Could not resolve owning repository');
|
|
672
|
+
}
|
|
673
|
+
await owningRepo.raw(['worktree', 'remove', '--force', worktreePath]);
|
|
674
|
+
console.log(`Removed worktree via git: ${worktreePath}`);
|
|
675
|
+
return;
|
|
676
|
+
} catch (gitError) {
|
|
677
|
+
console.log('Git worktree remove failed, trying manual cleanup...');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// git remove failed — remove directory manually
|
|
654
681
|
await this.removeDirectory(worktreePath);
|
|
655
682
|
console.log(`Removed worktree directory: ${worktreePath}`);
|
|
656
683
|
}
|
|
657
|
-
|
|
684
|
+
|
|
658
685
|
} catch (error) {
|
|
659
686
|
console.warn(`Warning: Could not cleanup worktree at ${worktreePath}: ${error.message}`);
|
|
660
687
|
// Don't throw - this is cleanup, continue with creation
|
|
@@ -777,14 +804,22 @@ class GitWorktreeManager {
|
|
|
777
804
|
}
|
|
778
805
|
|
|
779
806
|
/**
|
|
780
|
-
* Prune stale worktree registrations
|
|
807
|
+
* Prune stale worktree registrations from the owning repository.
|
|
808
|
+
* @param {string|import('simple-git').SimpleGit} [worktreePathOrGit] - A worktree path to
|
|
809
|
+
* resolve the owning repo from, or a SimpleGit instance directly. Falls back to process.cwd().
|
|
781
810
|
* @returns {Promise<void>}
|
|
782
811
|
*/
|
|
783
|
-
async pruneWorktrees() {
|
|
812
|
+
async pruneWorktrees(worktreePathOrGit) {
|
|
784
813
|
try {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
814
|
+
let git;
|
|
815
|
+
if (worktreePathOrGit && typeof worktreePathOrGit === 'object') {
|
|
816
|
+
git = worktreePathOrGit;
|
|
817
|
+
} else if (typeof worktreePathOrGit === 'string') {
|
|
818
|
+
git = await this.resolveOwningRepo(worktreePathOrGit);
|
|
819
|
+
}
|
|
820
|
+
if (!git) {
|
|
821
|
+
git = simpleGit(process.cwd());
|
|
822
|
+
}
|
|
788
823
|
await git.raw(['worktree', 'prune']);
|
|
789
824
|
console.log('Pruned stale worktree registrations');
|
|
790
825
|
} catch (error) {
|
|
@@ -892,12 +927,31 @@ class GitWorktreeManager {
|
|
|
892
927
|
|
|
893
928
|
console.log(`[pair-review] Found ${staleWorktrees.length} stale worktrees older than ${retentionDays} days`);
|
|
894
929
|
|
|
930
|
+
// Resolve owning repos BEFORE cleanup removes directories
|
|
931
|
+
const owningRepos = new Map();
|
|
932
|
+
for (const worktree of staleWorktrees) {
|
|
933
|
+
try {
|
|
934
|
+
const repo = await this.resolveOwningRepo(worktree.path);
|
|
935
|
+
if (repo) {
|
|
936
|
+
const repoPath = (await repo.raw(['rev-parse', '--git-dir'])).trim();
|
|
937
|
+
if (!owningRepos.has(repoPath)) {
|
|
938
|
+
owningRepos.set(repoPath, repo);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
} catch {
|
|
942
|
+
// ignore - will fall back to manual removal
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
895
946
|
for (const worktree of staleWorktrees) {
|
|
896
947
|
try {
|
|
897
948
|
// Try to remove via git worktree remove first
|
|
898
949
|
try {
|
|
899
|
-
const
|
|
900
|
-
|
|
950
|
+
const owningRepo = await this.resolveOwningRepo(worktree.path);
|
|
951
|
+
if (!owningRepo) {
|
|
952
|
+
throw new Error('Could not resolve owning repository');
|
|
953
|
+
}
|
|
954
|
+
await owningRepo.raw(['worktree', 'remove', '--force', worktree.path]);
|
|
901
955
|
console.log(`[pair-review] Removed worktree via git: ${worktree.path}`);
|
|
902
956
|
} catch (gitError) {
|
|
903
957
|
// If git worktree remove fails, try manual directory removal
|
|
@@ -923,8 +977,10 @@ class GitWorktreeManager {
|
|
|
923
977
|
}
|
|
924
978
|
}
|
|
925
979
|
|
|
926
|
-
//
|
|
927
|
-
|
|
980
|
+
// Prune each unique owning repo
|
|
981
|
+
for (const repo of owningRepos.values()) {
|
|
982
|
+
await this.pruneWorktrees(repo);
|
|
983
|
+
}
|
|
928
984
|
|
|
929
985
|
if (result.cleaned > 0) {
|
|
930
986
|
console.log(`[pair-review] Cleaned up ${result.cleaned} stale worktrees (older than ${retentionDays} days)`);
|
package/src/local-review.js
CHANGED
|
@@ -658,7 +658,7 @@ async function computeLocalDiffDigest(localPath) {
|
|
|
658
658
|
// Get unstaged diff (the actual content being reviewed)
|
|
659
659
|
let unstagedDiff = '';
|
|
660
660
|
try {
|
|
661
|
-
const result = await execAsync('git diff', {
|
|
661
|
+
const result = await execAsync('git diff --no-ext-diff', {
|
|
662
662
|
cwd: localPath,
|
|
663
663
|
encoding: 'utf8',
|
|
664
664
|
maxBuffer: 50 * 1024 * 1024
|
package/src/main.js
CHANGED
|
@@ -662,10 +662,10 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
662
662
|
}
|
|
663
663
|
}
|
|
664
664
|
|
|
665
|
-
diff = await git.diff([`${prData.base_sha}...${prData.head_sha}`, '--unified=3']);
|
|
665
|
+
diff = await git.diff([`${prData.base_sha}...${prData.head_sha}`, '--unified=3', '--no-ext-diff']);
|
|
666
666
|
|
|
667
667
|
// Get changed files
|
|
668
|
-
const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}
|
|
668
|
+
const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`, '--no-ext-diff']);
|
|
669
669
|
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
670
670
|
|
|
671
671
|
changedFiles = diffSummary.files.map(file => {
|
package/src/routes/config.js
CHANGED
|
@@ -55,6 +55,7 @@ router.get('/api/config', (req, res) => {
|
|
|
55
55
|
chat_enable_shortcuts: config.chat?.enable_shortcuts !== false,
|
|
56
56
|
pi_available: getCachedAvailability('pi')?.available || false,
|
|
57
57
|
assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review',
|
|
58
|
+
enable_graphite: config.enable_graphite === true,
|
|
58
59
|
// Share configuration for external review viewers.
|
|
59
60
|
// - url: The base URL of the external share site
|
|
60
61
|
// - method: Plumbed through for future use (e.g., POST-based share flows).
|
|
@@ -45,7 +45,7 @@ router.post('/api/github/review-requests/refresh', async (req, res) => {
|
|
|
45
45
|
const db = req.app.get('db');
|
|
46
46
|
const client = new GitHubClient(githubToken);
|
|
47
47
|
const user = await client.getAuthenticatedUser();
|
|
48
|
-
const prs = await client.searchPullRequests(`is:pr is:open user-review-requested:${user.login}`);
|
|
48
|
+
const prs = await client.searchPullRequests(`is:pr is:open archived:false user-review-requested:${user.login}`);
|
|
49
49
|
|
|
50
50
|
await withTransaction(db, async () => {
|
|
51
51
|
await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['review-requests']);
|
|
@@ -99,7 +99,7 @@ router.post('/api/github/my-prs/refresh', async (req, res) => {
|
|
|
99
99
|
const db = req.app.get('db');
|
|
100
100
|
const client = new GitHubClient(githubToken);
|
|
101
101
|
const user = await client.getAuthenticatedUser();
|
|
102
|
-
const prs = await client.searchPullRequests(`is:pr is:open author:${user.login}`);
|
|
102
|
+
const prs = await client.searchPullRequests(`is:pr is:open archived:false author:${user.login}`);
|
|
103
103
|
|
|
104
104
|
await withTransaction(db, async () => {
|
|
105
105
|
await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['my-prs']);
|
package/src/routes/pr.js
CHANGED
|
@@ -682,10 +682,10 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
682
682
|
|
|
683
683
|
if (baseSha && headSha) {
|
|
684
684
|
// Regenerate diff with -w flag to ignore whitespace changes
|
|
685
|
-
diffContent = await git.diff([`${baseSha}...${headSha}`, '--unified=3', '-w']);
|
|
685
|
+
diffContent = await git.diff([`${baseSha}...${headSha}`, '--unified=3', '-w', '--no-ext-diff']);
|
|
686
686
|
|
|
687
687
|
// Regenerate changed files stats with -w flag
|
|
688
|
-
const diffSummary = await git.diffSummary([`${baseSha}...${headSha}`, '-w']);
|
|
688
|
+
const diffSummary = await git.diffSummary([`${baseSha}...${headSha}`, '-w', '--no-ext-diff']);
|
|
689
689
|
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
690
690
|
changedFiles = diffSummary.files.map(file => {
|
|
691
691
|
const resolvedFile = resolveRenamedFile(file.file);
|
package/src/routes/worktrees.js
CHANGED
|
@@ -156,7 +156,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
|
|
|
156
156
|
w.created_at,
|
|
157
157
|
pm.title as pr_title,
|
|
158
158
|
pm.author,
|
|
159
|
-
pm.head_branch
|
|
159
|
+
pm.head_branch,
|
|
160
|
+
json_extract(pm.pr_data, '$.html_url') as html_url
|
|
160
161
|
FROM worktrees w
|
|
161
162
|
LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
|
|
162
163
|
WHERE w.last_accessed_at < ?
|
|
@@ -176,7 +177,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
|
|
|
176
177
|
w.created_at,
|
|
177
178
|
pm.title as pr_title,
|
|
178
179
|
pm.author,
|
|
179
|
-
pm.head_branch
|
|
180
|
+
pm.head_branch,
|
|
181
|
+
json_extract(pm.pr_data, '$.html_url') as html_url
|
|
180
182
|
FROM worktrees w
|
|
181
183
|
LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
|
|
182
184
|
ORDER BY w.last_accessed_at DESC
|
|
@@ -235,7 +237,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
|
|
|
235
237
|
branch: w.branch,
|
|
236
238
|
head_branch: w.head_branch || w.branch,
|
|
237
239
|
last_accessed_at: w.last_accessed_at,
|
|
238
|
-
created_at: w.created_at
|
|
240
|
+
created_at: w.created_at,
|
|
241
|
+
html_url: w.html_url || null
|
|
239
242
|
}));
|
|
240
243
|
|
|
241
244
|
res.json({
|
|
@@ -37,7 +37,7 @@ async function getDiffFileList(db, review) {
|
|
|
37
37
|
try {
|
|
38
38
|
const opts = { cwd: review.local_path };
|
|
39
39
|
const [{ stdout: unstaged }, { stdout: untracked }] = await Promise.all([
|
|
40
|
-
execPromise('git diff --name-only', opts),
|
|
40
|
+
execPromise('git diff --no-ext-diff --name-only', opts),
|
|
41
41
|
execPromise('git ls-files --others --exclude-standard', opts),
|
|
42
42
|
]);
|
|
43
43
|
const combined = `${unstaged}\n${untracked}`
|