@in-the-loop-labs/pair-review 2.6.2 → 2.7.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/bin/git-diff-lines +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
- package/public/css/pr.css +201 -0
- package/public/index.html +168 -3
- package/public/js/components/AIPanel.js +16 -2
- package/public/js/components/ChatPanel.js +41 -6
- package/public/js/components/ConfirmDialog.js +21 -2
- package/public/js/components/CouncilProgressModal.js +13 -0
- package/public/js/components/DiffOptionsDropdown.js +410 -23
- package/public/js/components/SuggestionNavigator.js +12 -5
- package/public/js/components/TabTitle.js +96 -0
- package/public/js/components/Toast.js +6 -0
- package/public/js/index.js +648 -43
- package/public/js/local.js +569 -76
- package/public/js/modules/analysis-history.js +3 -2
- package/public/js/modules/comment-manager.js +5 -0
- package/public/js/modules/comment-minimizer.js +304 -0
- package/public/js/pr.js +82 -6
- package/public/local.html +14 -0
- package/public/pr.html +3 -0
- package/src/ai/analyzer.js +22 -16
- package/src/ai/cursor-agent-provider.js +21 -12
- package/src/chat/prompt-builder.js +3 -3
- package/src/config.js +2 -0
- package/src/database.js +590 -39
- package/src/git/base-branch.js +173 -0
- package/src/git/sha-abbrev.js +35 -0
- package/src/git/worktree.js +3 -2
- package/src/github/client.js +32 -1
- package/src/hooks/hook-runner.js +100 -0
- package/src/hooks/payloads.js +212 -0
- package/src/local-review.js +468 -129
- package/src/local-scope.js +58 -0
- package/src/main.js +57 -6
- package/src/routes/analyses.js +73 -10
- package/src/routes/chat.js +33 -0
- package/src/routes/config.js +1 -0
- package/src/routes/github-collections.js +2 -2
- package/src/routes/local.js +734 -68
- package/src/routes/mcp.js +20 -10
- package/src/routes/pr.js +92 -14
- package/src/routes/setup.js +1 -0
- package/src/routes/worktrees.js +212 -148
- package/src/server.js +30 -0
- package/src/setup/local-setup.js +46 -5
- package/src/setup/pr-setup.js +28 -5
- package/src/utils/diff-file-list.js +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
const logger = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
const defaults = {
|
|
6
|
+
execSync,
|
|
7
|
+
// Callers should pass a resolved token via _deps.getGitHubToken.
|
|
8
|
+
// This default returns empty so GitHub lookup is silently skipped
|
|
9
|
+
// when no token is provided — never re-resolve config internally.
|
|
10
|
+
getGitHubToken: () => '',
|
|
11
|
+
createGitHubClient: (token) => {
|
|
12
|
+
const { GitHubClient } = require('../github/client');
|
|
13
|
+
return new GitHubClient(token);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect the base branch for the current branch.
|
|
19
|
+
*
|
|
20
|
+
* Priority:
|
|
21
|
+
* 1. Graphite — `gt trunk` and `gt parent`
|
|
22
|
+
* 2. GitHub PR — look up an open PR for this branch
|
|
23
|
+
* 3. Default branch — `git remote show origin` or local main/master
|
|
24
|
+
*
|
|
25
|
+
* @param {string} repoPath - Absolute path to the repository
|
|
26
|
+
* @param {string} currentBranch - Current branch name (or 'HEAD' if detached)
|
|
27
|
+
* @param {Object} [options]
|
|
28
|
+
* @param {string} [options.repository] - owner/repo string (needed for GitHub lookup)
|
|
29
|
+
* @param {boolean} [options.enableGraphite] - When true, try Graphite CLI for parent branch
|
|
30
|
+
* @param {Object} [options._deps] - Dependency overrides for testing
|
|
31
|
+
* @returns {Promise<{baseBranch: string, source: string, prNumber?: number}|null>}
|
|
32
|
+
*/
|
|
33
|
+
async function detectBaseBranch(repoPath, currentBranch, options = {}) {
|
|
34
|
+
const deps = { ...defaults, ...options._deps };
|
|
35
|
+
|
|
36
|
+
// Guard: detached HEAD — nothing to compare
|
|
37
|
+
if (!currentBranch || currentBranch === 'HEAD') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 1. Graphite (only when enabled via config)
|
|
42
|
+
if (options.enableGraphite) {
|
|
43
|
+
const graphiteResult = tryGraphite(repoPath, currentBranch, deps);
|
|
44
|
+
if (graphiteResult) return graphiteResult;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. GitHub PR
|
|
48
|
+
const ghResult = await tryGitHubPR(repoPath, currentBranch, options.repository, deps);
|
|
49
|
+
if (ghResult) return ghResult;
|
|
50
|
+
|
|
51
|
+
// 3. Default branch
|
|
52
|
+
const defaultResult = tryDefaultBranch(repoPath, currentBranch, deps);
|
|
53
|
+
if (defaultResult) return defaultResult;
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Try Graphite CLI to find the parent branch.
|
|
60
|
+
*/
|
|
61
|
+
function tryGraphite(repoPath, currentBranch, deps) {
|
|
62
|
+
try {
|
|
63
|
+
// Check if gt is installed
|
|
64
|
+
deps.execSync('which gt', {
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
67
|
+
timeout: 3000
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Get trunk branch
|
|
71
|
+
const trunk = deps.execSync('gt trunk', {
|
|
72
|
+
cwd: repoPath,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
75
|
+
timeout: 3000
|
|
76
|
+
}).trim();
|
|
77
|
+
|
|
78
|
+
// Get parent branch
|
|
79
|
+
const parent = deps.execSync('gt parent', {
|
|
80
|
+
cwd: repoPath,
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
timeout: 3000
|
|
84
|
+
}).trim();
|
|
85
|
+
|
|
86
|
+
if (parent && parent !== currentBranch) {
|
|
87
|
+
return { baseBranch: parent, source: 'graphite' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// If parent is ourselves or empty, try trunk
|
|
91
|
+
if (trunk && trunk !== currentBranch) {
|
|
92
|
+
return { baseBranch: trunk, source: 'graphite' };
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Graphite not installed or failed — fall through silently
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Try GitHub API to find an open PR for this branch.
|
|
103
|
+
*/
|
|
104
|
+
async function tryGitHubPR(repoPath, currentBranch, repository, deps) {
|
|
105
|
+
if (!repository || !repository.includes('/')) return null;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const token = deps.getGitHubToken();
|
|
109
|
+
if (!token) return null;
|
|
110
|
+
|
|
111
|
+
const [owner, repo] = repository.split('/');
|
|
112
|
+
const client = deps.createGitHubClient(token);
|
|
113
|
+
const result = await client.findPRByBranch(owner, repo, currentBranch);
|
|
114
|
+
|
|
115
|
+
if (result) {
|
|
116
|
+
// Guard: base branch same as current branch (shouldn't happen but be safe)
|
|
117
|
+
if (result.baseBranch === currentBranch) return null;
|
|
118
|
+
return {
|
|
119
|
+
baseBranch: result.baseBranch,
|
|
120
|
+
source: 'github-pr',
|
|
121
|
+
prNumber: result.prNumber
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.warn(`GitHub PR lookup failed: ${error.message}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Try to determine the default branch from git remote or local refs.
|
|
133
|
+
*/
|
|
134
|
+
function tryDefaultBranch(repoPath, currentBranch, deps) {
|
|
135
|
+
// Try `git remote show origin`
|
|
136
|
+
try {
|
|
137
|
+
const output = deps.execSync('git remote show origin', {
|
|
138
|
+
cwd: repoPath,
|
|
139
|
+
encoding: 'utf8',
|
|
140
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
141
|
+
timeout: 5000
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const match = output.match(/HEAD branch:\s*(.+)/);
|
|
145
|
+
if (match) {
|
|
146
|
+
const branch = match[1].trim();
|
|
147
|
+
if (branch && branch !== currentBranch && branch !== '(unknown)') {
|
|
148
|
+
return { baseBranch: branch, source: 'default-branch' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// No remote or network issue — try local refs
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fallback: check if main or master exists locally
|
|
156
|
+
for (const candidate of ['main', 'master']) {
|
|
157
|
+
if (candidate === currentBranch) continue;
|
|
158
|
+
try {
|
|
159
|
+
deps.execSync(`git rev-parse --verify ${candidate}`, {
|
|
160
|
+
cwd: repoPath,
|
|
161
|
+
encoding: 'utf8',
|
|
162
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
163
|
+
});
|
|
164
|
+
return { baseBranch: candidate, source: 'default-branch' };
|
|
165
|
+
} catch {
|
|
166
|
+
// Branch doesn't exist
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { detectBaseBranch };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SHA_ABBREV_LENGTH = 7;
|
|
5
|
+
|
|
6
|
+
const defaults = {
|
|
7
|
+
execSync,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the SHA abbreviation length that Git uses for a given repository.
|
|
12
|
+
*
|
|
13
|
+
* Calls `git rev-parse --short HEAD` and measures the output length.
|
|
14
|
+
* This respects the repository's `core.abbrev` setting and Git's
|
|
15
|
+
* auto-scaling logic (larger repos get longer abbreviations).
|
|
16
|
+
*
|
|
17
|
+
* @param {string} repoPath - Absolute path to the repository
|
|
18
|
+
* @param {Object} [_deps] - Dependency overrides for testing
|
|
19
|
+
* @returns {number} The abbreviation length Git uses for this repo
|
|
20
|
+
*/
|
|
21
|
+
function getShaAbbrevLength(repoPath, _deps) {
|
|
22
|
+
const deps = { ...defaults, ..._deps };
|
|
23
|
+
try {
|
|
24
|
+
const shortSha = deps.execSync('git rev-parse --short HEAD', {
|
|
25
|
+
cwd: repoPath,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
+
}).trim();
|
|
29
|
+
return shortSha.length || DEFAULT_SHA_ABBREV_LENGTH;
|
|
30
|
+
} catch {
|
|
31
|
+
return DEFAULT_SHA_ABBREV_LENGTH;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { getShaAbbrevLength, DEFAULT_SHA_ABBREV_LENGTH };
|
package/src/git/worktree.js
CHANGED
|
@@ -536,7 +536,8 @@ class GitWorktreeManager {
|
|
|
536
536
|
// This ensures we compare the exact commits from the PR, even if the base branch has moved
|
|
537
537
|
const diff = await git.diff([
|
|
538
538
|
`${prData.base_sha}...${prData.head_sha}`,
|
|
539
|
-
'--unified=3'
|
|
539
|
+
'--unified=3',
|
|
540
|
+
'--no-ext-diff'
|
|
540
541
|
]);
|
|
541
542
|
|
|
542
543
|
return diff;
|
|
@@ -559,7 +560,7 @@ class GitWorktreeManager {
|
|
|
559
560
|
|
|
560
561
|
// Get file changes with stats using base SHA and head SHA
|
|
561
562
|
// This ensures we get the exact files changed in the PR, even if the base branch has moved
|
|
562
|
-
const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}
|
|
563
|
+
const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`, '--no-ext-diff']);
|
|
563
564
|
|
|
564
565
|
// Parse .gitattributes to identify generated files
|
|
565
566
|
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
package/src/github/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
2
|
const { Octokit } = require('@octokit/rest');
|
|
3
3
|
const logger = require('../utils/logger');
|
|
4
|
+
const { DEFAULT_SHA_ABBREV_LENGTH } = require('../git/sha-abbrev');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Custom error class for GitHub API errors that preserves the HTTP status code.
|
|
@@ -332,7 +333,7 @@ class GitHubClient {
|
|
|
332
333
|
// Extract commit_id from first comment (all comments should have the same one)
|
|
333
334
|
const commitId = comments.length > 0 ? comments[0].commit_id : null;
|
|
334
335
|
if (commitId) {
|
|
335
|
-
console.log(`Using commit_id for review: ${commitId.substring(0,
|
|
336
|
+
console.log(`Using commit_id for review: ${commitId.substring(0, DEFAULT_SHA_ABBREV_LENGTH)}`);
|
|
336
337
|
} else {
|
|
337
338
|
console.warn('No commit_id available - review may fail for lines outside diff');
|
|
338
339
|
}
|
|
@@ -1225,6 +1226,36 @@ class GitHubClient {
|
|
|
1225
1226
|
|
|
1226
1227
|
throw lastError;
|
|
1227
1228
|
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Find an open PR for the given branch name.
|
|
1232
|
+
* @param {string} owner - Repository owner
|
|
1233
|
+
* @param {string} repo - Repository name
|
|
1234
|
+
* @param {string} branch - Head branch name (without owner: prefix)
|
|
1235
|
+
* @returns {Promise<{baseBranch: string, prNumber: number}|null>} Base branch info or null
|
|
1236
|
+
*/
|
|
1237
|
+
async findPRByBranch(owner, repo, branch) {
|
|
1238
|
+
try {
|
|
1239
|
+
const { data: pulls } = await this.octokit.rest.pulls.list({
|
|
1240
|
+
owner,
|
|
1241
|
+
repo,
|
|
1242
|
+
head: `${owner}:${branch}`,
|
|
1243
|
+
state: 'open',
|
|
1244
|
+
per_page: 1
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
if (pulls.length > 0) {
|
|
1248
|
+
return {
|
|
1249
|
+
baseBranch: pulls[0].base.ref,
|
|
1250
|
+
prNumber: pulls[0].number
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
return null;
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
logger.warn(`Could not look up PR for branch ${branch}: ${error.message}`);
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1228
1259
|
}
|
|
1229
1260
|
|
|
1230
1261
|
module.exports = { GitHubClient, GitHubApiError, isComplexityError };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Hook execution engine.
|
|
4
|
+
*
|
|
5
|
+
* Spawns user-configured commands for lifecycle events, piping a JSON
|
|
6
|
+
* payload to each command's stdin. All hooks are fire-and-forget —
|
|
7
|
+
* failures are logged but never block the caller.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const logger = require('../utils/logger');
|
|
12
|
+
|
|
13
|
+
const HOOK_TIMEOUT_MS = 5000;
|
|
14
|
+
|
|
15
|
+
const defaults = {
|
|
16
|
+
spawn,
|
|
17
|
+
logger,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fire all hooks registered for `eventName`.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} eventName - e.g. 'review.started', 'analysis.completed'
|
|
24
|
+
* @param {Object} payload - JSON-serialisable event data
|
|
25
|
+
* @param {Object} config - app config (must contain `hooks` key)
|
|
26
|
+
* @param {Object} [_deps] - dependency overrides (testing)
|
|
27
|
+
*/
|
|
28
|
+
function fireHooks(eventName, payload, config, _deps) {
|
|
29
|
+
const deps = { ...defaults, ..._deps };
|
|
30
|
+
const hookMap = config?.hooks?.[eventName];
|
|
31
|
+
if (!hookMap || typeof hookMap !== 'object') return;
|
|
32
|
+
|
|
33
|
+
const json = JSON.stringify(payload);
|
|
34
|
+
|
|
35
|
+
for (const [name, hook] of Object.entries(hookMap)) {
|
|
36
|
+
if (!hook?.command) continue;
|
|
37
|
+
spawnHook(name, hook.command, json, deps);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Spawn a single hook command, pipe `json` to its stdin, and enforce a timeout.
|
|
43
|
+
*/
|
|
44
|
+
function spawnHook(name, command, json, deps) {
|
|
45
|
+
const label = `${name} (${command})`;
|
|
46
|
+
try {
|
|
47
|
+
const child = deps.spawn(command, [], {
|
|
48
|
+
shell: true,
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
deps.logger.warn(`Hook timed out after ${HOOK_TIMEOUT_MS}ms, killing: ${label}`);
|
|
54
|
+
child.kill('SIGTERM');
|
|
55
|
+
}, HOOK_TIMEOUT_MS);
|
|
56
|
+
|
|
57
|
+
child.on('close', () => clearTimeout(timer));
|
|
58
|
+
|
|
59
|
+
child.on('error', (err) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
deps.logger.warn(`Hook error (${label}): ${err.message}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (child.stdout) {
|
|
65
|
+
child.stdout.on('data', (data) => {
|
|
66
|
+
deps.logger.debug(`Hook stdout [${name}]: ${data.toString().trimEnd()}`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (child.stderr) {
|
|
71
|
+
child.stderr.on('data', (data) => {
|
|
72
|
+
deps.logger.warn(`Hook stderr [${name}]: ${data.toString().trimEnd()}`);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
child.stdin.on('error', (err) => {
|
|
77
|
+
deps.logger.warn(`Hook stdin error (${label}): ${err.message}`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
child.stdin.write(json);
|
|
81
|
+
child.stdin.end();
|
|
82
|
+
} catch (err) {
|
|
83
|
+
deps.logger.warn(`Hook spawn failed (${label}): ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check whether any hooks are registered for `eventName`.
|
|
89
|
+
* Use this to skip expensive async work (e.g. getCachedUser) when no hooks exist.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} eventName - e.g. 'chat.started'
|
|
92
|
+
* @param {Object} config - app config
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function hasHooks(eventName, config) {
|
|
96
|
+
const hookMap = config?.hooks?.[eventName];
|
|
97
|
+
return hookMap && typeof hookMap === 'object' && Object.keys(hookMap).length > 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { fireHooks, hasHooks };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Hook payload builders.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions that assemble event-specific JSON payloads from
|
|
6
|
+
* route-level data. Keeps integration-point changes minimal.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { version } = require('../../package.json');
|
|
10
|
+
const logger = require('../utils/logger');
|
|
11
|
+
const { getGitHubToken } = require('../config');
|
|
12
|
+
|
|
13
|
+
const defaultDeps = {
|
|
14
|
+
GitHubClient: null, // lazy-loaded to avoid circular deps
|
|
15
|
+
getGitHubToken,
|
|
16
|
+
logger,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Module-level user cache (one GitHub API call per server session)
|
|
20
|
+
let cachedUser = undefined; // undefined = not yet resolved, null = no token / failed
|
|
21
|
+
|
|
22
|
+
// ── Shared context builder ──────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function buildContextFields({ mode, prContext, localContext, user }) {
|
|
25
|
+
const fields = { mode };
|
|
26
|
+
if (user) fields.user = user;
|
|
27
|
+
if (mode === 'pr' && prContext) {
|
|
28
|
+
fields.pr = { ...prContext };
|
|
29
|
+
} else if (mode === 'local' && localContext) {
|
|
30
|
+
fields.local = { ...localContext };
|
|
31
|
+
}
|
|
32
|
+
return fields;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Review payloads ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function buildReviewPayload(event, { reviewId, mode, prContext, localContext, user }) {
|
|
38
|
+
return {
|
|
39
|
+
event,
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
version,
|
|
42
|
+
reviewId,
|
|
43
|
+
...buildContextFields({ mode, prContext, localContext, user }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildReviewStartedPayload(opts) {
|
|
48
|
+
return buildReviewPayload('review.started', opts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildReviewLoadedPayload(opts) {
|
|
52
|
+
return buildReviewPayload('review.loaded', opts);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Analysis payloads ───────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function buildAnalysisStartedPayload({ reviewId, analysisId, provider, model, mode, prContext, localContext, user }) {
|
|
58
|
+
return {
|
|
59
|
+
event: 'analysis.started',
|
|
60
|
+
timestamp: new Date().toISOString(),
|
|
61
|
+
version,
|
|
62
|
+
reviewId,
|
|
63
|
+
analysisId,
|
|
64
|
+
provider: provider ?? null,
|
|
65
|
+
model: model ?? null,
|
|
66
|
+
...buildContextFields({ mode, prContext, localContext, user }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildAnalysisCompletedPayload({
|
|
71
|
+
reviewId, analysisId, provider, model, status,
|
|
72
|
+
totalSuggestions, mode, prContext, localContext, user,
|
|
73
|
+
}) {
|
|
74
|
+
return {
|
|
75
|
+
event: 'analysis.completed',
|
|
76
|
+
timestamp: new Date().toISOString(),
|
|
77
|
+
version,
|
|
78
|
+
reviewId,
|
|
79
|
+
analysisId,
|
|
80
|
+
provider: provider ?? null,
|
|
81
|
+
model: model ?? null,
|
|
82
|
+
status,
|
|
83
|
+
totalSuggestions: totalSuggestions ?? 0,
|
|
84
|
+
...buildContextFields({ mode, prContext, localContext, user }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Chat payloads ───────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function buildChatPayload(event, { reviewId, sessionId, provider, model, mode, prContext, localContext, user }) {
|
|
91
|
+
return {
|
|
92
|
+
event,
|
|
93
|
+
timestamp: new Date().toISOString(),
|
|
94
|
+
version,
|
|
95
|
+
reviewId,
|
|
96
|
+
sessionId,
|
|
97
|
+
provider: provider ?? null,
|
|
98
|
+
model: model ?? null,
|
|
99
|
+
...buildContextFields({ mode, prContext, localContext, user }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildChatStartedPayload(opts) {
|
|
104
|
+
return buildChatPayload('chat.started', opts);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildChatResumedPayload(opts) {
|
|
108
|
+
return buildChatPayload('chat.resumed', opts);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Derive hook context fields (mode, prContext/localContext) from a review record.
|
|
113
|
+
* @param {Object} review - Review record from the database
|
|
114
|
+
* @returns {{ mode: string, prContext?: Object, localContext?: Object }}
|
|
115
|
+
*/
|
|
116
|
+
function buildChatHookContext(review) {
|
|
117
|
+
if (review.review_type === 'local') {
|
|
118
|
+
return {
|
|
119
|
+
mode: 'local',
|
|
120
|
+
localContext: {
|
|
121
|
+
path: review.local_path ?? null,
|
|
122
|
+
branch: review.local_head_branch ?? null,
|
|
123
|
+
headSha: review.local_head_sha ?? null,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const [owner, repo] = (review.repository || '').split('/');
|
|
128
|
+
return {
|
|
129
|
+
mode: 'pr',
|
|
130
|
+
prContext: {
|
|
131
|
+
number: review.pr_number ?? null,
|
|
132
|
+
owner: owner || null,
|
|
133
|
+
repo: repo || null,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── User identity ───────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the current GitHub user, caching the result for the server session.
|
|
142
|
+
* Returns `{ login }` or `null` if no token / lookup fails.
|
|
143
|
+
*/
|
|
144
|
+
async function getCachedUser(config, _deps) {
|
|
145
|
+
if (cachedUser !== undefined) return cachedUser;
|
|
146
|
+
|
|
147
|
+
const deps = { ...defaultDeps, ..._deps };
|
|
148
|
+
|
|
149
|
+
const token = deps.getGitHubToken(config || {});
|
|
150
|
+
if (!token) {
|
|
151
|
+
cachedUser = null;
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
// Lazy-load to avoid circular dependency at require time
|
|
157
|
+
const GHClient = deps.GitHubClient || require('../github/client').GitHubClient;
|
|
158
|
+
const client = new GHClient(token);
|
|
159
|
+
const user = await client.getAuthenticatedUser();
|
|
160
|
+
cachedUser = { login: user.login };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
deps.logger.warn(`Failed to resolve GitHub user for hooks: ${err.message}`);
|
|
163
|
+
cachedUser = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return cachedUser;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _resetUserCache() {
|
|
170
|
+
cachedUser = undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Convenience: fire review.started in one call ────────────────
|
|
174
|
+
|
|
175
|
+
const defaultFireDeps = {
|
|
176
|
+
fireHooks: null, // lazy-loaded to avoid circular deps
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build and fire a `review.started` hook for a PR review.
|
|
181
|
+
*
|
|
182
|
+
* Encapsulates the full sequence: build prContext, resolve the GitHub
|
|
183
|
+
* user, assemble the payload, and fire. Callers should use `.catch()`.
|
|
184
|
+
*/
|
|
185
|
+
async function fireReviewStartedHook({ reviewId, prNumber, owner, repo, prData, config }, _deps) {
|
|
186
|
+
const deps = { ...defaultFireDeps, ..._deps };
|
|
187
|
+
const { hasHooks } = require('./hook-runner');
|
|
188
|
+
if (!hasHooks('review.started', config)) return;
|
|
189
|
+
|
|
190
|
+
const prContext = {
|
|
191
|
+
number: prNumber, owner, repo,
|
|
192
|
+
author: prData.author, baseBranch: prData.base_branch, headBranch: prData.head_branch,
|
|
193
|
+
baseSha: prData.base_sha || null, headSha: prData.head_sha || null,
|
|
194
|
+
};
|
|
195
|
+
const user = await getCachedUser(config);
|
|
196
|
+
const payload = buildReviewStartedPayload({ reviewId, mode: 'pr', prContext, user });
|
|
197
|
+
const fire = deps.fireHooks || require('./hook-runner').fireHooks;
|
|
198
|
+
fire('review.started', payload, config);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
buildReviewStartedPayload,
|
|
203
|
+
buildReviewLoadedPayload,
|
|
204
|
+
buildAnalysisStartedPayload,
|
|
205
|
+
buildAnalysisCompletedPayload,
|
|
206
|
+
buildChatStartedPayload,
|
|
207
|
+
buildChatResumedPayload,
|
|
208
|
+
buildChatHookContext,
|
|
209
|
+
getCachedUser,
|
|
210
|
+
fireReviewStartedHook,
|
|
211
|
+
_resetUserCache,
|
|
212
|
+
};
|