@in-the-loop-labs/pair-review 3.1.3 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +980 -3
- package/public/js/components/AIPanel.js +7 -4
- package/public/js/components/ChatPanel.js +34 -4
- package/public/js/components/CouncilProgressModal.js +11 -0
- package/public/js/components/NotificationDropdown.js +257 -0
- package/public/js/components/StackAnalysisDialog.js +313 -0
- package/public/js/components/StackProgressModal.js +475 -0
- package/public/js/components/StatusIndicator.js +1 -0
- package/public/js/components/SuggestionNavigator.js +2 -0
- package/public/js/modules/comment-manager.js +7 -0
- package/public/js/modules/comment-minimizer.js +151 -4
- package/public/js/modules/file-comment-manager.js +66 -2
- package/public/js/modules/suggestion-manager.js +2 -1
- package/public/js/pr.js +433 -2
- package/public/js/utils/notification-sounds.js +62 -0
- package/public/local.html +10 -0
- package/public/pr.html +12 -0
- package/public/setup.html +4 -0
- package/src/ai/claude-provider.js +1 -11
- package/src/ai/codex-provider.js +18 -16
- package/src/ai/copilot-provider.js +21 -21
- package/src/ai/gemini-provider.js +10 -0
- package/src/ai/pi-provider.js +22 -25
- package/src/ai/provider.js +26 -3
- package/src/chat/pi-bridge.js +8 -0
- package/src/chat/session-manager.js +1 -0
- package/src/git/base-branch.js +1 -51
- package/src/git/worktree-lock.js +88 -0
- package/src/git/worktree.js +64 -0
- package/src/github/stack-walker.js +196 -0
- package/src/routes/local.js +12 -8
- package/src/routes/pr.js +139 -26
- package/src/routes/sound.js +49 -0
- package/src/routes/stack-analysis.js +886 -0
- package/src/server.js +4 -0
- package/src/setup/stack-setup.js +77 -0
package/src/git/worktree.js
CHANGED
|
@@ -30,6 +30,15 @@ class GitWorktreeManager {
|
|
|
30
30
|
this.worktreeRepo = db ? new WorktreeRepository(db) : null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Create a simple-git instance for a path. Extracted for testability.
|
|
35
|
+
* @param {string} dirPath
|
|
36
|
+
* @returns {import('simple-git').SimpleGit}
|
|
37
|
+
*/
|
|
38
|
+
_gitFor(dirPath) {
|
|
39
|
+
return simpleGit(dirPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
/**
|
|
34
43
|
* Apply the name template to generate a worktree directory name
|
|
35
44
|
* @param {Object} context - Template context variables
|
|
@@ -902,6 +911,61 @@ class GitWorktreeManager {
|
|
|
902
911
|
}
|
|
903
912
|
}
|
|
904
913
|
|
|
914
|
+
/**
|
|
915
|
+
* Checkout a different PR branch in an existing worktree.
|
|
916
|
+
*
|
|
917
|
+
* Used by stack analysis to switch the shared worktree between PRs.
|
|
918
|
+
* Stores a persistent ref (refs/remotes/<remote>/pr-<N>) instead of
|
|
919
|
+
* overwriting FETCH_HEAD, so multiple PR heads can coexist.
|
|
920
|
+
*
|
|
921
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
922
|
+
* @param {number} prNumber - PR number to checkout
|
|
923
|
+
* @param {Object} [options={}]
|
|
924
|
+
* @param {string} [options.remote='origin'] - Git remote name (overridden by resolveRemoteForPR if prData provided)
|
|
925
|
+
* @param {Object} [options.prData=null] - PR data from GitHub API (for fork remote resolution)
|
|
926
|
+
* @param {Object} [options.prInfo=null] - PR info { owner, repo, number } (for fork remote resolution)
|
|
927
|
+
* @returns {Promise<string>} The HEAD SHA after checkout
|
|
928
|
+
* @throws {Error} If the worktree has uncommitted changes
|
|
929
|
+
*/
|
|
930
|
+
async checkoutBranch(worktreePath, prNumber, options = {}) {
|
|
931
|
+
const { remote: defaultRemote = 'origin', prData = null, prInfo = null } = options;
|
|
932
|
+
|
|
933
|
+
try {
|
|
934
|
+
// 1. Reject if worktree has uncommitted changes
|
|
935
|
+
const hasChanges = await this.hasLocalChanges(worktreePath);
|
|
936
|
+
if (hasChanges) {
|
|
937
|
+
throw new Error(`Worktree has uncommitted changes. Cannot checkout PR #${prNumber} at: ${worktreePath}`);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const git = this._gitFor(worktreePath);
|
|
941
|
+
|
|
942
|
+
// 2. Resolve the correct remote (handles fork PRs)
|
|
943
|
+
const remote = (prData || prInfo)
|
|
944
|
+
? await this.resolveRemoteForPR(git, prData, prInfo)
|
|
945
|
+
: defaultRemote;
|
|
946
|
+
|
|
947
|
+
// 3. Fetch PR head into a persistent ref
|
|
948
|
+
console.log(`Fetching PR #${prNumber} head from ${remote} into refs/remotes/${remote}/pr-${prNumber}...`);
|
|
949
|
+
await git.fetch([remote, `+refs/pull/${prNumber}/head:refs/remotes/${remote}/pr-${prNumber}`]);
|
|
950
|
+
|
|
951
|
+
// 4. Reset worktree to the fetched ref
|
|
952
|
+
console.log(`Resetting worktree to refs/remotes/${remote}/pr-${prNumber}...`);
|
|
953
|
+
await git.raw(['reset', '--hard', `refs/remotes/${remote}/pr-${prNumber}`]);
|
|
954
|
+
|
|
955
|
+
// 5. Return the new HEAD SHA
|
|
956
|
+
const headSha = (await git.revparse(['HEAD'])).trim();
|
|
957
|
+
console.log(`Worktree checked out PR #${prNumber} at ${headSha}`);
|
|
958
|
+
return headSha;
|
|
959
|
+
|
|
960
|
+
} catch (error) {
|
|
961
|
+
if (error.message.includes('uncommitted changes')) {
|
|
962
|
+
throw error;
|
|
963
|
+
}
|
|
964
|
+
console.error(`Error checking out PR #${prNumber}:`, error);
|
|
965
|
+
throw new Error(`Failed to checkout PR #${prNumber}: ${error.message}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
905
969
|
/**
|
|
906
970
|
* Cleanup stale worktrees that haven't been accessed within the retention period
|
|
907
971
|
* @param {number} retentionDays - Number of days to retain worktrees (default: 7)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
const logger = require('../utils/logger');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TRUNK_BRANCHES = ['main', 'master', 'develop'];
|
|
5
|
+
const MAX_WALK_DEPTH = 20;
|
|
6
|
+
|
|
7
|
+
const FETCH_PR_QUERY = `
|
|
8
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
9
|
+
repository(owner: $owner, name: $repo) {
|
|
10
|
+
pullRequest(number: $number) {
|
|
11
|
+
number title baseRefName headRefName headRefOid state url
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const FIND_PRS_BY_HEAD_QUERY = `
|
|
18
|
+
query($owner: String!, $repo: String!, $branch: String!) {
|
|
19
|
+
repository(owner: $owner, name: $repo) {
|
|
20
|
+
pullRequests(headRefName: $branch, states: [OPEN, MERGED], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
21
|
+
nodes { number title baseRefName headRefName headRefOid state url }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
const FIND_PRS_BY_BASE_QUERY = `
|
|
28
|
+
query($owner: String!, $repo: String!, $branch: String!) {
|
|
29
|
+
repository(owner: $owner, name: $repo) {
|
|
30
|
+
pullRequests(baseRefName: $branch, states: [OPEN], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
31
|
+
nodes { number title baseRefName headRefName headRefOid state url }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Select the best PR from a list of candidates for the same branch.
|
|
39
|
+
* Prefers OPEN over MERGED.
|
|
40
|
+
*
|
|
41
|
+
* @param {Array} prs - Array of PR nodes from GraphQL
|
|
42
|
+
* @returns {Object|null} The best candidate or null
|
|
43
|
+
*/
|
|
44
|
+
function pickBestPR(prs) {
|
|
45
|
+
if (!prs || prs.length === 0) return null;
|
|
46
|
+
const open = prs.find(pr => pr.state === 'OPEN');
|
|
47
|
+
if (open) return open;
|
|
48
|
+
return prs[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Walk a GitHub PR stack by following the branch chain via GraphQL.
|
|
53
|
+
*
|
|
54
|
+
* Starting from a given PR, walks up toward trunk (following baseRefName)
|
|
55
|
+
* and down toward the tip (following headRefName) to discover the full stack.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} client - GitHubClient instance (uses client.octokit.graphql)
|
|
58
|
+
* @param {string} owner - Repository owner
|
|
59
|
+
* @param {string} repo - Repository name
|
|
60
|
+
* @param {number} prNumber - Starting PR number
|
|
61
|
+
* @param {Object} [_deps] - Optional dependency overrides for testing
|
|
62
|
+
* @param {string[]} [_deps.defaultBranches] - Branch names considered trunk
|
|
63
|
+
* @returns {Promise<Array>} Ordered stack from trunk to tip
|
|
64
|
+
*/
|
|
65
|
+
async function walkPRStack(client, owner, repo, prNumber, _deps) {
|
|
66
|
+
const deps = { defaultBranches: DEFAULT_TRUNK_BRANCHES, ..._deps };
|
|
67
|
+
const graphql = client.octokit.graphql.bind(client.octokit);
|
|
68
|
+
const visited = new Set();
|
|
69
|
+
|
|
70
|
+
// Step 1: Fetch the starting PR
|
|
71
|
+
const startResult = await graphql(FETCH_PR_QUERY, { owner, repo, number: prNumber });
|
|
72
|
+
const startPR = startResult.repository?.pullRequest;
|
|
73
|
+
if (!startPR) {
|
|
74
|
+
throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logger.debug(`Stack walker: starting from PR #${startPR.number} (${startPR.headRefName} -> ${startPR.baseRefName})`);
|
|
78
|
+
visited.add(startPR.headRefName);
|
|
79
|
+
|
|
80
|
+
// Step 2: Walk UP toward trunk
|
|
81
|
+
const parents = []; // will be reversed at the end
|
|
82
|
+
let currentBase = startPR.baseRefName;
|
|
83
|
+
let walkUpDepth = 0;
|
|
84
|
+
|
|
85
|
+
while (walkUpDepth < MAX_WALK_DEPTH) {
|
|
86
|
+
if (deps.defaultBranches.includes(currentBase)) {
|
|
87
|
+
// Reached trunk
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (visited.has(currentBase)) {
|
|
91
|
+
logger.warn(`Stack walker: cycle detected at branch "${currentBase}", stopping upward walk`);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
visited.add(currentBase);
|
|
95
|
+
|
|
96
|
+
let parentPR;
|
|
97
|
+
try {
|
|
98
|
+
const result = await graphql(FIND_PRS_BY_HEAD_QUERY, { owner, repo, branch: currentBase });
|
|
99
|
+
const candidates = result.repository?.pullRequests?.nodes || [];
|
|
100
|
+
parentPR = pickBestPR(candidates);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.warn(`Stack walker: GraphQL error walking up at branch "${currentBase}": ${err.message}`);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!parentPR) {
|
|
107
|
+
// No parent PR found — currentBase is effectively trunk for this stack
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
parents.push({
|
|
112
|
+
branch: parentPR.headRefName,
|
|
113
|
+
isTrunk: false,
|
|
114
|
+
prNumber: parentPR.number,
|
|
115
|
+
title: parentPR.title,
|
|
116
|
+
state: parentPR.state,
|
|
117
|
+
url: parentPR.url,
|
|
118
|
+
headSha: parentPR.headRefOid,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
currentBase = parentPR.baseRefName;
|
|
122
|
+
walkUpDepth++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (walkUpDepth >= MAX_WALK_DEPTH) {
|
|
126
|
+
logger.warn(`Stack walker: upward walk reached max depth of ${MAX_WALK_DEPTH}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The trunk entry is whatever branch the topmost PR targets
|
|
130
|
+
const trunkBranch = currentBase;
|
|
131
|
+
|
|
132
|
+
// Step 3: Walk DOWN toward tip
|
|
133
|
+
const children = [];
|
|
134
|
+
let currentHead = startPR.headRefName;
|
|
135
|
+
let walkDownDepth = 0;
|
|
136
|
+
|
|
137
|
+
while (walkDownDepth < MAX_WALK_DEPTH) {
|
|
138
|
+
let childPR;
|
|
139
|
+
try {
|
|
140
|
+
const result = await graphql(FIND_PRS_BY_BASE_QUERY, { owner, repo, branch: currentHead });
|
|
141
|
+
const candidates = result.repository?.pullRequests?.nodes || [];
|
|
142
|
+
childPR = pickBestPR(candidates);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.warn(`Stack walker: GraphQL error walking down at branch "${currentHead}": ${err.message}`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!childPR) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (visited.has(childPR.headRefName)) {
|
|
153
|
+
logger.warn(`Stack walker: cycle detected at branch "${childPR.headRefName}", stopping downward walk`);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
visited.add(childPR.headRefName);
|
|
157
|
+
|
|
158
|
+
children.push({
|
|
159
|
+
branch: childPR.headRefName,
|
|
160
|
+
isTrunk: false,
|
|
161
|
+
prNumber: childPR.number,
|
|
162
|
+
title: childPR.title,
|
|
163
|
+
state: childPR.state,
|
|
164
|
+
url: childPR.url,
|
|
165
|
+
headSha: childPR.headRefOid,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
currentHead = childPR.headRefName;
|
|
169
|
+
walkDownDepth++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (walkDownDepth >= MAX_WALK_DEPTH) {
|
|
173
|
+
logger.warn(`Stack walker: downward walk reached max depth of ${MAX_WALK_DEPTH}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Step 4: Assemble the ordered stack (trunk -> ... -> parents -> start -> children -> ... -> tip)
|
|
177
|
+
const stack = [
|
|
178
|
+
{ branch: trunkBranch, isTrunk: true },
|
|
179
|
+
...parents.reverse(),
|
|
180
|
+
{
|
|
181
|
+
branch: startPR.headRefName,
|
|
182
|
+
isTrunk: false,
|
|
183
|
+
prNumber: startPR.number,
|
|
184
|
+
title: startPR.title,
|
|
185
|
+
state: startPR.state,
|
|
186
|
+
url: startPR.url,
|
|
187
|
+
headSha: startPR.headRefOid,
|
|
188
|
+
},
|
|
189
|
+
...children,
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
logger.debug(`Stack walker: found ${stack.length} entries (${stack.filter(e => !e.isTrunk).length} PRs)`);
|
|
193
|
+
return stack;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { walkPRStack, DEFAULT_TRUNK_BRANCHES };
|
package/src/routes/local.js
CHANGED
|
@@ -16,7 +16,7 @@ const express = require('express');
|
|
|
16
16
|
const { execSync } = require('child_process');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const fs = require('fs').promises;
|
|
19
|
-
const { queryOne, run, ReviewRepository, RepoSettingsRepository, AnalysisRunRepository, CouncilRepository } = require('../database');
|
|
19
|
+
const { query, queryOne, run, ReviewRepository, RepoSettingsRepository, AnalysisRunRepository, CouncilRepository } = require('../database');
|
|
20
20
|
const Analyzer = require('../ai/analyzer');
|
|
21
21
|
const { v4: uuidv4 } = require('uuid');
|
|
22
22
|
const logger = require('../utils/logger');
|
|
@@ -32,8 +32,7 @@ const { getShaAbbrevLength } = require('../git/sha-abbrev');
|
|
|
32
32
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
33
33
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
34
34
|
const { getProviderClass, createProvider } = require('../ai/provider');
|
|
35
|
-
const {
|
|
36
|
-
const { getDefaultBranch, tryGraphiteState, readGraphitePRInfo, enrichStackWithPRInfo } = require('../git/base-branch');
|
|
35
|
+
const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
|
|
37
36
|
const { CommentRepository } = require('../database');
|
|
38
37
|
const { runExecutableAnalysis, getChangedFiles } = require('./executable-analysis');
|
|
39
38
|
const {
|
|
@@ -630,12 +629,17 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
630
629
|
const localConfig = req.app.get('config') || {};
|
|
631
630
|
if (localConfig.enable_graphite === true && review.local_path && branchName && branchName !== 'unknown' && branchName !== 'HEAD') {
|
|
632
631
|
try {
|
|
633
|
-
const graphiteResult = tryGraphiteState(review.local_path, branchName, { execSync
|
|
632
|
+
const graphiteResult = tryGraphiteState(review.local_path, branchName, { execSync });
|
|
634
633
|
if (graphiteResult?.stack) {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
?
|
|
638
|
-
:
|
|
634
|
+
// Enrich with PR numbers from pr_metadata DB
|
|
635
|
+
const allPRs = repositoryName
|
|
636
|
+
? await query(db, 'SELECT pr_number, head_branch FROM pr_metadata WHERE repository = ? COLLATE NOCASE', [repositoryName])
|
|
637
|
+
: [];
|
|
638
|
+
const prMap = new Map(allPRs.filter(p => p.head_branch).map(p => [p.head_branch, p.pr_number]));
|
|
639
|
+
stackData = graphiteResult.stack.map(entry => {
|
|
640
|
+
const prNumber = prMap.get(entry.branch);
|
|
641
|
+
return prNumber != null ? { ...entry, prNumber } : entry;
|
|
642
|
+
});
|
|
639
643
|
}
|
|
640
644
|
} catch {
|
|
641
645
|
// Non-fatal — stack detection is an enhancement
|
package/src/routes/pr.js
CHANGED
|
@@ -32,10 +32,8 @@ const { broadcastReviewEvent } = require('../events/review-events');
|
|
|
32
32
|
const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
33
33
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
|
|
34
34
|
const simpleGit = require('simple-git');
|
|
35
|
-
const { execSync } = require('child_process');
|
|
36
|
-
const { readFileSync } = require('fs');
|
|
37
35
|
const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('../git/diff-flags');
|
|
38
|
-
const {
|
|
36
|
+
const { walkPRStack, DEFAULT_TRUNK_BRANCHES } = require('../github/stack-walker');
|
|
39
37
|
const {
|
|
40
38
|
activeAnalyses,
|
|
41
39
|
reviewToAnalysisId,
|
|
@@ -53,7 +51,7 @@ const { getProviderClass, createProvider } = require('../ai/provider');
|
|
|
53
51
|
const { CommentRepository } = require('../database');
|
|
54
52
|
const { runExecutableAnalysis } = require('./executable-analysis');
|
|
55
53
|
const analysesRouter = require('./analyses');
|
|
56
|
-
|
|
54
|
+
const { worktreeLock } = require('../git/worktree-lock');
|
|
57
55
|
const router = express.Router();
|
|
58
56
|
|
|
59
57
|
/**
|
|
@@ -236,21 +234,20 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
236
234
|
? getShaAbbrevLength(extendedData.worktree_path)
|
|
237
235
|
: DEFAULT_SHA_ABBREV_LENGTH;
|
|
238
236
|
|
|
239
|
-
// Detect
|
|
237
|
+
// Detect PR stack via GitHub GraphQL chain-walking
|
|
240
238
|
let stackData = null;
|
|
241
239
|
{
|
|
242
240
|
const stackConfig = req.app.get('config') || {};
|
|
243
|
-
|
|
241
|
+
const githubToken = getGitHubToken(stackConfig) || req.app.get('githubToken');
|
|
242
|
+
if (githubToken) {
|
|
244
243
|
try {
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
} catch {
|
|
253
|
-
// Non-fatal — stack detection is an enhancement
|
|
244
|
+
const ghClient = new GitHubClient(githubToken);
|
|
245
|
+
const defaultBranch = extendedData.repository?.default_branch;
|
|
246
|
+
stackData = await walkPRStack(ghClient, repoOwner, repoName, prNumber, {
|
|
247
|
+
defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
|
|
248
|
+
});
|
|
249
|
+
} catch (stackError) {
|
|
250
|
+
logger.debug('Failed to walk PR stack:', stackError.message);
|
|
254
251
|
}
|
|
255
252
|
}
|
|
256
253
|
}
|
|
@@ -356,6 +353,20 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
356
353
|
if (!githubToken) {
|
|
357
354
|
return res.status(401).json({ error: 'GitHub token not configured' });
|
|
358
355
|
}
|
|
356
|
+
|
|
357
|
+
// Check if worktree is locked before modifying it
|
|
358
|
+
const worktreeManagerForLock = new GitWorktreeManager(db);
|
|
359
|
+
const existingWorktreePath = await worktreeManagerForLock.getWorktreePath({ owner, repo, number: prNumber });
|
|
360
|
+
if (existingWorktreePath) {
|
|
361
|
+
const lockState = worktreeLock.isLocked(existingWorktreePath);
|
|
362
|
+
if (lockState.locked) {
|
|
363
|
+
return res.status(409).json({
|
|
364
|
+
error: 'Worktree is in use by stack analysis',
|
|
365
|
+
holderId: lockState.holderId
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
359
370
|
const githubClient = new GitHubClient(githubToken);
|
|
360
371
|
const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
361
372
|
|
|
@@ -433,19 +444,20 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
433
444
|
const parsedData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
|
|
434
445
|
const [repoOwner, repoName] = repository.split('/');
|
|
435
446
|
|
|
436
|
-
//
|
|
447
|
+
// Refresh stack data via GitHub GraphQL
|
|
437
448
|
let stackData = null;
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
:
|
|
449
|
+
{
|
|
450
|
+
const githubToken = getGitHubToken(config) || req.app.get('githubToken');
|
|
451
|
+
if (githubToken) {
|
|
452
|
+
try {
|
|
453
|
+
const ghClient = new GitHubClient(githubToken);
|
|
454
|
+
const defaultBranch = prData.repository?.default_branch;
|
|
455
|
+
stackData = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
|
|
456
|
+
defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
|
|
457
|
+
});
|
|
458
|
+
} catch (stackError) {
|
|
459
|
+
logger.debug('Failed to walk PR stack on refresh:', stackError.message);
|
|
446
460
|
}
|
|
447
|
-
} catch {
|
|
448
|
-
// Non-fatal — stack detection is an enhancement
|
|
449
461
|
}
|
|
450
462
|
}
|
|
451
463
|
|
|
@@ -1619,6 +1631,17 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1619
1631
|
});
|
|
1620
1632
|
}
|
|
1621
1633
|
|
|
1634
|
+
// Reject if worktree is locked by another operation (e.g. stack analysis)
|
|
1635
|
+
if (worktreePath) {
|
|
1636
|
+
const lockState = worktreeLock.isLocked(worktreePath);
|
|
1637
|
+
if (lockState.locked) {
|
|
1638
|
+
return res.status(409).json({
|
|
1639
|
+
error: 'Worktree is in use by stack analysis',
|
|
1640
|
+
holderId: lockState.holderId
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1622
1645
|
const appConfig = req.app.get('config') || {};
|
|
1623
1646
|
const globalInstructions = appConfig.globalInstructions || null;
|
|
1624
1647
|
|
|
@@ -2210,4 +2233,94 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
|
|
|
2210
2233
|
}
|
|
2211
2234
|
});
|
|
2212
2235
|
|
|
2236
|
+
/**
|
|
2237
|
+
* Get enriched stack info for a PR stack via GitHub GraphQL chain-walking.
|
|
2238
|
+
* Returns all branches in the stack with PR numbers, titles, analysis status,
|
|
2239
|
+
* and worktree ownership — used by the stack selection dialog.
|
|
2240
|
+
*/
|
|
2241
|
+
router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
|
|
2242
|
+
try {
|
|
2243
|
+
const { owner, repo, number } = req.params;
|
|
2244
|
+
const prNumber = parseInt(number);
|
|
2245
|
+
|
|
2246
|
+
if (isNaN(prNumber) || prNumber <= 0) {
|
|
2247
|
+
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
const repository = normalizeRepository(owner, repo);
|
|
2251
|
+
const db = req.app.get('db');
|
|
2252
|
+
const config = req.app.get('config') || {};
|
|
2253
|
+
|
|
2254
|
+
const githubToken = getGitHubToken(config) || req.app.get('githubToken');
|
|
2255
|
+
if (!githubToken) {
|
|
2256
|
+
return res.json({ stack: [] });
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Look up pr_data to extract default_branch for stack walking
|
|
2260
|
+
const prMetadataRow = await queryOne(db, `
|
|
2261
|
+
SELECT pr_data FROM pr_metadata
|
|
2262
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
2263
|
+
`, [prNumber, repository]);
|
|
2264
|
+
const parsedPrData = prMetadataRow?.pr_data ? JSON.parse(prMetadataRow.pr_data) : {};
|
|
2265
|
+
const defaultBranch = parsedPrData.repository?.default_branch;
|
|
2266
|
+
|
|
2267
|
+
const ghClient = new GitHubClient(githubToken);
|
|
2268
|
+
let stack;
|
|
2269
|
+
try {
|
|
2270
|
+
stack = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
|
|
2271
|
+
defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
|
|
2272
|
+
});
|
|
2273
|
+
} catch (walkError) {
|
|
2274
|
+
logger.debug('Failed to walk PR stack for stack-info:', walkError.message);
|
|
2275
|
+
return res.json({ stack: [] });
|
|
2276
|
+
}
|
|
2277
|
+
if (!stack) {
|
|
2278
|
+
return res.json({ stack: [] });
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// For each non-trunk entry with a PR number, look up analysis status and worktree
|
|
2282
|
+
const enrichedStack = [];
|
|
2283
|
+
const worktreeRepo = new WorktreeRepository(db);
|
|
2284
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
2285
|
+
const reviewRepo = new ReviewRepository(db);
|
|
2286
|
+
|
|
2287
|
+
for (const entry of stack) {
|
|
2288
|
+
if (entry.isTrunk) {
|
|
2289
|
+
enrichedStack.push(entry);
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
const enriched = { ...entry };
|
|
2294
|
+
|
|
2295
|
+
if (entry.prNumber) {
|
|
2296
|
+
enriched.title = entry.title || null;
|
|
2297
|
+
|
|
2298
|
+
// Check if there's an analysis run matching the current head SHA
|
|
2299
|
+
const review = await reviewRepo.getReviewByPR(entry.prNumber, repository);
|
|
2300
|
+
if (review) {
|
|
2301
|
+
const latestRun = await analysisRunRepo.getLatestCompletedByReviewId(review.id);
|
|
2302
|
+
enriched.hasAnalysis = latestRun?.head_sha === enriched.headSha;
|
|
2303
|
+
} else {
|
|
2304
|
+
enriched.hasAnalysis = false;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// Check if it has its own worktree
|
|
2308
|
+
const wt = await worktreeRepo.findByPR(entry.prNumber, repository);
|
|
2309
|
+
enriched.hasOwnWorktree = wt != null;
|
|
2310
|
+
} else {
|
|
2311
|
+
enriched.title = null;
|
|
2312
|
+
enriched.hasAnalysis = false;
|
|
2313
|
+
enriched.hasOwnWorktree = false;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
enrichedStack.push(enriched);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
res.json({ stack: enrichedStack });
|
|
2320
|
+
} catch (error) {
|
|
2321
|
+
logger.error('Error fetching stack info:', error);
|
|
2322
|
+
res.status(500).json({ error: 'Failed to fetch stack info' });
|
|
2323
|
+
}
|
|
2324
|
+
});
|
|
2325
|
+
|
|
2213
2326
|
module.exports = router;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const { execFile } = require('child_process');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
// Default dependencies (overridable for testing)
|
|
7
|
+
const defaults = {
|
|
8
|
+
execFile,
|
|
9
|
+
logger,
|
|
10
|
+
platform: process.platform,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a Router for the sound playback endpoint.
|
|
15
|
+
*
|
|
16
|
+
* Plays a system notification sound on the host machine via a platform-specific
|
|
17
|
+
* CLI command. Fire-and-forget: the response is sent immediately and any
|
|
18
|
+
* playback errors are logged at warn level (sound failure is non-critical).
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
21
|
+
* @returns {import('express').Router}
|
|
22
|
+
*/
|
|
23
|
+
function createSoundRouter(_deps = {}) {
|
|
24
|
+
const { execFile, logger, platform } = { ...defaults, ..._deps };
|
|
25
|
+
const router = express.Router();
|
|
26
|
+
|
|
27
|
+
router.post('/api/play-sound', (req, res) => {
|
|
28
|
+
if (platform === 'darwin') {
|
|
29
|
+
execFile('afplay', ['/System/Library/Sounds/Glass.aiff'], (err) => {
|
|
30
|
+
if (err) logger.warn(`Sound playback failed: ${err.message}`);
|
|
31
|
+
});
|
|
32
|
+
} else if (platform === 'linux') {
|
|
33
|
+
execFile('paplay', ['/usr/share/sounds/freedesktop/stereo/complete.oga'], (err) => {
|
|
34
|
+
if (err) logger.warn(`Sound playback failed: ${err.message}`);
|
|
35
|
+
});
|
|
36
|
+
} else if (platform === 'win32') {
|
|
37
|
+
execFile('powershell', ['-Command', '[System.Media.SystemSounds]::Asterisk.Play()'], (err) => {
|
|
38
|
+
if (err) logger.warn(`Sound playback failed: ${err.message}`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// Unsupported platforms: silent degradation — no execFile call, still 204.
|
|
42
|
+
|
|
43
|
+
res.sendStatus(204);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return router;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { createSoundRouter };
|