@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.
Files changed (50) hide show
  1. package/bin/git-diff-lines +1 -1
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  6. package/public/css/pr.css +201 -0
  7. package/public/index.html +168 -3
  8. package/public/js/components/AIPanel.js +16 -2
  9. package/public/js/components/ChatPanel.js +41 -6
  10. package/public/js/components/ConfirmDialog.js +21 -2
  11. package/public/js/components/CouncilProgressModal.js +13 -0
  12. package/public/js/components/DiffOptionsDropdown.js +410 -23
  13. package/public/js/components/SuggestionNavigator.js +12 -5
  14. package/public/js/components/TabTitle.js +96 -0
  15. package/public/js/components/Toast.js +6 -0
  16. package/public/js/index.js +648 -43
  17. package/public/js/local.js +569 -76
  18. package/public/js/modules/analysis-history.js +3 -2
  19. package/public/js/modules/comment-manager.js +5 -0
  20. package/public/js/modules/comment-minimizer.js +304 -0
  21. package/public/js/pr.js +82 -6
  22. package/public/local.html +14 -0
  23. package/public/pr.html +3 -0
  24. package/src/ai/analyzer.js +22 -16
  25. package/src/ai/cursor-agent-provider.js +21 -12
  26. package/src/chat/prompt-builder.js +3 -3
  27. package/src/config.js +2 -0
  28. package/src/database.js +590 -39
  29. package/src/git/base-branch.js +173 -0
  30. package/src/git/sha-abbrev.js +35 -0
  31. package/src/git/worktree.js +3 -2
  32. package/src/github/client.js +32 -1
  33. package/src/hooks/hook-runner.js +100 -0
  34. package/src/hooks/payloads.js +212 -0
  35. package/src/local-review.js +468 -129
  36. package/src/local-scope.js +58 -0
  37. package/src/main.js +57 -6
  38. package/src/routes/analyses.js +73 -10
  39. package/src/routes/chat.js +33 -0
  40. package/src/routes/config.js +1 -0
  41. package/src/routes/github-collections.js +2 -2
  42. package/src/routes/local.js +734 -68
  43. package/src/routes/mcp.js +20 -10
  44. package/src/routes/pr.js +92 -14
  45. package/src/routes/setup.js +1 -0
  46. package/src/routes/worktrees.js +212 -148
  47. package/src/server.js +30 -0
  48. package/src/setup/local-setup.js +46 -5
  49. package/src/setup/pr-setup.js +28 -5
  50. 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 };
@@ -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);
@@ -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, 7)}`);
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
+ };