threadlines 0.2.25 → 0.4.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/README.md +87 -42
- package/dist/api/client.js +49 -12
- package/dist/commands/check.js +147 -105
- package/dist/commands/init.js +32 -23
- package/dist/git/ci-context.js +6 -8
- package/dist/git/diff.js +60 -25
- package/dist/git/local.js +195 -42
- package/dist/llm/prompt-builder.js +72 -0
- package/dist/processors/expert.js +120 -0
- package/dist/processors/single-expert.js +253 -0
- package/dist/utils/config-file.js +27 -17
- package/dist/utils/config.js +20 -14
- package/dist/utils/diff-filter.js +105 -0
- package/dist/utils/logger.js +13 -6
- package/dist/utils/slim-diff.js +133 -0
- package/package.json +2 -4
package/dist/git/diff.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.getRepoUrl = getRepoUrl;
|
|
7
4
|
exports.getHeadCommitSha = getHeadCommitSha;
|
|
@@ -9,7 +6,6 @@ exports.getCommitMessage = getCommitMessage;
|
|
|
9
6
|
exports.getCommitAuthor = getCommitAuthor;
|
|
10
7
|
exports.getPRDiff = getPRDiff;
|
|
11
8
|
exports.getCommitDiff = getCommitDiff;
|
|
12
|
-
const simple_git_1 = __importDefault(require("simple-git"));
|
|
13
9
|
const child_process_1 = require("child_process");
|
|
14
10
|
const logger_1 = require("../utils/logger");
|
|
15
11
|
// =============================================================================
|
|
@@ -88,18 +84,33 @@ async function getHeadCommitSha(repoRoot) {
|
|
|
88
84
|
}
|
|
89
85
|
/**
|
|
90
86
|
* Get commit message for a specific commit SHA
|
|
91
|
-
*
|
|
87
|
+
*
|
|
88
|
+
* Fails loudly if commit cannot be retrieved (commit not found, git error, etc.).
|
|
89
|
+
* This function is only called when a commit is expected to exist:
|
|
90
|
+
* - In CI environments (always has HEAD commit)
|
|
91
|
+
* - In local environment with --commit flag (user explicitly provided SHA)
|
|
92
|
+
*
|
|
93
|
+
* @param repoRoot - Path to the repository root
|
|
94
|
+
* @param sha - Commit SHA to get message for
|
|
95
|
+
* @returns Full commit message (subject + body)
|
|
96
|
+
* @throws Error if commit cannot be retrieved
|
|
92
97
|
*/
|
|
93
98
|
async function getCommitMessage(repoRoot, sha) {
|
|
94
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
95
99
|
try {
|
|
96
100
|
// Get full commit message (subject + body)
|
|
97
|
-
const message =
|
|
98
|
-
|
|
101
|
+
const message = (0, child_process_1.execSync)(`git show --format=%B --no-patch ${sha}`, {
|
|
102
|
+
encoding: 'utf-8',
|
|
103
|
+
cwd: repoRoot
|
|
104
|
+
}).trim();
|
|
105
|
+
if (!message) {
|
|
106
|
+
throw new Error(`Commit ${sha} exists but has no message`);
|
|
107
|
+
}
|
|
108
|
+
return message;
|
|
99
109
|
}
|
|
100
|
-
catch {
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
catch (error) {
|
|
111
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
112
|
+
throw new Error(`Failed to get commit message for ${sha}: ${errorMessage}\n` +
|
|
113
|
+
`This commit should exist (called from CI or with --commit flag).`);
|
|
103
114
|
}
|
|
104
115
|
}
|
|
105
116
|
/**
|
|
@@ -180,16 +191,19 @@ async function getCommitAuthor(repoRoot, sha) {
|
|
|
180
191
|
* @param logger - Optional logger for debug output
|
|
181
192
|
*/
|
|
182
193
|
async function getPRDiff(repoRoot, targetBranch, logger) {
|
|
183
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
184
194
|
// Fetch target branch on-demand (works with shallow clones)
|
|
185
195
|
logger?.debug(`Fetching target branch: origin/${targetBranch}`);
|
|
186
196
|
try {
|
|
187
|
-
|
|
197
|
+
(0, child_process_1.execSync)(`git fetch origin ${targetBranch}:refs/remotes/origin/${targetBranch} --depth=1`, {
|
|
198
|
+
cwd: repoRoot,
|
|
199
|
+
stdio: 'pipe' // Suppress fetch output
|
|
200
|
+
});
|
|
188
201
|
}
|
|
189
202
|
catch (fetchError) {
|
|
203
|
+
const errorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError);
|
|
190
204
|
throw new Error(`Failed to fetch target branch origin/${targetBranch}. ` +
|
|
191
205
|
`This is required for PR/MR diff comparison. ` +
|
|
192
|
-
`Error: ${
|
|
206
|
+
`Error: ${errorMessage}`);
|
|
193
207
|
}
|
|
194
208
|
// Try three dots (merge base) first - shows only developer's changes
|
|
195
209
|
// Falls back to two dots (direct comparison) if shallow clone prevents merge base calculation
|
|
@@ -200,9 +214,16 @@ async function getPRDiff(repoRoot, targetBranch, logger) {
|
|
|
200
214
|
// This isolates developer changes by comparing against merge base
|
|
201
215
|
// Works when we have enough history (full clones or GitHub's merge commits)
|
|
202
216
|
logger?.debug(`Attempting three-dots diff (merge base): origin/${targetBranch}...HEAD`);
|
|
203
|
-
diff =
|
|
204
|
-
|
|
205
|
-
|
|
217
|
+
diff = (0, child_process_1.execSync)(`git diff origin/${targetBranch}...HEAD -U200`, {
|
|
218
|
+
encoding: 'utf-8',
|
|
219
|
+
cwd: repoRoot
|
|
220
|
+
});
|
|
221
|
+
// Get changed files using git diff --name-only
|
|
222
|
+
const changedFilesOutput = (0, child_process_1.execSync)(`git diff --name-only origin/${targetBranch}...HEAD`, {
|
|
223
|
+
encoding: 'utf-8',
|
|
224
|
+
cwd: repoRoot
|
|
225
|
+
}).trim();
|
|
226
|
+
changedFiles = changedFilesOutput ? changedFilesOutput.split('\n') : [];
|
|
206
227
|
}
|
|
207
228
|
catch (error) {
|
|
208
229
|
// Step 2: Fallback to "Risky" Diff (Two Dots)
|
|
@@ -215,9 +236,15 @@ async function getPRDiff(repoRoot, targetBranch, logger) {
|
|
|
215
236
|
logger?.debug(`Fallback error: ${errorMessage}`);
|
|
216
237
|
// Use two dots (direct comparison) - shows all differences between tips
|
|
217
238
|
logger?.debug(`Using two-dots diff (direct comparison): origin/${targetBranch}..HEAD`);
|
|
218
|
-
diff =
|
|
219
|
-
|
|
220
|
-
|
|
239
|
+
diff = (0, child_process_1.execSync)(`git diff origin/${targetBranch}..HEAD -U200`, {
|
|
240
|
+
encoding: 'utf-8',
|
|
241
|
+
cwd: repoRoot
|
|
242
|
+
});
|
|
243
|
+
const changedFilesOutput = (0, child_process_1.execSync)(`git diff --name-only origin/${targetBranch}..HEAD`, {
|
|
244
|
+
encoding: 'utf-8',
|
|
245
|
+
cwd: repoRoot
|
|
246
|
+
}).trim();
|
|
247
|
+
changedFiles = changedFilesOutput ? changedFilesOutput.split('\n') : [];
|
|
221
248
|
}
|
|
222
249
|
return {
|
|
223
250
|
diff: diff || '',
|
|
@@ -250,7 +277,6 @@ async function getPRDiff(repoRoot, targetBranch, logger) {
|
|
|
250
277
|
* @param sha - Commit SHA to get diff for (defaults to HEAD)
|
|
251
278
|
*/
|
|
252
279
|
async function getCommitDiff(repoRoot, sha = 'HEAD') {
|
|
253
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
254
280
|
// Fetch parent commit on-demand to ensure git show can generate a proper diff
|
|
255
281
|
// This works regardless of CI checkout depth settings (depth=1 or depth=2)
|
|
256
282
|
// If parent is already available, fetch is fast/no-op; if not, we fetch it
|
|
@@ -295,7 +321,10 @@ async function getCommitDiff(repoRoot, sha = 'HEAD') {
|
|
|
295
321
|
// If we get here, parentSha is guaranteed to be a valid 40-character SHA
|
|
296
322
|
try {
|
|
297
323
|
// Fetch just this one commit (depth=1 is fine, we only need the parent)
|
|
298
|
-
|
|
324
|
+
(0, child_process_1.execSync)(`git fetch origin ${parentSha} --depth=1`, {
|
|
325
|
+
cwd: repoRoot,
|
|
326
|
+
stdio: 'pipe' // Suppress fetch output
|
|
327
|
+
});
|
|
299
328
|
}
|
|
300
329
|
catch (error) {
|
|
301
330
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -312,10 +341,16 @@ async function getCommitDiff(repoRoot, sha = 'HEAD') {
|
|
|
312
341
|
let changedFiles;
|
|
313
342
|
try {
|
|
314
343
|
// Use git diff to compare parent against HEAD (plumbing command, ignores shallow boundaries)
|
|
315
|
-
diff =
|
|
344
|
+
diff = (0, child_process_1.execSync)(`git diff ${parentSha}..${sha} -U200`, {
|
|
345
|
+
encoding: 'utf-8',
|
|
346
|
+
cwd: repoRoot
|
|
347
|
+
});
|
|
316
348
|
// Get changed files using git diff --name-only
|
|
317
|
-
const
|
|
318
|
-
|
|
349
|
+
const changedFilesOutput = (0, child_process_1.execSync)(`git diff --name-only ${parentSha}..${sha}`, {
|
|
350
|
+
encoding: 'utf-8',
|
|
351
|
+
cwd: repoRoot
|
|
352
|
+
}).trim();
|
|
353
|
+
changedFiles = changedFilesOutput ? changedFilesOutput.split('\n') : [];
|
|
319
354
|
}
|
|
320
355
|
catch (error) {
|
|
321
356
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
package/dist/git/local.js
CHANGED
|
@@ -11,21 +11,55 @@
|
|
|
11
11
|
* - branchName: string
|
|
12
12
|
* - commitAuthor: { name: string; email: string }
|
|
13
13
|
*/
|
|
14
|
-
var
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
17
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
48
|
exports.getLocalContext = getLocalContext;
|
|
19
|
-
const
|
|
49
|
+
const child_process_1 = require("child_process");
|
|
50
|
+
const fs = __importStar(require("fs"));
|
|
51
|
+
const path = __importStar(require("path"));
|
|
20
52
|
const diff_1 = require("./diff");
|
|
53
|
+
const logger_1 = require("../utils/logger");
|
|
21
54
|
/**
|
|
22
55
|
* Gets all Local context
|
|
23
56
|
*/
|
|
24
57
|
async function getLocalContext(repoRoot, commitSha) {
|
|
25
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
26
58
|
// Check if we're in a git repo
|
|
27
|
-
|
|
28
|
-
|
|
59
|
+
try {
|
|
60
|
+
(0, child_process_1.execSync)('git rev-parse --git-dir', { cwd: repoRoot, stdio: 'ignore' });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
29
63
|
throw new Error('Not a git repository. Threadline requires a git repository.');
|
|
30
64
|
}
|
|
31
65
|
// Get all Local context
|
|
@@ -37,13 +71,10 @@ async function getLocalContext(repoRoot, commitSha) {
|
|
|
37
71
|
const commitAuthor = commitSha
|
|
38
72
|
? await getCommitAuthorFromGit(repoRoot, commitSha)
|
|
39
73
|
: await getCommitAuthorFromConfig(repoRoot);
|
|
40
|
-
// Get commit message if we have a SHA
|
|
74
|
+
// Get commit message if we have a SHA (fails loudly if commit doesn't exist)
|
|
41
75
|
let commitMessage;
|
|
42
76
|
if (commitSha) {
|
|
43
|
-
|
|
44
|
-
if (message) {
|
|
45
|
-
commitMessage = message;
|
|
46
|
-
}
|
|
77
|
+
commitMessage = await (0, diff_1.getCommitMessage)(repoRoot, commitSha);
|
|
47
78
|
}
|
|
48
79
|
return {
|
|
49
80
|
diff,
|
|
@@ -64,45 +95,162 @@ async function getLocalContext(repoRoot, commitSha) {
|
|
|
64
95
|
* or review unstaged changes if nothing is staged.
|
|
65
96
|
*/
|
|
66
97
|
async function getDiff(repoRoot) {
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
98
|
+
// Get git status in porcelain format to determine what changes exist
|
|
99
|
+
// Porcelain format: XY filename
|
|
100
|
+
// X = staged status, Y = unstaged status
|
|
101
|
+
// ' ' = no change, 'M' = modified, 'A' = added, 'D' = deleted, etc.
|
|
102
|
+
// '?' = untracked (only in Y position, X is always '?' too)
|
|
103
|
+
const statusOutput = (0, child_process_1.execSync)('git status --porcelain', {
|
|
104
|
+
encoding: 'utf-8',
|
|
105
|
+
cwd: repoRoot
|
|
106
|
+
}).trim();
|
|
107
|
+
const lines = statusOutput ? statusOutput.split('\n') : [];
|
|
108
|
+
const staged = [];
|
|
109
|
+
const unstaged = [];
|
|
110
|
+
const untracked = [];
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const stagedStatus = line[0];
|
|
113
|
+
const unstagedStatus = line[1];
|
|
114
|
+
// Collect untracked files separately (they need special handling)
|
|
115
|
+
if (stagedStatus === '?' && unstagedStatus === '?') {
|
|
116
|
+
// Format: "?? filename" - skip 3 characters
|
|
117
|
+
const file = line.slice(3);
|
|
118
|
+
untracked.push(file);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// For tracked files, the format can be:
|
|
122
|
+
// - "M filename" (staged, no leading space) - skip 2 characters
|
|
123
|
+
// - " M filename" (unstaged, leading space) - skip 3 characters
|
|
124
|
+
// - "MM filename" (both staged and unstaged) - skip 3 characters
|
|
125
|
+
let file;
|
|
126
|
+
if (stagedStatus !== ' ' && unstagedStatus === ' ') {
|
|
127
|
+
// Staged only: "M filename" - skip 2 characters (M + space)
|
|
128
|
+
file = line.slice(2);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Unstaged or both: " M filename" or "MM filename" - skip 3 characters
|
|
132
|
+
file = line.slice(3);
|
|
133
|
+
}
|
|
134
|
+
if (stagedStatus !== ' ') {
|
|
135
|
+
staged.push(file);
|
|
136
|
+
}
|
|
137
|
+
if (unstagedStatus !== ' ' && unstagedStatus !== '?') {
|
|
138
|
+
unstaged.push(file);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
70
141
|
let diff;
|
|
71
142
|
let changedFiles;
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
143
|
+
// Check if there are actually staged files (use git diff as source of truth)
|
|
144
|
+
// git status parsing can be inconsistent, so we verify with git diff
|
|
145
|
+
const stagedFilesOutput = (0, child_process_1.execSync)('git diff --cached --name-only', {
|
|
146
|
+
encoding: 'utf-8',
|
|
147
|
+
cwd: repoRoot
|
|
148
|
+
}).trim();
|
|
149
|
+
const actualStagedFiles = stagedFilesOutput ? stagedFilesOutput.split('\n') : [];
|
|
150
|
+
// Workflow A: Developer has staged files - check ONLY staged files
|
|
151
|
+
// (Ignore unstaged and untracked - developer explicitly chose to check staged)
|
|
152
|
+
if (actualStagedFiles.length > 0) {
|
|
153
|
+
diff = (0, child_process_1.execSync)('git diff --cached -U200', {
|
|
154
|
+
encoding: 'utf-8',
|
|
155
|
+
cwd: repoRoot
|
|
156
|
+
});
|
|
157
|
+
changedFiles = actualStagedFiles;
|
|
158
|
+
// If staged files exist but diff is empty, something is wrong
|
|
159
|
+
if (!diff || diff.trim() === '') {
|
|
160
|
+
throw new Error(`Staged files exist but diff is empty. ` +
|
|
161
|
+
`This may indicate binary files, whitespace-only changes, or a git issue. ` +
|
|
162
|
+
`Staged files: ${actualStagedFiles.join(', ')}`);
|
|
163
|
+
}
|
|
164
|
+
logger_1.logger.info(`Checking STAGED changes (${changedFiles.length} file(s))`);
|
|
165
|
+
return {
|
|
166
|
+
diff: diff || '',
|
|
167
|
+
changedFiles
|
|
168
|
+
};
|
|
77
169
|
}
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.filter(f => f.working_dir !== ' ' || f.index !== ' ')
|
|
83
|
-
.map(f => f.path);
|
|
170
|
+
// No staged files - log clearly and continue to unstaged/untracked
|
|
171
|
+
if (staged.length > 0) {
|
|
172
|
+
// git status showed staged files but git diff doesn't - they were likely unstaged
|
|
173
|
+
logger_1.logger.info(`No staged files detected (files may have been unstaged), checking unstaged/untracked files instead.`);
|
|
84
174
|
}
|
|
85
|
-
// No changes at all
|
|
86
175
|
else {
|
|
176
|
+
logger_1.logger.info(`No staged files, checking unstaged/untracked files.`);
|
|
177
|
+
}
|
|
178
|
+
// Workflow B: Developer hasn't staged files - check unstaged + untracked files
|
|
179
|
+
// (Untracked files are conceptually "unstaged" - files being worked on but not committed)
|
|
180
|
+
if (unstaged.length > 0 || untracked.length > 0) {
|
|
181
|
+
// Get unstaged diff if there are unstaged files
|
|
182
|
+
if (unstaged.length > 0) {
|
|
183
|
+
diff = (0, child_process_1.execSync)('git diff -U200', {
|
|
184
|
+
encoding: 'utf-8',
|
|
185
|
+
cwd: repoRoot
|
|
186
|
+
});
|
|
187
|
+
const changedFilesOutput = (0, child_process_1.execSync)('git diff --name-only', {
|
|
188
|
+
encoding: 'utf-8',
|
|
189
|
+
cwd: repoRoot
|
|
190
|
+
}).trim();
|
|
191
|
+
changedFiles = changedFilesOutput ? changedFilesOutput.split('\n') : [];
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
diff = '';
|
|
195
|
+
changedFiles = [];
|
|
196
|
+
}
|
|
197
|
+
// Handle untracked files: read their content and create artificial diffs
|
|
198
|
+
// Fails loudly if any untracked file cannot be read (permissions, filesystem errors, etc.)
|
|
199
|
+
const untrackedDiffs = [];
|
|
200
|
+
const untrackedFileList = [];
|
|
201
|
+
for (const file of untracked) {
|
|
202
|
+
const fullPath = path.resolve(repoRoot, file);
|
|
203
|
+
// Skip if it's a directory (git status can show directories)
|
|
204
|
+
const stats = fs.statSync(fullPath);
|
|
205
|
+
if (!stats.isFile()) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Read file content - fails loudly on any error (permissions, encoding, etc.)
|
|
209
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
210
|
+
// Normalize path to forward slashes for cross-platform consistency
|
|
211
|
+
const normalizedPath = file.replace(/\\/g, '/');
|
|
212
|
+
// Create artificial diff (all lines as additions, similar to getFileContent)
|
|
213
|
+
const lines = content.split('\n');
|
|
214
|
+
const fileDiff = lines.map((line) => `+${line}`).join('\n');
|
|
215
|
+
// Add git diff header (matches format expected by server's filterDiffByFiles)
|
|
216
|
+
const diffHeader = `diff --git a/${normalizedPath} b/${normalizedPath}\n--- /dev/null\n+++ b/${normalizedPath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
217
|
+
untrackedDiffs.push(diffHeader + fileDiff);
|
|
218
|
+
untrackedFileList.push(normalizedPath);
|
|
219
|
+
}
|
|
220
|
+
// Combine unstaged changes with untracked files
|
|
221
|
+
const combinedDiff = untrackedDiffs.length > 0
|
|
222
|
+
? (diff ? diff + '\n' : '') + untrackedDiffs.join('\n')
|
|
223
|
+
: diff;
|
|
224
|
+
const allChangedFiles = [...changedFiles, ...untrackedFileList];
|
|
225
|
+
const unstagedCount = changedFiles.length;
|
|
226
|
+
const untrackedCount = untrackedFileList.length;
|
|
227
|
+
if (unstagedCount > 0 && untrackedCount > 0) {
|
|
228
|
+
logger_1.logger.info(`Checking UNSTAGED changes (${unstagedCount} file(s)) + ${untrackedCount} untracked file(s)`);
|
|
229
|
+
}
|
|
230
|
+
else if (unstagedCount > 0) {
|
|
231
|
+
logger_1.logger.info(`Checking UNSTAGED changes (${unstagedCount} file(s))`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
logger_1.logger.info(`Checking UNTRACKED files (${untrackedCount} file(s))`);
|
|
235
|
+
}
|
|
87
236
|
return {
|
|
88
|
-
diff: '',
|
|
89
|
-
changedFiles:
|
|
237
|
+
diff: combinedDiff || '',
|
|
238
|
+
changedFiles: allChangedFiles
|
|
90
239
|
};
|
|
91
240
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
changedFiles
|
|
95
|
-
};
|
|
241
|
+
// No changes at all - fail loudly
|
|
242
|
+
throw new Error('No changes detected. Stage files with "git add" or modify files to run threadlines.');
|
|
96
243
|
}
|
|
97
244
|
/**
|
|
98
245
|
* Gets branch name for local environment
|
|
99
246
|
* (Uses git command directly - works in local because not in detached HEAD state)
|
|
100
247
|
*/
|
|
101
248
|
async function getBranchName(repoRoot) {
|
|
102
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
103
249
|
try {
|
|
104
|
-
const
|
|
105
|
-
|
|
250
|
+
const currentBranch = (0, child_process_1.execSync)('git branch --show-current', {
|
|
251
|
+
encoding: 'utf-8',
|
|
252
|
+
cwd: repoRoot
|
|
253
|
+
}).trim();
|
|
106
254
|
if (!currentBranch) {
|
|
107
255
|
throw new Error('Could not determine current branch. Are you in a git repository?');
|
|
108
256
|
}
|
|
@@ -120,17 +268,22 @@ async function getBranchName(repoRoot) {
|
|
|
120
268
|
* No fallbacks - if git config is not set or fails, throws an error.
|
|
121
269
|
*/
|
|
122
270
|
async function getCommitAuthorFromConfig(repoRoot) {
|
|
123
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
124
271
|
try {
|
|
125
|
-
const name =
|
|
126
|
-
|
|
127
|
-
|
|
272
|
+
const name = (0, child_process_1.execSync)('git config --get user.name', {
|
|
273
|
+
encoding: 'utf-8',
|
|
274
|
+
cwd: repoRoot
|
|
275
|
+
}).trim();
|
|
276
|
+
const email = (0, child_process_1.execSync)('git config --get user.email', {
|
|
277
|
+
encoding: 'utf-8',
|
|
278
|
+
cwd: repoRoot
|
|
279
|
+
}).trim();
|
|
280
|
+
if (!name || !email) {
|
|
128
281
|
throw new Error('Git config user.name or user.email is not set. ' +
|
|
129
282
|
'Run: git config user.name "Your Name" && git config user.email "your.email@example.com"');
|
|
130
283
|
}
|
|
131
284
|
return {
|
|
132
|
-
name: name
|
|
133
|
-
email: email
|
|
285
|
+
name: name,
|
|
286
|
+
email: email
|
|
134
287
|
};
|
|
135
288
|
}
|
|
136
289
|
catch (error) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Prompt Builder for LLM Threadline Checks
|
|
4
|
+
*
|
|
5
|
+
* Builds prompts for OpenAI API calls to check code changes against threadline guidelines.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.buildPrompt = buildPrompt;
|
|
9
|
+
function buildPrompt(threadline, diff, matchingFiles) {
|
|
10
|
+
// Build context files section if available
|
|
11
|
+
const contextFilesSection = threadline.contextContent && Object.keys(threadline.contextContent).length > 0
|
|
12
|
+
? `Context Files:\n${Object.entries(threadline.contextContent)
|
|
13
|
+
.map(([file, content]) => `\n--- ${file} ---\n${content}`)
|
|
14
|
+
.join('\n')}\n\n`
|
|
15
|
+
: '';
|
|
16
|
+
return `You are a code quality checker focused EXCLUSIVELY on: ${threadline.id}
|
|
17
|
+
|
|
18
|
+
CRITICAL: You must ONLY check for violations of THIS SPECIFIC threadline. Do NOT flag other code quality issues, style problems, or unrelated concerns.
|
|
19
|
+
If the code does not violate THIS threadline's specific rules, return "compliant" even if other issues exist.
|
|
20
|
+
|
|
21
|
+
Threadline Guidelines:
|
|
22
|
+
${threadline.content}
|
|
23
|
+
|
|
24
|
+
${contextFilesSection}Code Changes (Git Diff Format):
|
|
25
|
+
${diff}
|
|
26
|
+
|
|
27
|
+
Changed Files:
|
|
28
|
+
${matchingFiles.join('\n')}
|
|
29
|
+
|
|
30
|
+
Review the code changes AGAINST ONLY THE THREADLINE GUIDELINES ABOVE.
|
|
31
|
+
|
|
32
|
+
YOUR OBJECTIVES:
|
|
33
|
+
1. Detect new violations being introduced in the code changes
|
|
34
|
+
2. Review whether engineers have successfully addressed earlier violations
|
|
35
|
+
|
|
36
|
+
This is why it's important to look very carefully at the diff structure. You'll come across diffs that introduce new violations. You will also come across some that address earlier violations. The diff structure should allow you to tell which is which, because lines starting with '-' are removed in favour of lines with '+'.
|
|
37
|
+
|
|
38
|
+
CRITICAL CHECK BEFORE FLAGGING VIOLATIONS:
|
|
39
|
+
Before commenting on or flagging a violation in any line, look at the FIRST CHARACTER of that line:
|
|
40
|
+
* If it's a "-", the code is deleted.
|
|
41
|
+
→ Only flag violations in lines starting with "+" (new code being added)
|
|
42
|
+
* If the first character is "+", this is NEW code being added - flag violations here if they violate the threadline
|
|
43
|
+
* If the line doesn't start with "+" or "-" (context lines), these are UNCHANGED - do NOT flag violations here
|
|
44
|
+
* Some violations may not be line-specific (e.g., file-level patterns, overall structure) - include those in your reasoning as well
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
IMPORTANT:
|
|
48
|
+
- Only flag violations of the specific rules defined in this threadline
|
|
49
|
+
- Ignore all other code quality issues, style problems, or unrelated concerns
|
|
50
|
+
- Focus on understanding the diff structure to distinguish between new violations and fixes
|
|
51
|
+
|
|
52
|
+
Return JSON only with this exact structure:
|
|
53
|
+
{
|
|
54
|
+
"status": "compliant" | "attention" | "not_relevant",
|
|
55
|
+
"reasoning": "explanation with file paths and line numbers embedded in the text (e.g., 'app/api/checks/route.ts:8 - The addition of...')",
|
|
56
|
+
"file_references": [file paths where violations occur - MUST match files from the diff, include ONLY files with violations]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
CRITICAL: For each violation, you MUST:
|
|
60
|
+
1. Embed the file path and line number(s) directly in your reasoning text (e.g., "app/api/checks/route.ts:8 - The addition of 'c.files_changed_counts' violates...")
|
|
61
|
+
2. For line-specific violations, include the line number (e.g., "file.ts:42")
|
|
62
|
+
3. For file-level or pattern violations, just include the file path (e.g., "file.ts")
|
|
63
|
+
4. Include ONLY files that actually contain violations in "file_references" array
|
|
64
|
+
5. Do NOT include files that don't have violations, even if they appear in the diff
|
|
65
|
+
6. The "file_references" array should be a simple list of file paths - no line numbers needed there since they're in the reasoning
|
|
66
|
+
|
|
67
|
+
Status meanings:
|
|
68
|
+
- "compliant": Code follows THIS threadline's guidelines, no violations found (even if other issues exist)
|
|
69
|
+
- "attention": Code DIRECTLY violates THIS threadline's specific guidelines
|
|
70
|
+
- "not_relevant": This threadline doesn't apply to these files/changes (e.g., wrong file type, no matching code patterns)
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.processThreadlines = processThreadlines;
|
|
4
|
+
const single_expert_1 = require("./single-expert");
|
|
5
|
+
const logger_1 = require("../utils/logger");
|
|
6
|
+
const EXPERT_TIMEOUT = 40000; // 40 seconds
|
|
7
|
+
async function processThreadlines(request) {
|
|
8
|
+
const { threadlines, diff, files, apiKey, model, serviceTier, contextLinesForLLM } = request;
|
|
9
|
+
// Determine LLM model (same for all threadlines in this check)
|
|
10
|
+
const llmModel = `${model} ${serviceTier}`;
|
|
11
|
+
// Create promises with timeout
|
|
12
|
+
const promises = threadlines.map(threadline => {
|
|
13
|
+
let timeoutId = null;
|
|
14
|
+
let resolved = false;
|
|
15
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
16
|
+
timeoutId = setTimeout(() => {
|
|
17
|
+
// Only log and resolve if we haven't already resolved
|
|
18
|
+
if (!resolved) {
|
|
19
|
+
logger_1.logger.error(`Request timed out after ${EXPERT_TIMEOUT / 1000}s for threadline: ${threadline.id}`);
|
|
20
|
+
resolved = true;
|
|
21
|
+
resolve({
|
|
22
|
+
expertId: threadline.id,
|
|
23
|
+
status: 'error',
|
|
24
|
+
reasoning: `Error: Request timed out after ${EXPERT_TIMEOUT / 1000}s`,
|
|
25
|
+
error: {
|
|
26
|
+
message: `Request timed out after ${EXPERT_TIMEOUT / 1000}s`,
|
|
27
|
+
type: 'timeout'
|
|
28
|
+
},
|
|
29
|
+
fileReferences: [],
|
|
30
|
+
relevantFiles: [],
|
|
31
|
+
filteredDiff: '',
|
|
32
|
+
filesInFilteredDiff: [],
|
|
33
|
+
actualModel: undefined
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}, EXPERT_TIMEOUT);
|
|
37
|
+
});
|
|
38
|
+
const actualPromise = (0, single_expert_1.processThreadline)(threadline, diff, files, apiKey, model, serviceTier, contextLinesForLLM).then(result => {
|
|
39
|
+
// Mark as resolved and clear timeout if it hasn't fired yet
|
|
40
|
+
resolved = true;
|
|
41
|
+
if (timeoutId) {
|
|
42
|
+
clearTimeout(timeoutId);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
});
|
|
46
|
+
return Promise.race([actualPromise, timeoutPromise]);
|
|
47
|
+
});
|
|
48
|
+
// Wait for all (some may timeout)
|
|
49
|
+
const results = await Promise.allSettled(promises);
|
|
50
|
+
// Process results
|
|
51
|
+
const expertResults = [];
|
|
52
|
+
let completed = 0;
|
|
53
|
+
let timedOut = 0;
|
|
54
|
+
let errors = 0;
|
|
55
|
+
let actualModelFromResponse;
|
|
56
|
+
for (let i = 0; i < results.length; i++) {
|
|
57
|
+
const result = results[i];
|
|
58
|
+
const threadline = threadlines[i];
|
|
59
|
+
if (result.status === 'fulfilled') {
|
|
60
|
+
const expertResult = result.value;
|
|
61
|
+
// Check status directly - errors and timeouts are now 'error' status
|
|
62
|
+
if (expertResult.status === 'error') {
|
|
63
|
+
// Check if it's a timeout (has error.type === 'timeout')
|
|
64
|
+
if ('error' in expertResult && expertResult.error?.type === 'timeout') {
|
|
65
|
+
timedOut++;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
errors++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
completed++;
|
|
73
|
+
}
|
|
74
|
+
expertResults.push(expertResult);
|
|
75
|
+
// Capture actual model from first successful result (all threadlines use same model)
|
|
76
|
+
if (!actualModelFromResponse && 'actualModel' in expertResult && expertResult.actualModel) {
|
|
77
|
+
actualModelFromResponse = expertResult.actualModel;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
errors++;
|
|
82
|
+
expertResults.push({
|
|
83
|
+
expertId: threadline.id,
|
|
84
|
+
status: 'error',
|
|
85
|
+
reasoning: `Error: ${result.reason?.message || 'Unknown error'}`,
|
|
86
|
+
error: {
|
|
87
|
+
message: result.reason?.message || 'Unknown error',
|
|
88
|
+
rawResponse: result.reason
|
|
89
|
+
},
|
|
90
|
+
fileReferences: [],
|
|
91
|
+
relevantFiles: [],
|
|
92
|
+
filteredDiff: '',
|
|
93
|
+
filesInFilteredDiff: []
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Use actual model from OpenAI response, append service tier
|
|
98
|
+
let modelToStore;
|
|
99
|
+
if (actualModelFromResponse) {
|
|
100
|
+
modelToStore = `${actualModelFromResponse} ${serviceTier}`;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// All calls failed - log prominently and preserve requested model for debugging
|
|
104
|
+
logger_1.logger.error(`No successful LLM responses received. Requested model: ${llmModel}`);
|
|
105
|
+
logger_1.logger.error(`Completed: ${completed}, Timed out: ${timedOut}, Errors: ${errors}`);
|
|
106
|
+
// Store requested model so we can debug what was attempted
|
|
107
|
+
modelToStore = `${llmModel} (no successful responses)`;
|
|
108
|
+
}
|
|
109
|
+
// Return all results - CLI will handle filtering/display
|
|
110
|
+
return {
|
|
111
|
+
results: expertResults,
|
|
112
|
+
metadata: {
|
|
113
|
+
totalThreadlines: threadlines.length,
|
|
114
|
+
completed,
|
|
115
|
+
timedOut,
|
|
116
|
+
errors,
|
|
117
|
+
llmModel: modelToStore
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|