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.
@@ -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.client = axios_1.default.create({
11
- baseURL,
12
- timeout: 60000, // 60s timeout for entire request
13
- headers: {
14
- 'Content-Type': 'application/json'
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 response = await this.client.post('/api/threadline-check', request);
20
- return response.data;
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 response = await this.client.post('/api/threadline-check-results', request);
24
- return response.data;
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;
@@ -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 simple_git_1 = __importDefault(require("simple-git"));
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...\n`);
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
- const isRepo = await git.checkIsRepo();
88
- if (!isRepo) {
89
- logger_1.logger.error('Not a git repository. Threadline requires a git repository.');
90
- process.exit(1);
91
- }
92
- gitRoot = (await git.revparse(['--show-toplevel'])).trim();
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 = {};
@@ -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 simple_git_1 = __importDefault(require("simple-git"));
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
- const isRepo = await git.checkIsRepo();
36
- if (!isRepo) {
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) || undefined;
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
- * Returns full commit message (subject + body) or null if commit not found
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 = await git.show([sha, '--format=%B', '--no-patch']);
98
- return message.trim() || null;
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
- // Commit not found or invalid
102
- return null;
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
- await git.fetch(['origin', `${targetBranch}:refs/remotes/origin/${targetBranch}`, '--depth=1']);
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: ${fetchError instanceof Error ? fetchError.message : 'Unknown 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 = await git.diff([`origin/${targetBranch}...HEAD`, '-U200']);
204
- const diffSummary = await git.diffSummary([`origin/${targetBranch}...HEAD`]);
205
- changedFiles = diffSummary.files.map(f => f.file);
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 = await git.diff([`origin/${targetBranch}..HEAD`, '-U200']);
219
- const diffSummary = await git.diffSummary([`origin/${targetBranch}..HEAD`]);
220
- changedFiles = diffSummary.files.map(f => f.file);
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
- await git.fetch(['origin', parentSha, '--depth=1']);
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 = await git.diff([`${parentSha}..${sha}`, '-U200']);
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 diffSummary = await git.diffSummary([`${parentSha}..${sha}`]);
318
- changedFiles = diffSummary.files.map(f => f.file);
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 __importDefault = (this && this.__importDefault) || function (mod) {
15
- return (mod && mod.__esModule) ? mod : { "default": mod };
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 simple_git_1 = __importDefault(require("simple-git"));
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
- const isRepo = await git.checkIsRepo();
28
- if (!isRepo) {
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
- const message = await (0, diff_1.getCommitMessage)(repoRoot, commitSha);
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
- const git = (0, simple_git_1.default)(repoRoot);
68
- // Get git status to determine what changes exist
69
- const status = await git.status();
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
- // Priority 1: Use staged changes if available
73
- if (status.staged.length > 0) {
74
- diff = await git.diff(['--cached', '-U200']);
75
- // status.staged is an array of strings (file paths)
76
- changedFiles = status.staged;
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
- // Priority 2: Use unstaged changes if no staged changes
79
- else if (status.files.length > 0) {
80
- diff = await git.diff(['-U200']);
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
- return {
93
- diff: diff || '',
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 branchSummary = await git.branchLocal();
105
- const currentBranch = branchSummary.current;
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 = await git.getConfig('user.name');
126
- const email = await git.getConfig('user.email');
127
- if (!name.value || !email.value) {
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.value.trim(),
133
- email: email.value.trim()
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
- const requestParams = {
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
- requestParams.service_tier = normalizedServiceTier;
72
+ requestBody.service_tier = normalizedServiceTier;
77
73
  }
78
- const response = await openai.chat.completions.create(requestParams);
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 simple_git_1 = __importDefault(require("simple-git"));
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
- * Returns startDir if not in a git repository.
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
- const git = (0, simple_git_1.default)(startDir);
58
- const isRepo = await git.checkIsRepo();
59
- if (isRepo) {
60
- return (await git.revparse(['--show-toplevel'])).trim();
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
- // Not a git repo or error - return startDir
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.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",