threadlines 0.3.0 → 0.5.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/dist/api/client.js +47 -14
- package/dist/commands/check.js +10 -14
- package/dist/git/ci-context.js +6 -8
- package/dist/git/diff.js +60 -25
- package/dist/git/local.js +178 -42
- package/dist/processors/single-expert.js +69 -13
- package/dist/utils/config-file.js +14 -13
- package/package.json +2 -5
package/dist/api/client.js
CHANGED
|
@@ -1,27 +1,60 @@
|
|
|
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.ReviewAPIClient = void 0;
|
|
7
|
-
const axios_1 = __importDefault(require("axios"));
|
|
8
4
|
class ReviewAPIClient {
|
|
9
5
|
constructor(baseURL) {
|
|
10
|
-
this.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
this.timeout = 60000; // 60s timeout for entire request
|
|
7
|
+
this.baseURL = baseURL;
|
|
8
|
+
}
|
|
9
|
+
async fetchWithTimeout(url, options) {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
...options,
|
|
15
|
+
signal: controller.signal,
|
|
16
|
+
});
|
|
17
|
+
clearTimeout(timeoutId);
|
|
18
|
+
return response;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
clearTimeout(timeoutId);
|
|
22
|
+
// Handle AbortError from timeout
|
|
23
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
24
|
+
throw new Error(`Request timeout after ${this.timeout}ms`);
|
|
15
25
|
}
|
|
16
|
-
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
17
28
|
}
|
|
18
29
|
async review(request) {
|
|
19
|
-
const
|
|
20
|
-
|
|
30
|
+
const url = `${this.baseURL}/api/threadline-check`;
|
|
31
|
+
const response = await this.fetchWithTimeout(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(request),
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const errorText = await response.text();
|
|
40
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
41
|
+
}
|
|
42
|
+
return await response.json();
|
|
21
43
|
}
|
|
22
44
|
async syncResults(request) {
|
|
23
|
-
const
|
|
24
|
-
|
|
45
|
+
const url = `${this.baseURL}/api/threadline-check-results`;
|
|
46
|
+
const response = await this.fetchWithTimeout(url, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(request),
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const errorText = await response.text();
|
|
55
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
56
|
+
}
|
|
57
|
+
return await response.json();
|
|
25
58
|
}
|
|
26
59
|
}
|
|
27
60
|
exports.ReviewAPIClient = ReviewAPIClient;
|
package/dist/commands/check.js
CHANGED
|
@@ -50,7 +50,7 @@ const expert_1 = require("../processors/expert");
|
|
|
50
50
|
const fs = __importStar(require("fs"));
|
|
51
51
|
const path = __importStar(require("path"));
|
|
52
52
|
const chalk_1 = __importDefault(require("chalk"));
|
|
53
|
-
const
|
|
53
|
+
const child_process_1 = require("child_process");
|
|
54
54
|
/**
|
|
55
55
|
* Helper to get context for any environment.
|
|
56
56
|
* CI environments use the unified getCIContext().
|
|
@@ -79,21 +79,22 @@ async function checkCommand(options) {
|
|
|
79
79
|
const repoRoot = cwd; // Keep for backward compatibility with rest of function
|
|
80
80
|
// Load configuration
|
|
81
81
|
const config = await (0, config_file_1.loadConfig)(cwd);
|
|
82
|
-
logger_1.logger.info(`🔍 Threadline CLI v${CLI_VERSION}: Checking code against your threadlines
|
|
82
|
+
logger_1.logger.info(`🔍 Threadline CLI v${CLI_VERSION}: Checking code against your threadlines...`);
|
|
83
83
|
// Get git root for consistent file paths across monorepo
|
|
84
|
-
const git = (0, simple_git_1.default)(cwd);
|
|
85
84
|
let gitRoot;
|
|
86
85
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
// Check if we're in a git repo
|
|
87
|
+
(0, child_process_1.execSync)('git rev-parse --git-dir', { cwd: cwd, stdio: 'ignore' });
|
|
88
|
+
// Get git root
|
|
89
|
+
gitRoot = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
cwd: cwd
|
|
92
|
+
}).trim();
|
|
93
93
|
}
|
|
94
94
|
catch (error) {
|
|
95
95
|
const message = error instanceof Error ? error.message : String(error);
|
|
96
96
|
logger_1.logger.error(`Failed to get git root: ${message}`);
|
|
97
|
+
logger_1.logger.error('Not a git repository. Threadline requires a git repository.');
|
|
97
98
|
process.exit(1);
|
|
98
99
|
}
|
|
99
100
|
// Pre-flight check: Validate OpenAI API key is set (required for local processing)
|
|
@@ -232,11 +233,6 @@ async function checkCommand(options) {
|
|
|
232
233
|
logger_1.logger.output('');
|
|
233
234
|
process.exit(0);
|
|
234
235
|
}
|
|
235
|
-
logger_1.logger.info(`✓ Found ${gitDiff.changedFiles.length} changed file(s) (context: ${reviewContext})\n`);
|
|
236
|
-
// Log the files being sent
|
|
237
|
-
for (const file of gitDiff.changedFiles) {
|
|
238
|
-
logger_1.logger.info(` → ${file}`);
|
|
239
|
-
}
|
|
240
236
|
// 4. Read context files for each threadline
|
|
241
237
|
const threadlinesWithContext = threadlines.map(threadline => {
|
|
242
238
|
const contextContent = {};
|
package/dist/git/ci-context.js
CHANGED
|
@@ -9,12 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* This replaces the individual github.ts, gitlab.ts, bitbucket.ts, vercel.ts files.
|
|
11
11
|
*/
|
|
12
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
-
};
|
|
15
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
13
|
exports.getCIContext = getCIContext;
|
|
17
|
-
const
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
18
15
|
const logger_1 = require("../utils/logger");
|
|
19
16
|
const ci_config_1 = require("./ci-config");
|
|
20
17
|
const diff_1 = require("./diff");
|
|
@@ -29,18 +26,19 @@ const diff_1 = require("./diff");
|
|
|
29
26
|
* @param environment - The CI environment (github, gitlab, bitbucket, vercel)
|
|
30
27
|
*/
|
|
31
28
|
async function getCIContext(repoRoot, environment) {
|
|
32
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
33
29
|
const config = ci_config_1.CI_CONFIGS[environment];
|
|
34
30
|
// Check if we're in a git repo
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
try {
|
|
32
|
+
(0, child_process_1.execSync)('git rev-parse --git-dir', { cwd: repoRoot, stdio: 'ignore' });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
37
35
|
throw new Error('Not a git repository. Threadline requires a git repository.');
|
|
38
36
|
}
|
|
39
37
|
// === SHARED GIT COMMANDS (reliable across all CI environments) ===
|
|
40
38
|
const repoName = await (0, diff_1.getRepoUrl)(repoRoot);
|
|
41
39
|
const commitSha = await (0, diff_1.getHeadCommitSha)(repoRoot);
|
|
42
40
|
const commitAuthor = await (0, diff_1.getCommitAuthor)(repoRoot);
|
|
43
|
-
const commitMessage = await (0, diff_1.getCommitMessage)(repoRoot, commitSha)
|
|
41
|
+
const commitMessage = await (0, diff_1.getCommitMessage)(repoRoot, commitSha);
|
|
44
42
|
// === CI-SPECIFIC ENV VARS (only for things git can't provide) ===
|
|
45
43
|
const branchName = config.getBranchName();
|
|
46
44
|
const isPR = config.isPullRequest();
|
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,145 @@ 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
|
+
// Use git diff commands as source of truth (more reliable than git status --porcelain)
|
|
99
|
+
// git status --porcelain can be inconsistent in some edge cases
|
|
100
|
+
// Check staged files first (source of truth)
|
|
101
|
+
const stagedFilesOutput = (0, child_process_1.execSync)('git diff --cached --name-only', {
|
|
102
|
+
encoding: 'utf-8',
|
|
103
|
+
cwd: repoRoot
|
|
104
|
+
}).trim();
|
|
105
|
+
const actualStagedFiles = stagedFilesOutput ? stagedFilesOutput.split('\n') : [];
|
|
106
|
+
// Check unstaged files (source of truth)
|
|
107
|
+
const unstagedFilesOutput = (0, child_process_1.execSync)('git diff --name-only', {
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
cwd: repoRoot
|
|
110
|
+
}).trim();
|
|
111
|
+
const actualUnstagedFiles = unstagedFilesOutput ? unstagedFilesOutput.split('\n') : [];
|
|
112
|
+
// Get untracked files from git status --porcelain (only reliable way to get untracked)
|
|
113
|
+
const statusOutput = (0, child_process_1.execSync)('git status --porcelain', {
|
|
114
|
+
encoding: 'utf-8',
|
|
115
|
+
cwd: repoRoot
|
|
116
|
+
}).trim();
|
|
117
|
+
const lines = statusOutput ? statusOutput.split('\n') : [];
|
|
118
|
+
const untracked = [];
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
const stagedStatus = line[0];
|
|
121
|
+
const unstagedStatus = line[1];
|
|
122
|
+
// Collect untracked files (only reliable way to detect them)
|
|
123
|
+
if (stagedStatus === '?' && unstagedStatus === '?') {
|
|
124
|
+
const file = line.slice(3);
|
|
125
|
+
untracked.push(file);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
70
128
|
let diff;
|
|
71
129
|
let changedFiles;
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
130
|
+
// Workflow A: Developer has staged files - check ONLY staged files
|
|
131
|
+
// (Ignore unstaged and untracked - developer explicitly chose to check staged)
|
|
132
|
+
if (actualStagedFiles.length > 0) {
|
|
133
|
+
diff = (0, child_process_1.execSync)('git diff --cached -U200', {
|
|
134
|
+
encoding: 'utf-8',
|
|
135
|
+
cwd: repoRoot
|
|
136
|
+
});
|
|
137
|
+
changedFiles = actualStagedFiles;
|
|
138
|
+
// If staged files exist but diff is empty, something is wrong
|
|
139
|
+
if (!diff || diff.trim() === '') {
|
|
140
|
+
throw new Error(`Staged files exist but diff is empty. ` +
|
|
141
|
+
`This may indicate binary files, whitespace-only changes, or a git issue. ` +
|
|
142
|
+
`Staged files: ${actualStagedFiles.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
logger_1.logger.info(`Checking STAGED changes (${changedFiles.length} file(s))`);
|
|
145
|
+
return {
|
|
146
|
+
diff: diff || '',
|
|
147
|
+
changedFiles
|
|
148
|
+
};
|
|
77
149
|
}
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
changedFiles = status.files
|
|
82
|
-
.filter(f => f.working_dir !== ' ' || f.index !== ' ')
|
|
83
|
-
.map(f => f.path);
|
|
150
|
+
// No staged files - log clearly and continue to unstaged/untracked
|
|
151
|
+
if (actualUnstagedFiles.length > 0 || untracked.length > 0) {
|
|
152
|
+
logger_1.logger.info(`No staged files, checking unstaged/untracked files.`);
|
|
84
153
|
}
|
|
85
|
-
// No changes at all
|
|
86
154
|
else {
|
|
155
|
+
logger_1.logger.info(`No staged files detected.`);
|
|
156
|
+
}
|
|
157
|
+
// Workflow B: Developer hasn't staged files - check unstaged + untracked files
|
|
158
|
+
// (Untracked files are conceptually "unstaged" - files being worked on but not committed)
|
|
159
|
+
if (actualUnstagedFiles.length > 0 || untracked.length > 0) {
|
|
160
|
+
// Get unstaged diff if there are unstaged files
|
|
161
|
+
if (actualUnstagedFiles.length > 0) {
|
|
162
|
+
diff = (0, child_process_1.execSync)('git diff -U200', {
|
|
163
|
+
encoding: 'utf-8',
|
|
164
|
+
cwd: repoRoot
|
|
165
|
+
});
|
|
166
|
+
changedFiles = actualUnstagedFiles;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
diff = '';
|
|
170
|
+
changedFiles = [];
|
|
171
|
+
}
|
|
172
|
+
// Handle untracked files: read their content and create artificial diffs
|
|
173
|
+
// Fails loudly if any untracked file cannot be read (permissions, filesystem errors, etc.)
|
|
174
|
+
const untrackedDiffs = [];
|
|
175
|
+
const untrackedFileList = [];
|
|
176
|
+
for (const file of untracked) {
|
|
177
|
+
const fullPath = path.resolve(repoRoot, file);
|
|
178
|
+
// Skip if it's a directory (git status can show directories)
|
|
179
|
+
const stats = fs.statSync(fullPath);
|
|
180
|
+
if (!stats.isFile()) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// Read file content - fails loudly on any error (permissions, encoding, etc.)
|
|
184
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
185
|
+
// Normalize path to forward slashes for cross-platform consistency
|
|
186
|
+
const normalizedPath = file.replace(/\\/g, '/');
|
|
187
|
+
// Create artificial diff (all lines as additions, similar to getFileContent)
|
|
188
|
+
const lines = content.split('\n');
|
|
189
|
+
const fileDiff = lines.map((line) => `+${line}`).join('\n');
|
|
190
|
+
// Add git diff header (matches format expected by server's filterDiffByFiles)
|
|
191
|
+
const diffHeader = `diff --git a/${normalizedPath} b/${normalizedPath}\n--- /dev/null\n+++ b/${normalizedPath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
192
|
+
untrackedDiffs.push(diffHeader + fileDiff);
|
|
193
|
+
untrackedFileList.push(normalizedPath);
|
|
194
|
+
}
|
|
195
|
+
// Combine unstaged changes with untracked files
|
|
196
|
+
const combinedDiff = untrackedDiffs.length > 0
|
|
197
|
+
? (diff ? diff + '\n' : '') + untrackedDiffs.join('\n')
|
|
198
|
+
: diff;
|
|
199
|
+
const allChangedFiles = [...changedFiles, ...untrackedFileList];
|
|
200
|
+
// Validate that we actually have changes to review
|
|
201
|
+
// This can happen if:
|
|
202
|
+
// 1. git status showed files but git diff returns empty (files were staged/unstaged between commands)
|
|
203
|
+
// 2. All untracked items are directories (skipped)
|
|
204
|
+
// 3. Parsing incorrectly categorized files
|
|
205
|
+
if (allChangedFiles.length === 0 || !combinedDiff || combinedDiff.trim() === '') {
|
|
206
|
+
throw new Error('No changes detected. Stage files with "git add" or modify files to run threadlines.');
|
|
207
|
+
}
|
|
208
|
+
const unstagedCount = changedFiles.length;
|
|
209
|
+
const untrackedCount = untrackedFileList.length;
|
|
210
|
+
if (unstagedCount > 0 && untrackedCount > 0) {
|
|
211
|
+
logger_1.logger.info(`Checking UNSTAGED changes (${unstagedCount} file(s)) + ${untrackedCount} untracked file(s)`);
|
|
212
|
+
}
|
|
213
|
+
else if (unstagedCount > 0) {
|
|
214
|
+
logger_1.logger.info(`Checking UNSTAGED changes (${unstagedCount} file(s))`);
|
|
215
|
+
}
|
|
216
|
+
else if (untrackedCount > 0) {
|
|
217
|
+
logger_1.logger.info(`Checking UNTRACKED files (${untrackedCount} file(s))`);
|
|
218
|
+
}
|
|
87
219
|
return {
|
|
88
|
-
diff: '',
|
|
89
|
-
changedFiles:
|
|
220
|
+
diff: combinedDiff || '',
|
|
221
|
+
changedFiles: allChangedFiles
|
|
90
222
|
};
|
|
91
223
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
changedFiles
|
|
95
|
-
};
|
|
224
|
+
// No changes at all - fail loudly
|
|
225
|
+
throw new Error('No changes detected. Stage files with "git add" or modify files to run threadlines.');
|
|
96
226
|
}
|
|
97
227
|
/**
|
|
98
228
|
* Gets branch name for local environment
|
|
99
229
|
* (Uses git command directly - works in local because not in detached HEAD state)
|
|
100
230
|
*/
|
|
101
231
|
async function getBranchName(repoRoot) {
|
|
102
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
103
232
|
try {
|
|
104
|
-
const
|
|
105
|
-
|
|
233
|
+
const currentBranch = (0, child_process_1.execSync)('git branch --show-current', {
|
|
234
|
+
encoding: 'utf-8',
|
|
235
|
+
cwd: repoRoot
|
|
236
|
+
}).trim();
|
|
106
237
|
if (!currentBranch) {
|
|
107
238
|
throw new Error('Could not determine current branch. Are you in a git repository?');
|
|
108
239
|
}
|
|
@@ -120,17 +251,22 @@ async function getBranchName(repoRoot) {
|
|
|
120
251
|
* No fallbacks - if git config is not set or fails, throws an error.
|
|
121
252
|
*/
|
|
122
253
|
async function getCommitAuthorFromConfig(repoRoot) {
|
|
123
|
-
const git = (0, simple_git_1.default)(repoRoot);
|
|
124
254
|
try {
|
|
125
|
-
const name =
|
|
126
|
-
|
|
127
|
-
|
|
255
|
+
const name = (0, child_process_1.execSync)('git config --get user.name', {
|
|
256
|
+
encoding: 'utf-8',
|
|
257
|
+
cwd: repoRoot
|
|
258
|
+
}).trim();
|
|
259
|
+
const email = (0, child_process_1.execSync)('git config --get user.email', {
|
|
260
|
+
encoding: 'utf-8',
|
|
261
|
+
cwd: repoRoot
|
|
262
|
+
}).trim();
|
|
263
|
+
if (!name || !email) {
|
|
128
264
|
throw new Error('Git config user.name or user.email is not set. ' +
|
|
129
265
|
'Run: git config user.name "Your Name" && git config user.email "your.email@example.com"');
|
|
130
266
|
}
|
|
131
267
|
return {
|
|
132
|
-
name: name
|
|
133
|
-
email: email
|
|
268
|
+
name: name,
|
|
269
|
+
email: email
|
|
134
270
|
};
|
|
135
271
|
}
|
|
136
272
|
catch (error) {
|
|
@@ -1,16 +1,11 @@
|
|
|
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.processThreadline = processThreadline;
|
|
7
|
-
const openai_1 = __importDefault(require("openai"));
|
|
8
4
|
const prompt_builder_1 = require("../llm/prompt-builder");
|
|
9
5
|
const diff_filter_1 = require("../utils/diff-filter");
|
|
10
6
|
const slim_diff_1 = require("../utils/slim-diff");
|
|
11
7
|
const logger_1 = require("../utils/logger");
|
|
12
8
|
async function processThreadline(threadline, diff, files, apiKey, model, serviceTier, contextLinesForLLM) {
|
|
13
|
-
const openai = new openai_1.default({ apiKey });
|
|
14
9
|
// Filter files that match threadline patterns
|
|
15
10
|
const relevantFiles = files.filter(file => threadline.patterns.some(pattern => matchesPattern(file, pattern)));
|
|
16
11
|
// If no files match, return not_relevant
|
|
@@ -55,7 +50,8 @@ async function processThreadline(threadline, diff, files, apiKey, model, service
|
|
|
55
50
|
let llmCallStatus = 'success';
|
|
56
51
|
let llmCallErrorMessage = null;
|
|
57
52
|
try {
|
|
58
|
-
|
|
53
|
+
// Build request body for OpenAI API (direct HTTP call - zero dependencies)
|
|
54
|
+
const requestBody = {
|
|
59
55
|
model,
|
|
60
56
|
messages: [
|
|
61
57
|
{
|
|
@@ -73,9 +69,63 @@ async function processThreadline(threadline, diff, files, apiKey, model, service
|
|
|
73
69
|
// Add service_tier if not 'standard'
|
|
74
70
|
const normalizedServiceTier = serviceTier.toLowerCase();
|
|
75
71
|
if (normalizedServiceTier !== 'standard' && (normalizedServiceTier === 'auto' || normalizedServiceTier === 'default' || normalizedServiceTier === 'flex')) {
|
|
76
|
-
|
|
72
|
+
requestBody.service_tier = normalizedServiceTier;
|
|
77
73
|
}
|
|
78
|
-
|
|
74
|
+
// Direct HTTP call to OpenAI API (native fetch - zero dependencies)
|
|
75
|
+
// Use AbortController for timeout (higher-level timeout in expert.ts is 40s, use 45s here as safety margin)
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
|
78
|
+
let httpResponse;
|
|
79
|
+
try {
|
|
80
|
+
httpResponse = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify(requestBody),
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
clearTimeout(timeoutId);
|
|
90
|
+
}
|
|
91
|
+
catch (fetchError) {
|
|
92
|
+
clearTimeout(timeoutId);
|
|
93
|
+
// Handle AbortError from timeout
|
|
94
|
+
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
|
95
|
+
throw new Error('Request timeout');
|
|
96
|
+
}
|
|
97
|
+
throw fetchError;
|
|
98
|
+
}
|
|
99
|
+
if (!httpResponse.ok) {
|
|
100
|
+
const errorText = await httpResponse.text();
|
|
101
|
+
let errorMessage = `HTTP ${httpResponse.status}: ${errorText}`;
|
|
102
|
+
// Try to parse OpenAI error structure
|
|
103
|
+
try {
|
|
104
|
+
const errorData = JSON.parse(errorText);
|
|
105
|
+
if (errorData.error) {
|
|
106
|
+
errorMessage = errorData.error.message || errorText;
|
|
107
|
+
// Create error object matching SDK error structure for compatibility
|
|
108
|
+
const errorObj = new Error(errorMessage);
|
|
109
|
+
errorObj.status = httpResponse.status;
|
|
110
|
+
errorObj.error = {
|
|
111
|
+
type: errorData.error.type,
|
|
112
|
+
code: errorData.error.code,
|
|
113
|
+
param: errorData.error.param,
|
|
114
|
+
};
|
|
115
|
+
throw errorObj;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (parseError) {
|
|
119
|
+
// If it's already our structured error, re-throw it
|
|
120
|
+
const structuredError = parseError;
|
|
121
|
+
if (structuredError.status) {
|
|
122
|
+
throw parseError;
|
|
123
|
+
}
|
|
124
|
+
// Otherwise create a basic error
|
|
125
|
+
throw new Error(errorMessage);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const response = await httpResponse.json();
|
|
79
129
|
// Capture the actual model returned by OpenAI (may differ from requested)
|
|
80
130
|
const actualModel = response.model;
|
|
81
131
|
llmCallFinishedAt = new Date().toISOString();
|
|
@@ -83,12 +133,12 @@ async function processThreadline(threadline, diff, files, apiKey, model, service
|
|
|
83
133
|
// Capture token usage if available
|
|
84
134
|
if (response.usage) {
|
|
85
135
|
llmCallTokens = {
|
|
86
|
-
prompt_tokens: response.usage.prompt_tokens,
|
|
87
|
-
completion_tokens: response.usage.completion_tokens,
|
|
88
|
-
total_tokens: response.usage.total_tokens
|
|
136
|
+
prompt_tokens: response.usage.prompt_tokens || 0,
|
|
137
|
+
completion_tokens: response.usage.completion_tokens || 0,
|
|
138
|
+
total_tokens: response.usage.total_tokens || 0
|
|
89
139
|
};
|
|
90
140
|
}
|
|
91
|
-
const content = response.choices[0]?.message?.content;
|
|
141
|
+
const content = response.choices?.[0]?.message?.content;
|
|
92
142
|
if (!content) {
|
|
93
143
|
throw new Error('No response from LLM');
|
|
94
144
|
}
|
|
@@ -145,13 +195,19 @@ async function processThreadline(threadline, diff, files, apiKey, model, service
|
|
|
145
195
|
// Log full error for debugging
|
|
146
196
|
logger_1.logger.error(` ❌ OpenAI error: ${JSON.stringify(error, null, 2)}`);
|
|
147
197
|
// Extract OpenAI error details from the error object
|
|
198
|
+
// Handle both SDK-style errors and HTTP errors
|
|
148
199
|
const errorObj = error;
|
|
149
200
|
const openAIError = errorObj?.error || {};
|
|
150
201
|
const rawErrorResponse = {
|
|
151
202
|
status: errorObj?.status,
|
|
152
203
|
headers: errorObj?.headers,
|
|
153
204
|
request_id: errorObj?.request_id,
|
|
154
|
-
error: errorObj?.error
|
|
205
|
+
error: errorObj?.error || {
|
|
206
|
+
type: errorObj?.type,
|
|
207
|
+
code: errorObj?.code,
|
|
208
|
+
param: errorObj?.param,
|
|
209
|
+
message: errorObj?.message,
|
|
210
|
+
},
|
|
155
211
|
code: errorObj?.code,
|
|
156
212
|
param: errorObj?.param,
|
|
157
213
|
type: errorObj?.type
|
|
@@ -32,15 +32,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
36
|
exports.DEFAULT_CONFIG = void 0;
|
|
40
37
|
exports.loadConfig = loadConfig;
|
|
41
38
|
const fs = __importStar(require("fs"));
|
|
42
39
|
const path = __importStar(require("path"));
|
|
43
|
-
const
|
|
40
|
+
const child_process_1 = require("child_process");
|
|
44
41
|
exports.DEFAULT_CONFIG = {
|
|
45
42
|
mode: 'online', // Default: sync enabled. Set to "offline" for local-only processing.
|
|
46
43
|
api_url: 'https://devthreadline.com',
|
|
@@ -50,20 +47,24 @@ exports.DEFAULT_CONFIG = {
|
|
|
50
47
|
};
|
|
51
48
|
/**
|
|
52
49
|
* Finds the git root directory by walking up from startDir.
|
|
53
|
-
*
|
|
50
|
+
* Fails loudly if not in a git repository (this tool requires a git repo).
|
|
54
51
|
*/
|
|
55
52
|
async function findGitRoot(startDir) {
|
|
56
53
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
// Check if we're in a git repo
|
|
55
|
+
(0, child_process_1.execSync)('git rev-parse --git-dir', { cwd: startDir, stdio: 'ignore' });
|
|
56
|
+
// Get git root
|
|
57
|
+
return (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
|
|
58
|
+
encoding: 'utf-8',
|
|
59
|
+
cwd: startDir
|
|
60
|
+
}).trim();
|
|
62
61
|
}
|
|
63
|
-
catch {
|
|
64
|
-
|
|
62
|
+
catch (error) {
|
|
63
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
64
|
+
throw new Error(`Not a git repository. Threadline requires a git repository.\n` +
|
|
65
|
+
`Current directory: ${startDir}\n` +
|
|
66
|
+
`Error: ${errorMessage}`);
|
|
65
67
|
}
|
|
66
|
-
return startDir;
|
|
67
68
|
}
|
|
68
69
|
/**
|
|
69
70
|
* Loads configuration from .threadlinerc file.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "threadlines",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Threadlines CLI - AI-powered linter based on your natural language documentation",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -48,14 +48,11 @@
|
|
|
48
48
|
"node": ">=18.0.0"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"axios": "^1.7.9",
|
|
52
51
|
"chalk": "^4.1.2",
|
|
53
52
|
"commander": "^12.1.0",
|
|
54
53
|
"dotenv": "^16.4.7",
|
|
55
54
|
"glob": "^13.0.0",
|
|
56
|
-
"js-yaml": "^4.1.0"
|
|
57
|
-
"openai": "^4.73.1",
|
|
58
|
-
"simple-git": "^3.27.0"
|
|
55
|
+
"js-yaml": "^4.1.0"
|
|
59
56
|
},
|
|
60
57
|
"devDependencies": {
|
|
61
58
|
"@types/glob": "^8.1.0",
|