threadlines 0.3.0 → 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/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 +195 -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,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) {
|
|
@@ -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.4.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",
|