@in-the-loop-labs/pair-review 2.6.0 → 2.6.2
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 +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +71 -10
- package/public/index.html +16 -0
- package/public/js/index.js +28 -4
- package/public/js/local.js +6 -0
- package/public/js/pr.js +143 -12
- package/public/pr.html +16 -0
- package/public/setup.html +19 -0
- package/src/config.js +8 -3
- package/src/git/worktree.js +78 -23
- package/src/routes/config.js +3 -0
- package/src/routes/worktrees.js +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
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.2",
|
|
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
|
@@ -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 {
|
|
@@ -6078,15 +6123,25 @@ body::before {
|
|
|
6078
6123
|
justify-content: center;
|
|
6079
6124
|
}
|
|
6080
6125
|
|
|
6081
|
-
/* Icon buttons within the icon group have special styling (no border, transparent bg)
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6126
|
+
/* Icon buttons within the icon group have special styling (no border, transparent bg).
|
|
6127
|
+
Uses high-specificity selector to override the generic .btn-icon rule (council selector)
|
|
6128
|
+
and the [data-theme="dark"] .btn-icon dark-mode override.
|
|
6129
|
+
NOTE: ID-based selectors (#theme-toggle, #refresh-pr) beat this rule regardless of
|
|
6130
|
+
how many classes are stacked, so those rules must also set border to transparent. */
|
|
6131
|
+
[data-theme] .header-icon-group .btn.btn-icon {
|
|
6132
|
+
border: 1px solid transparent;
|
|
6133
|
+
border-color: transparent;
|
|
6085
6134
|
background: transparent;
|
|
6135
|
+
color: var(--color-text-tertiary);
|
|
6136
|
+
width: 36px;
|
|
6137
|
+
height: 36px;
|
|
6138
|
+
box-sizing: border-box;
|
|
6086
6139
|
}
|
|
6087
6140
|
|
|
6088
|
-
.header-icon-group .btn-icon:hover {
|
|
6141
|
+
[data-theme] .header-icon-group .btn.btn-icon:hover {
|
|
6089
6142
|
background: var(--color-bg-tertiary);
|
|
6143
|
+
border-color: var(--color-border-primary);
|
|
6144
|
+
color: var(--color-text-primary);
|
|
6090
6145
|
}
|
|
6091
6146
|
|
|
6092
6147
|
.header .btn-secondary {
|
|
@@ -6111,27 +6166,33 @@ body::before {
|
|
|
6111
6166
|
box-shadow: 0 0 12px var(--ai-glow);
|
|
6112
6167
|
}
|
|
6113
6168
|
|
|
6114
|
-
/* Theme toggle in header
|
|
6169
|
+
/* Theme toggle in header — border is transparent at rest, visible on hover.
|
|
6170
|
+
Must set border-color explicitly here because this ID selector beats the
|
|
6171
|
+
class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
|
|
6115
6172
|
.header #theme-toggle {
|
|
6116
6173
|
background: transparent;
|
|
6117
|
-
border: 1px solid
|
|
6174
|
+
border: 1px solid transparent;
|
|
6118
6175
|
color: var(--color-text-secondary);
|
|
6119
6176
|
}
|
|
6120
6177
|
|
|
6121
6178
|
.header #theme-toggle:hover {
|
|
6122
6179
|
background: var(--color-bg-secondary);
|
|
6180
|
+
border-color: var(--color-border-primary);
|
|
6123
6181
|
color: var(--color-text-primary);
|
|
6124
6182
|
}
|
|
6125
6183
|
|
|
6126
|
-
/* Refresh button in header
|
|
6184
|
+
/* Refresh button in header — border is transparent at rest, visible on hover.
|
|
6185
|
+
Must set border-color explicitly here because this ID selector beats the
|
|
6186
|
+
class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
|
|
6127
6187
|
.header #refresh-pr {
|
|
6128
6188
|
background: transparent;
|
|
6129
|
-
border: 1px solid
|
|
6189
|
+
border: 1px solid transparent;
|
|
6130
6190
|
color: var(--color-text-secondary);
|
|
6131
6191
|
}
|
|
6132
6192
|
|
|
6133
6193
|
.header #refresh-pr:hover {
|
|
6134
6194
|
background: var(--color-bg-secondary);
|
|
6195
|
+
border-color: var(--color-border-primary);
|
|
6135
6196
|
color: var(--color-text-primary);
|
|
6136
6197
|
}
|
|
6137
6198
|
|
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">
|
|
@@ -177,6 +184,14 @@
|
|
|
177
184
|
color: var(--color-text-primary);
|
|
178
185
|
}
|
|
179
186
|
|
|
187
|
+
.app-version {
|
|
188
|
+
color: var(--color-fg-muted, #656d76);
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
font-weight: 400;
|
|
191
|
+
margin-left: 6px;
|
|
192
|
+
opacity: 0.7;
|
|
193
|
+
}
|
|
194
|
+
|
|
180
195
|
.header-right {
|
|
181
196
|
display: flex;
|
|
182
197
|
align-items: center;
|
|
@@ -1089,6 +1104,7 @@
|
|
|
1089
1104
|
<path transform="rotate(-50 12 12)" d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.356-8-5.096 0-5.096 8 0 8 5.223 0 7.26-8 12.356-8z"/>
|
|
1090
1105
|
</svg>
|
|
1091
1106
|
<span class="logo-text">pair<span class="logo-accent">review</span></span>
|
|
1107
|
+
<span id="app-version" class="app-version"></span>
|
|
1092
1108
|
</a>
|
|
1093
1109
|
</div>
|
|
1094
1110
|
<div class="header-right">
|
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"/>' +
|
|
@@ -1092,11 +1107,18 @@
|
|
|
1092
1107
|
const config = await response.json();
|
|
1093
1108
|
updateCommandExamples(config.is_running_via_npx);
|
|
1094
1109
|
|
|
1110
|
+
// Display version in header
|
|
1111
|
+
if (config.version) {
|
|
1112
|
+
const versionEl = document.getElementById('app-version');
|
|
1113
|
+
if (versionEl) versionEl.textContent = 'v' + config.version;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1095
1116
|
// Expose chat provider config to components (ChatPanel reads these)
|
|
1096
1117
|
window.__pairReview = window.__pairReview || {};
|
|
1097
1118
|
window.__pairReview.chatProvider = config.chat_provider || 'pi';
|
|
1098
1119
|
const chatProviders = config.chat_providers || [];
|
|
1099
1120
|
window.__pairReview.chatProviders = chatProviders;
|
|
1121
|
+
window.__pairReview.enableGraphite = config.enable_graphite === true;
|
|
1100
1122
|
|
|
1101
1123
|
// Set chat feature state based on config and provider availability
|
|
1102
1124
|
let chatState = 'disabled';
|
|
@@ -1226,9 +1248,11 @@
|
|
|
1226
1248
|
|
|
1227
1249
|
// ─── DOMContentLoaded Initialization ────────────────────────────────────────
|
|
1228
1250
|
|
|
1229
|
-
document.addEventListener('DOMContentLoaded', function () {
|
|
1230
|
-
// Load config and update command examples based on npx detection
|
|
1231
|
-
|
|
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();
|
|
1232
1256
|
|
|
1233
1257
|
// Restore saved tab from localStorage (default: 'pr-tab')
|
|
1234
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/public/setup.html
CHANGED
|
@@ -137,6 +137,14 @@
|
|
|
137
137
|
color: var(--color-text-primary);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
.app-version {
|
|
141
|
+
color: var(--color-fg-muted, #656d76);
|
|
142
|
+
font-size: 12px;
|
|
143
|
+
font-weight: 400;
|
|
144
|
+
margin-left: 6px;
|
|
145
|
+
opacity: 0.7;
|
|
146
|
+
}
|
|
147
|
+
|
|
140
148
|
.setup-target {
|
|
141
149
|
font-family: var(--font-mono);
|
|
142
150
|
font-size: 15px;
|
|
@@ -463,6 +471,7 @@
|
|
|
463
471
|
<path transform="rotate(-50 12 12)" d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.356-8-5.096 0-5.096 8 0 8 5.223 0 7.26-8 12.356-8z"/>
|
|
464
472
|
</svg>
|
|
465
473
|
<span class="logo-text">pair<span class="logo-accent">review</span></span>
|
|
474
|
+
<span id="app-version" class="app-version"></span>
|
|
466
475
|
</a>
|
|
467
476
|
<div class="setup-target" id="setup-target"></div>
|
|
468
477
|
<div class="setup-subtitle">Setting up review</div>
|
|
@@ -839,6 +848,16 @@
|
|
|
839
848
|
});
|
|
840
849
|
}
|
|
841
850
|
|
|
851
|
+
/* ── Display Version ── */
|
|
852
|
+
fetch('/api/config').then(function(r) {
|
|
853
|
+
return r.ok ? r.json() : null;
|
|
854
|
+
}).then(function(config) {
|
|
855
|
+
if (config && config.version) {
|
|
856
|
+
var versionEl = document.getElementById('app-version');
|
|
857
|
+
if (versionEl) versionEl.textContent = 'v' + config.version;
|
|
858
|
+
}
|
|
859
|
+
}).catch(function() { /* non-critical */ });
|
|
860
|
+
|
|
842
861
|
/* ── Kick Off ── */
|
|
843
862
|
startSetup();
|
|
844
863
|
})();
|
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
|
/**
|
|
@@ -185,22 +186,26 @@ async function loadConfig() {
|
|
|
185
186
|
|
|
186
187
|
let mergedConfig = { ...DEFAULT_CONFIG };
|
|
187
188
|
let isFirstRun = false;
|
|
189
|
+
let hasManagedConfig = false;
|
|
188
190
|
|
|
189
191
|
for (const source of sources) {
|
|
190
192
|
try {
|
|
191
193
|
const data = await fs.readFile(source.path, 'utf8');
|
|
192
194
|
const parsed = JSON.parse(data);
|
|
195
|
+
if (source.label === 'managed config' && Object.keys(parsed).length > 0) {
|
|
196
|
+
hasManagedConfig = true;
|
|
197
|
+
}
|
|
193
198
|
mergedConfig = deepMerge(mergedConfig, parsed);
|
|
194
199
|
} catch (error) {
|
|
195
200
|
if (error.code === 'ENOENT') {
|
|
196
|
-
if (source.required) {
|
|
201
|
+
if (source.required && !hasManagedConfig) {
|
|
197
202
|
// Global config doesn't exist — create it with defaults
|
|
198
203
|
const config = { ...DEFAULT_CONFIG };
|
|
199
204
|
await saveConfig(config);
|
|
200
205
|
logger.debug(`Created default config file: ${CONFIG_FILE}`);
|
|
201
206
|
isFirstRun = true;
|
|
202
207
|
}
|
|
203
|
-
// Optional files: skip silently
|
|
208
|
+
// Optional files or managed-config-present: skip silently
|
|
204
209
|
} else if (error instanceof SyntaxError) {
|
|
205
210
|
if (source.required) {
|
|
206
211
|
console.error(`Invalid configuration file at ~/.pair-review/config.json`);
|
package/src/git/worktree.js
CHANGED
|
@@ -626,6 +626,29 @@ class GitWorktreeManager {
|
|
|
626
626
|
}
|
|
627
627
|
}
|
|
628
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Resolve the owning git repository for a worktree path.
|
|
631
|
+
* Uses `git rev-parse --git-common-dir` to find the main repo,
|
|
632
|
+
* which works even when worktrees are outside the parent repo directory.
|
|
633
|
+
* @param {string} worktreePath - Path to a worktree
|
|
634
|
+
* @returns {Promise<import('simple-git').SimpleGit|null>} simpleGit instance for the owning repo, or null
|
|
635
|
+
*/
|
|
636
|
+
async resolveOwningRepo(worktreePath) {
|
|
637
|
+
try {
|
|
638
|
+
const git = simpleGit(worktreePath);
|
|
639
|
+
const commonDir = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
|
|
640
|
+
// commonDir is either a .git subdirectory (regular repos) or the bare repo root itself.
|
|
641
|
+
// Only strip the last component when it's actually a .git directory.
|
|
642
|
+
const resolvedCommonDir = path.resolve(worktreePath, commonDir);
|
|
643
|
+
const repoRoot = path.basename(resolvedCommonDir) === '.git'
|
|
644
|
+
? path.dirname(resolvedCommonDir)
|
|
645
|
+
: resolvedCommonDir;
|
|
646
|
+
return simpleGit(repoRoot);
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
629
652
|
/**
|
|
630
653
|
* Cleanup a specific worktree
|
|
631
654
|
* @param {string} worktreePath - Path to worktree to cleanup
|
|
@@ -634,27 +657,30 @@ class GitWorktreeManager {
|
|
|
634
657
|
async cleanupWorktree(worktreePath) {
|
|
635
658
|
try {
|
|
636
659
|
// First try to prune any stale worktree registrations
|
|
637
|
-
await this.pruneWorktrees();
|
|
638
|
-
|
|
660
|
+
await this.pruneWorktrees(worktreePath);
|
|
661
|
+
|
|
639
662
|
// Check if worktree exists
|
|
640
663
|
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
664
|
|
|
652
|
-
// If directory exists, remove it manually
|
|
653
665
|
if (exists) {
|
|
666
|
+
// Try to remove via git worktree remove first (handles both directory and registration)
|
|
667
|
+
try {
|
|
668
|
+
const owningRepo = await this.resolveOwningRepo(worktreePath);
|
|
669
|
+
if (!owningRepo) {
|
|
670
|
+
throw new Error('Could not resolve owning repository');
|
|
671
|
+
}
|
|
672
|
+
await owningRepo.raw(['worktree', 'remove', '--force', worktreePath]);
|
|
673
|
+
console.log(`Removed worktree via git: ${worktreePath}`);
|
|
674
|
+
return;
|
|
675
|
+
} catch (gitError) {
|
|
676
|
+
console.log('Git worktree remove failed, trying manual cleanup...');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// git remove failed — remove directory manually
|
|
654
680
|
await this.removeDirectory(worktreePath);
|
|
655
681
|
console.log(`Removed worktree directory: ${worktreePath}`);
|
|
656
682
|
}
|
|
657
|
-
|
|
683
|
+
|
|
658
684
|
} catch (error) {
|
|
659
685
|
console.warn(`Warning: Could not cleanup worktree at ${worktreePath}: ${error.message}`);
|
|
660
686
|
// Don't throw - this is cleanup, continue with creation
|
|
@@ -777,14 +803,22 @@ class GitWorktreeManager {
|
|
|
777
803
|
}
|
|
778
804
|
|
|
779
805
|
/**
|
|
780
|
-
* Prune stale worktree registrations
|
|
806
|
+
* Prune stale worktree registrations from the owning repository.
|
|
807
|
+
* @param {string|import('simple-git').SimpleGit} [worktreePathOrGit] - A worktree path to
|
|
808
|
+
* resolve the owning repo from, or a SimpleGit instance directly. Falls back to process.cwd().
|
|
781
809
|
* @returns {Promise<void>}
|
|
782
810
|
*/
|
|
783
|
-
async pruneWorktrees() {
|
|
811
|
+
async pruneWorktrees(worktreePathOrGit) {
|
|
784
812
|
try {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
813
|
+
let git;
|
|
814
|
+
if (worktreePathOrGit && typeof worktreePathOrGit === 'object') {
|
|
815
|
+
git = worktreePathOrGit;
|
|
816
|
+
} else if (typeof worktreePathOrGit === 'string') {
|
|
817
|
+
git = await this.resolveOwningRepo(worktreePathOrGit);
|
|
818
|
+
}
|
|
819
|
+
if (!git) {
|
|
820
|
+
git = simpleGit(process.cwd());
|
|
821
|
+
}
|
|
788
822
|
await git.raw(['worktree', 'prune']);
|
|
789
823
|
console.log('Pruned stale worktree registrations');
|
|
790
824
|
} catch (error) {
|
|
@@ -892,12 +926,31 @@ class GitWorktreeManager {
|
|
|
892
926
|
|
|
893
927
|
console.log(`[pair-review] Found ${staleWorktrees.length} stale worktrees older than ${retentionDays} days`);
|
|
894
928
|
|
|
929
|
+
// Resolve owning repos BEFORE cleanup removes directories
|
|
930
|
+
const owningRepos = new Map();
|
|
931
|
+
for (const worktree of staleWorktrees) {
|
|
932
|
+
try {
|
|
933
|
+
const repo = await this.resolveOwningRepo(worktree.path);
|
|
934
|
+
if (repo) {
|
|
935
|
+
const repoPath = (await repo.raw(['rev-parse', '--git-dir'])).trim();
|
|
936
|
+
if (!owningRepos.has(repoPath)) {
|
|
937
|
+
owningRepos.set(repoPath, repo);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
} catch {
|
|
941
|
+
// ignore - will fall back to manual removal
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
895
945
|
for (const worktree of staleWorktrees) {
|
|
896
946
|
try {
|
|
897
947
|
// Try to remove via git worktree remove first
|
|
898
948
|
try {
|
|
899
|
-
const
|
|
900
|
-
|
|
949
|
+
const owningRepo = await this.resolveOwningRepo(worktree.path);
|
|
950
|
+
if (!owningRepo) {
|
|
951
|
+
throw new Error('Could not resolve owning repository');
|
|
952
|
+
}
|
|
953
|
+
await owningRepo.raw(['worktree', 'remove', '--force', worktree.path]);
|
|
901
954
|
console.log(`[pair-review] Removed worktree via git: ${worktree.path}`);
|
|
902
955
|
} catch (gitError) {
|
|
903
956
|
// If git worktree remove fails, try manual directory removal
|
|
@@ -923,8 +976,10 @@ class GitWorktreeManager {
|
|
|
923
976
|
}
|
|
924
977
|
}
|
|
925
978
|
|
|
926
|
-
//
|
|
927
|
-
|
|
979
|
+
// Prune each unique owning repo
|
|
980
|
+
for (const repo of owningRepos.values()) {
|
|
981
|
+
await this.pruneWorktrees(repo);
|
|
982
|
+
}
|
|
928
983
|
|
|
929
984
|
if (result.cleaned > 0) {
|
|
930
985
|
console.log(`[pair-review] Cleaned up ${result.cleaned} stale worktrees (older than ${retentionDays} days)`);
|
package/src/routes/config.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
} = require('../ai');
|
|
22
22
|
const { normalizeRepository } = require('../utils/paths');
|
|
23
23
|
const { isRunningViaNpx, saveConfig } = require('../config');
|
|
24
|
+
const { version } = require('../../package.json');
|
|
24
25
|
const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
|
|
25
26
|
const { PRESETS } = require('../utils/comment-formatter');
|
|
26
27
|
const logger = require('../utils/logger');
|
|
@@ -42,6 +43,7 @@ router.get('/api/config', (req, res) => {
|
|
|
42
43
|
|
|
43
44
|
// Only return safe configuration values (not secrets like github_token)
|
|
44
45
|
res.json({
|
|
46
|
+
version,
|
|
45
47
|
theme: config.theme || 'light',
|
|
46
48
|
comment_button_action: config.comment_button_action || 'submit',
|
|
47
49
|
comment_format: config.comment_format || 'legacy',
|
|
@@ -53,6 +55,7 @@ router.get('/api/config', (req, res) => {
|
|
|
53
55
|
chat_enable_shortcuts: config.chat?.enable_shortcuts !== false,
|
|
54
56
|
pi_available: getCachedAvailability('pi')?.available || false,
|
|
55
57
|
assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review',
|
|
58
|
+
enable_graphite: config.enable_graphite === true,
|
|
56
59
|
// Share configuration for external review viewers.
|
|
57
60
|
// - url: The base URL of the external share site
|
|
58
61
|
// - method: Plumbed through for future use (e.g., POST-based share flows).
|
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({
|