@in-the-loop-labs/pair-review 3.1.4 → 3.2.1
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 +913 -1
- package/public/js/components/CouncilProgressModal.js +8 -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/pr.js +420 -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/pi-provider.js +13 -5
- package/src/ai/provider.js +4 -0
- package/src/chat/chat-providers.js +4 -0
- package/src/chat/pi-bridge.js +8 -0
- package/src/chat/session-manager.js +5 -1
- 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/ai/provider.js
CHANGED
|
@@ -532,6 +532,8 @@ function applyConfigOverrides(config) {
|
|
|
532
532
|
installInstructions: providerConfig.installInstructions,
|
|
533
533
|
extra_args: providerConfig.extra_args,
|
|
534
534
|
env: providerConfig.env,
|
|
535
|
+
load_skills: providerConfig.load_skills,
|
|
536
|
+
app_extensions: providerConfig.app_extensions,
|
|
535
537
|
models: AliasClass.getModels() !== BaseClass.getModels() ? AliasClass.getModels() : null
|
|
536
538
|
});
|
|
537
539
|
logger.debug(`Registered aliased provider: ${providerId} (base: ${providerConfig.type})`);
|
|
@@ -557,6 +559,8 @@ function applyConfigOverrides(config) {
|
|
|
557
559
|
installInstructions: providerConfig.installInstructions,
|
|
558
560
|
extra_args: providerConfig.extra_args,
|
|
559
561
|
env: providerConfig.env,
|
|
562
|
+
load_skills: providerConfig.load_skills,
|
|
563
|
+
app_extensions: providerConfig.app_extensions,
|
|
560
564
|
models: processedModels
|
|
561
565
|
});
|
|
562
566
|
}
|
|
@@ -121,6 +121,8 @@ function getChatProvider(id) {
|
|
|
121
121
|
if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
|
|
122
122
|
provider.args = [...provider.args, ...overrides.extra_args];
|
|
123
123
|
}
|
|
124
|
+
if (overrides.load_skills !== undefined) provider.load_skills = overrides.load_skills;
|
|
125
|
+
if (overrides.app_extensions !== undefined) provider.app_extensions = overrides.app_extensions;
|
|
124
126
|
if (provider.command.includes(' ')) {
|
|
125
127
|
provider.useShell = true;
|
|
126
128
|
}
|
|
@@ -142,6 +144,8 @@ function getChatProvider(id) {
|
|
|
142
144
|
if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
|
|
143
145
|
merged.args = [...(merged.args || []), ...overrides.extra_args];
|
|
144
146
|
}
|
|
147
|
+
if (overrides.load_skills !== undefined) merged.load_skills = overrides.load_skills;
|
|
148
|
+
if (overrides.app_extensions !== undefined) merged.app_extensions = overrides.app_extensions;
|
|
145
149
|
// For multi-word commands (e.g. "devx claude"), use shell mode
|
|
146
150
|
if (merged.command && merged.command.includes(' ')) {
|
|
147
151
|
merged.useShell = true;
|
package/src/chat/pi-bridge.js
CHANGED
|
@@ -37,6 +37,7 @@ class PiBridge extends EventEmitter {
|
|
|
37
37
|
* @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
|
|
38
38
|
* @param {string[]} [options.extraArgs] - Extra CLI args to append (e.g., from config extra_args)
|
|
39
39
|
* @param {string} [options.sessionPath] - Path to a session file for resumption
|
|
40
|
+
* @param {boolean} [options.loadSkills] - When false, adds --no-skills to suppress auto-discovery (default: true)
|
|
40
41
|
*/
|
|
41
42
|
constructor(options = {}) {
|
|
42
43
|
super();
|
|
@@ -52,6 +53,7 @@ class PiBridge extends EventEmitter {
|
|
|
52
53
|
this.extensions = options.extensions || [];
|
|
53
54
|
this.extraArgs = options.extraArgs || [];
|
|
54
55
|
this.sessionPath = options.sessionPath || null;
|
|
56
|
+
this.loadSkills = options.loadSkills !== false;
|
|
55
57
|
|
|
56
58
|
this._process = null;
|
|
57
59
|
this._readline = null;
|
|
@@ -290,6 +292,12 @@ class PiBridge extends EventEmitter {
|
|
|
290
292
|
args.push('-e', ext);
|
|
291
293
|
}
|
|
292
294
|
|
|
295
|
+
// Suppress skill auto-discovery when loadSkills is false.
|
|
296
|
+
// Explicit --skill entries above still load.
|
|
297
|
+
if (!this.loadSkills) {
|
|
298
|
+
args.push('--no-skills');
|
|
299
|
+
}
|
|
300
|
+
|
|
293
301
|
// Append extra args from provider config (e.g., extra_args in chat_providers).
|
|
294
302
|
// These go last so they can override earlier flags if needed.
|
|
295
303
|
if (this.extraArgs.length > 0) {
|
|
@@ -575,6 +575,9 @@ class ChatSessionManager {
|
|
|
575
575
|
// which would forward it as `--provider pi` to the Pi CLI. The CLI's --provider flag
|
|
576
576
|
// expects a model provider ("google", "anthropic", etc.) and should only come from
|
|
577
577
|
// explicit user configuration (providerDef.provider).
|
|
578
|
+
// app_extensions (default true): when false, omit pair-review's task extension.
|
|
579
|
+
// load_skills (default true): when false, suppress Pi's skill auto-discovery.
|
|
580
|
+
const appExtensions = def?.app_extensions !== false;
|
|
578
581
|
return new PiBridge({
|
|
579
582
|
...options,
|
|
580
583
|
provider: def?.provider || null,
|
|
@@ -584,7 +587,8 @@ class ChatSessionManager {
|
|
|
584
587
|
env: def?.env,
|
|
585
588
|
useShell: def?.useShell,
|
|
586
589
|
tools: CHAT_TOOLS,
|
|
587
|
-
extensions: [taskExtensionDir],
|
|
590
|
+
extensions: appExtensions ? [taskExtensionDir] : [],
|
|
591
|
+
loadSkills: def?.load_skills,
|
|
588
592
|
});
|
|
589
593
|
}
|
|
590
594
|
|
package/src/git/base-branch.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const { execSync } = require('child_process');
|
|
3
|
-
const { readFileSync } = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
3
|
const logger = require('../utils/logger');
|
|
6
4
|
|
|
7
5
|
const defaults = {
|
|
8
6
|
execSync,
|
|
9
|
-
readFileSync,
|
|
10
7
|
// Callers should pass a resolved token via _deps.getGitHubToken.
|
|
11
8
|
// This default returns empty so GitHub lookup is silently skipped
|
|
12
9
|
// when no token is provided — never re-resolve config internally.
|
|
@@ -135,53 +132,6 @@ function buildStack(state, currentBranch, trunk) {
|
|
|
135
132
|
return entries;
|
|
136
133
|
}
|
|
137
134
|
|
|
138
|
-
/**
|
|
139
|
-
* Read Graphite PR info from the `.graphite_pr_info` file in the git dir.
|
|
140
|
-
*
|
|
141
|
-
* @param {string} repoPath - Absolute path to the repository
|
|
142
|
-
* @param {Object} deps - Dependencies (execSync, readFileSync)
|
|
143
|
-
* @returns {Object|null} Parsed PR info object with `prInfos` array, or null
|
|
144
|
-
*/
|
|
145
|
-
function readGraphitePRInfo(repoPath, deps) {
|
|
146
|
-
try {
|
|
147
|
-
const gitCommonDir = deps.execSync('git rev-parse --git-common-dir', {
|
|
148
|
-
cwd: repoPath,
|
|
149
|
-
encoding: 'utf8',
|
|
150
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
151
|
-
}).trim();
|
|
152
|
-
|
|
153
|
-
const prInfoPath = path.resolve(repoPath, gitCommonDir, '.graphite_pr_info');
|
|
154
|
-
const raw = deps.readFileSync(prInfoPath, 'utf8');
|
|
155
|
-
return JSON.parse(raw);
|
|
156
|
-
} catch (error) {
|
|
157
|
-
logger.debug(`Graphite PR info read failed: ${error.message}`);
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Enrich stack entries with PR numbers from Graphite PR info.
|
|
164
|
-
*
|
|
165
|
-
* @param {Array} stack - Stack entries from buildStack
|
|
166
|
-
* @param {Array} prInfos - Array of PR info objects with headRefName and prNumber
|
|
167
|
-
* @returns {Array} New array of stack entries, each with optional prNumber
|
|
168
|
-
*/
|
|
169
|
-
function enrichStackWithPRInfo(stack, prInfos) {
|
|
170
|
-
if (!prInfos || !Array.isArray(prInfos)) return stack;
|
|
171
|
-
|
|
172
|
-
const prMap = new Map();
|
|
173
|
-
for (const info of prInfos) {
|
|
174
|
-
if (info.headRefName) {
|
|
175
|
-
prMap.set(info.headRefName, info.prNumber);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return stack.map(entry => {
|
|
180
|
-
const prNumber = prMap.get(entry.branch);
|
|
181
|
-
return prNumber != null ? { ...entry, prNumber } : { ...entry };
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
135
|
/**
|
|
186
136
|
* Try GitHub API to find an open PR for this branch.
|
|
187
137
|
*/
|
|
@@ -302,4 +252,4 @@ function getDefaultBranch(localPath, _deps) {
|
|
|
302
252
|
return null;
|
|
303
253
|
}
|
|
304
254
|
|
|
305
|
-
module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack
|
|
255
|
+
module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* In-memory worktree lock manager.
|
|
4
|
+
*
|
|
5
|
+
* Prevents concurrent git operations on the same worktree during
|
|
6
|
+
* stack analysis. Non-blocking — callers check and fail fast.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const logger = require('../utils/logger');
|
|
10
|
+
|
|
11
|
+
class WorktreeLockManager {
|
|
12
|
+
constructor() {
|
|
13
|
+
/** @type {Map<string, { holderId: string, lockedAt: Date }>} */
|
|
14
|
+
this._locks = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Acquire a lock on a worktree path.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
21
|
+
* @param {string} holderId - Unique identifier for the lock holder (e.g. stackAnalysisId)
|
|
22
|
+
* @returns {boolean} true if acquired (or re-acquired by same holder), false if held by another
|
|
23
|
+
*/
|
|
24
|
+
acquire(worktreePath, holderId) {
|
|
25
|
+
const existing = this._locks.get(worktreePath);
|
|
26
|
+
|
|
27
|
+
if (existing) {
|
|
28
|
+
if (existing.holderId === holderId) {
|
|
29
|
+
// Re-acquire by same holder — update timestamp
|
|
30
|
+
existing.lockedAt = new Date();
|
|
31
|
+
logger.debug(`Worktree lock re-acquired: ${worktreePath} by ${holderId}`);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
logger.debug(`Worktree lock denied: ${worktreePath} held by ${existing.holderId}, requested by ${holderId}`);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this._locks.set(worktreePath, { holderId, lockedAt: new Date() });
|
|
39
|
+
logger.info(`Worktree lock acquired: ${worktreePath} by ${holderId}`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Release a lock on a worktree path.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
47
|
+
* @param {string} holderId - Must match the holder that acquired the lock
|
|
48
|
+
* @returns {boolean} true if released, false if not held or held by a different holder
|
|
49
|
+
*/
|
|
50
|
+
release(worktreePath, holderId) {
|
|
51
|
+
const existing = this._locks.get(worktreePath);
|
|
52
|
+
|
|
53
|
+
if (!existing) {
|
|
54
|
+
logger.debug(`Worktree lock release: no lock found for ${worktreePath}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (existing.holderId !== holderId) {
|
|
59
|
+
logger.debug(`Worktree lock release denied: ${worktreePath} held by ${existing.holderId}, release requested by ${holderId}`);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this._locks.delete(worktreePath);
|
|
64
|
+
logger.info(`Worktree lock released: ${worktreePath} by ${holderId}`);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check whether a worktree is currently locked.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
72
|
+
* @returns {{ locked: boolean, holderId?: string }}
|
|
73
|
+
*/
|
|
74
|
+
isLocked(worktreePath) {
|
|
75
|
+
const existing = this._locks.get(worktreePath);
|
|
76
|
+
|
|
77
|
+
if (!existing) {
|
|
78
|
+
return { locked: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { locked: true, holderId: existing.holderId };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Singleton instance for application-wide use
|
|
86
|
+
const worktreeLock = new WorktreeLockManager();
|
|
87
|
+
|
|
88
|
+
module.exports = { worktreeLock, WorktreeLockManager };
|
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
|