@qodly/gentrace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +149 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +69 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +162 -0
- package/dist/commands/hooks.d.ts +2 -0
- package/dist/commands/hooks.js +128 -0
- package/dist/commands/install-git-ai.d.ts +1 -0
- package/dist/commands/install-git-ai.js +41 -0
- package/dist/commands/publish.d.ts +1 -0
- package/dist/commands/publish.js +224 -0
- package/dist/commands/skip.d.ts +3 -0
- package/dist/commands/skip.js +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/config.js +50 -0
- package/dist/lib/debug.d.ts +8 -0
- package/dist/lib/debug.js +36 -0
- package/dist/lib/exec.d.ts +14 -0
- package/dist/lib/exec.js +26 -0
- package/dist/lib/git-ai-ingest-context.d.ts +23 -0
- package/dist/lib/git-ai-ingest-context.js +81 -0
- package/dist/lib/git-ai.d.ts +35 -0
- package/dist/lib/git-ai.js +105 -0
- package/dist/lib/git.d.ts +21 -0
- package/dist/lib/git.js +67 -0
- package/dist/lib/prompt.d.ts +2 -0
- package/dist/lib/prompt.js +45 -0
- package/dist/lib/skip.d.ts +6 -0
- package/dist/lib/skip.js +19 -0
- package/dist/lib/telemetry-post.d.ts +14 -0
- package/dist/lib/telemetry-post.js +60 -0
- package/package.json +40 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** CLI / GENTRACE_DEBUG verbose logging (stderr). */
|
|
2
|
+
let enabledOverride = false;
|
|
3
|
+
function envDebug() {
|
|
4
|
+
const v = process.env.GENTRACE_DEBUG?.toLowerCase();
|
|
5
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
6
|
+
}
|
|
7
|
+
export const VERBOSE_FLAGS = new Set(['--verbose', '--debug', '-v']);
|
|
8
|
+
export function isDebug() {
|
|
9
|
+
return enabledOverride || envDebug();
|
|
10
|
+
}
|
|
11
|
+
export function enableDebug() {
|
|
12
|
+
enabledOverride = true;
|
|
13
|
+
}
|
|
14
|
+
/** Remove --verbose / --debug / -v from argv; enables debug if any were present. */
|
|
15
|
+
export function parseVerboseFlags(argv) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const a of argv) {
|
|
18
|
+
if (VERBOSE_FLAGS.has(a)) {
|
|
19
|
+
enableDebug();
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
out.push(a);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export function debugLog(...parts) {
|
|
28
|
+
if (!isDebug())
|
|
29
|
+
return;
|
|
30
|
+
console.error('[gentrace]', ...parts);
|
|
31
|
+
}
|
|
32
|
+
export function redactApiKey(key) {
|
|
33
|
+
if (key.length <= 8)
|
|
34
|
+
return '***';
|
|
35
|
+
return `${key.slice(0, 4)}…${key.slice(-4)}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ExecResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
exitCode: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function exec(cmd: string, args: string[], opts?: {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
timeout?: number;
|
|
9
|
+
}): Promise<ExecResult>;
|
|
10
|
+
export declare function execOrThrow(cmd: string, args: string[], opts?: {
|
|
11
|
+
cwd?: string;
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}): Promise<ExecResult>;
|
|
14
|
+
export declare function which(binary: string): Promise<string | null>;
|
package/dist/lib/exec.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export function exec(cmd, args, opts) {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
execFile(cmd, args, {
|
|
5
|
+
cwd: opts?.cwd,
|
|
6
|
+
timeout: opts?.timeout ?? 30_000,
|
|
7
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
8
|
+
encoding: 'utf-8',
|
|
9
|
+
}, (error, stdout, stderr) => {
|
|
10
|
+
const exitCode = error && 'code' in error && typeof error.code === 'number' ? error.code : error ? 1 : 0;
|
|
11
|
+
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function execOrThrow(cmd, args, opts) {
|
|
16
|
+
return exec(cmd, args, opts).then((r) => {
|
|
17
|
+
if (r.exitCode !== 0) {
|
|
18
|
+
throw new Error(`${cmd} ${args.join(' ')} exited ${r.exitCode}: ${r.stderr || r.stdout}`);
|
|
19
|
+
}
|
|
20
|
+
return r;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function which(binary) {
|
|
24
|
+
const r = await exec('which', [binary]);
|
|
25
|
+
return r.exitCode === 0 ? r.stdout.trim() : null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { GitAiStats } from './git-ai.js';
|
|
2
|
+
/** Pick the tool/model bucket with the most AI-attributed lines (git-ai `tool_model_breakdown`). */
|
|
3
|
+
export declare function primaryToolModelKey(stats: GitAiStats | null): string | undefined;
|
|
4
|
+
/** git-ai uses `tool::model` (e.g. `cursor::default`) or legacy `tool/model`. */
|
|
5
|
+
export declare function parseToolModelKey(key: string): {
|
|
6
|
+
tool: string;
|
|
7
|
+
model: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
/** Read agent tool from `git-ai diff --json` when stats are not yet populated. */
|
|
10
|
+
export declare function toolModelFromDiff(diff: unknown): {
|
|
11
|
+
tool: string;
|
|
12
|
+
model: string | undefined;
|
|
13
|
+
} | null;
|
|
14
|
+
/**
|
|
15
|
+
* Map git-ai agent `tool` slug to Gentrace telemetry `ide` (see shared-types `IDE` enum names).
|
|
16
|
+
* Falls back to `cli` when attribution is CLI-only or unknown.
|
|
17
|
+
*/
|
|
18
|
+
export declare function inferIdeFromAgentTool(tool: string): string;
|
|
19
|
+
export declare function resolvePublishAiContext(stats: GitAiStats | null, diff: unknown): {
|
|
20
|
+
aiProvider: string;
|
|
21
|
+
aiModel: string | undefined;
|
|
22
|
+
ide: string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Pick the tool/model bucket with the most AI-attributed lines (git-ai `tool_model_breakdown`). */
|
|
2
|
+
export function primaryToolModelKey(stats) {
|
|
3
|
+
if (!stats?.tool_model_breakdown)
|
|
4
|
+
return undefined;
|
|
5
|
+
const entries = Object.entries(stats.tool_model_breakdown);
|
|
6
|
+
if (entries.length === 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
entries.sort((a, b) => {
|
|
9
|
+
const aiA = a[1]?.ai_additions ?? 0;
|
|
10
|
+
const aiB = b[1]?.ai_additions ?? 0;
|
|
11
|
+
return aiB - aiA;
|
|
12
|
+
});
|
|
13
|
+
return entries[0]?.[0];
|
|
14
|
+
}
|
|
15
|
+
/** git-ai uses `tool::model` (e.g. `cursor::default`) or legacy `tool/model`. */
|
|
16
|
+
export function parseToolModelKey(key) {
|
|
17
|
+
const k = key.trim();
|
|
18
|
+
if (!k)
|
|
19
|
+
return { tool: 'custom', model: undefined };
|
|
20
|
+
if (k.includes('::')) {
|
|
21
|
+
const [a, b] = k.split('::');
|
|
22
|
+
const tool = (a ?? '').trim() || 'custom';
|
|
23
|
+
const model = (b ?? '').trim() || undefined;
|
|
24
|
+
return { tool, model };
|
|
25
|
+
}
|
|
26
|
+
if (k.includes('/')) {
|
|
27
|
+
const [a, b] = k.split('/');
|
|
28
|
+
const tool = (a ?? '').trim() || 'custom';
|
|
29
|
+
const model = (b ?? '').trim() || undefined;
|
|
30
|
+
return { tool, model };
|
|
31
|
+
}
|
|
32
|
+
return { tool: k, model: undefined };
|
|
33
|
+
}
|
|
34
|
+
/** Read agent tool from `git-ai diff --json` when stats are not yet populated. */
|
|
35
|
+
export function toolModelFromDiff(diff) {
|
|
36
|
+
if (!diff || typeof diff !== 'object')
|
|
37
|
+
return null;
|
|
38
|
+
const d = diff;
|
|
39
|
+
if (!d.sessions || typeof d.sessions !== 'object')
|
|
40
|
+
return null;
|
|
41
|
+
for (const s of Object.values(d.sessions)) {
|
|
42
|
+
const tool = s?.agent_id?.tool?.trim();
|
|
43
|
+
if (!tool)
|
|
44
|
+
continue;
|
|
45
|
+
const rawModel = s?.agent_id?.model?.trim();
|
|
46
|
+
const model = rawModel && rawModel.length > 0 ? rawModel : undefined;
|
|
47
|
+
return { tool, model };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Map git-ai agent `tool` slug to Gentrace telemetry `ide` (see shared-types `IDE` enum names).
|
|
53
|
+
* Falls back to `cli` when attribution is CLI-only or unknown.
|
|
54
|
+
*/
|
|
55
|
+
export function inferIdeFromAgentTool(tool) {
|
|
56
|
+
const t = tool.toLowerCase().trim();
|
|
57
|
+
if (!t || t === 'custom')
|
|
58
|
+
return 'cli';
|
|
59
|
+
if (t === 'cursor')
|
|
60
|
+
return 'cursor_ide';
|
|
61
|
+
if (t === 'vscode' || t === 'code' || t === 'codespaces')
|
|
62
|
+
return 'vscode';
|
|
63
|
+
if (t === 'jetbrains' || t === 'idea' || t === 'intellij' || t === 'webstorm' || t === 'pycharm')
|
|
64
|
+
return 'jetbrains';
|
|
65
|
+
if (t === 'neovim' || t === 'nvim')
|
|
66
|
+
return 'neovim';
|
|
67
|
+
if (t === 'zed')
|
|
68
|
+
return 'zed';
|
|
69
|
+
return t;
|
|
70
|
+
}
|
|
71
|
+
export function resolvePublishAiContext(stats, diff) {
|
|
72
|
+
const fromKey = primaryToolModelKey(stats);
|
|
73
|
+
const parsedFromStats = fromKey ? parseToolModelKey(fromKey) : null;
|
|
74
|
+
const parsedFromDiff = toolModelFromDiff(diff);
|
|
75
|
+
const merged = parsedFromStats ?? parsedFromDiff ?? { tool: 'custom', model: undefined };
|
|
76
|
+
return {
|
|
77
|
+
aiProvider: merged.tool,
|
|
78
|
+
aiModel: merged.model,
|
|
79
|
+
ide: inferIdeFromAgentTool(merged.tool),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare function isGitAiInstalled(): Promise<boolean>;
|
|
2
|
+
export declare function getGitAiVersion(): Promise<string | null>;
|
|
3
|
+
export interface GitAiStats {
|
|
4
|
+
human_additions: number;
|
|
5
|
+
ai_additions: number;
|
|
6
|
+
ai_accepted: number;
|
|
7
|
+
mixed_additions: number;
|
|
8
|
+
/** Lines not yet classified; git-ai keeps additions here until authorship is bound (common right after commit). */
|
|
9
|
+
unknown_additions?: number;
|
|
10
|
+
git_diff_added_lines: number;
|
|
11
|
+
git_diff_deleted_lines: number;
|
|
12
|
+
total_ai_additions: number;
|
|
13
|
+
total_ai_deletions: number;
|
|
14
|
+
time_waiting_for_ai: number;
|
|
15
|
+
tool_model_breakdown: Record<string, {
|
|
16
|
+
ai_additions: number;
|
|
17
|
+
ai_accepted: number;
|
|
18
|
+
mixed_additions?: number;
|
|
19
|
+
total_ai_additions?: number;
|
|
20
|
+
total_ai_deletions?: number;
|
|
21
|
+
time_waiting_for_ai?: number;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export declare function getStats(cwd: string, ref?: string): Promise<GitAiStats | null>;
|
|
25
|
+
/**
|
|
26
|
+
* After `post-commit`, git-ai stats and `git-ai show` can lag behind the commit.
|
|
27
|
+
* Poll both until attribution is reflected (or timeout), then return the last stats snapshot.
|
|
28
|
+
*/
|
|
29
|
+
export declare function waitForGitAiStats(cwd: string, ref: string, commitInsertions: number, opts?: {
|
|
30
|
+
maxWaitMs?: number;
|
|
31
|
+
pollMs?: number;
|
|
32
|
+
}): Promise<GitAiStats | null>;
|
|
33
|
+
export declare function getDiff(cwd: string, ref?: string): Promise<unknown | null>;
|
|
34
|
+
export declare function getShow(cwd: string, ref?: string): Promise<string | null>;
|
|
35
|
+
export declare function getPrompt(cwd: string, promptId: string, commitRef?: string): Promise<unknown | null>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { exec, which } from './exec.js';
|
|
2
|
+
export async function isGitAiInstalled() {
|
|
3
|
+
return (await which('git-ai')) !== null;
|
|
4
|
+
}
|
|
5
|
+
export async function getGitAiVersion() {
|
|
6
|
+
const r = await exec('git-ai', ['--version']);
|
|
7
|
+
return r.exitCode === 0 ? r.stdout.trim() : null;
|
|
8
|
+
}
|
|
9
|
+
export async function getStats(cwd, ref = 'HEAD') {
|
|
10
|
+
const r = await exec('git-ai', ['stats', ref, '--json'], { cwd, timeout: 15_000 });
|
|
11
|
+
if (r.exitCode !== 0)
|
|
12
|
+
return null;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(r.stdout);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* True when git-ai has finished classifying insertions for this commit (or there is nothing to classify).
|
|
25
|
+
*
|
|
26
|
+
* git-ai can briefly expose inconsistent snapshots (e.g. `unknown_additions` only, or all counters at 0 while
|
|
27
|
+
* `git_diff_added_lines` > 0). We wait until at least one attributed bucket or `tool_model_breakdown` is set.
|
|
28
|
+
*/
|
|
29
|
+
function statsLookReady(stats, commitInsertions) {
|
|
30
|
+
if (!stats)
|
|
31
|
+
return false;
|
|
32
|
+
const attributed = stats.ai_additions + stats.human_additions + stats.mixed_additions;
|
|
33
|
+
const hasBreakdown = Object.keys(stats.tool_model_breakdown ?? {}).length > 0;
|
|
34
|
+
const diffAdds = Math.max(0, Number(stats.git_diff_added_lines) || 0);
|
|
35
|
+
const hasInsertionsToAttribute = diffAdds > 0 || commitInsertions > 0;
|
|
36
|
+
if (!hasInsertionsToAttribute)
|
|
37
|
+
return true;
|
|
38
|
+
return attributed > 0 || hasBreakdown;
|
|
39
|
+
}
|
|
40
|
+
const NO_AUTHORSHIP_PLACEHOLDER = /no authorship data found/i;
|
|
41
|
+
/** Stats counters look usable; see also `publishDataReady` for git-ai `show` alignment. */
|
|
42
|
+
function publishDataReady(stats, show, commitInsertions) {
|
|
43
|
+
if (!statsLookReady(stats, commitInsertions))
|
|
44
|
+
return false;
|
|
45
|
+
if (!stats)
|
|
46
|
+
return false;
|
|
47
|
+
const attributed = stats.ai_additions + stats.human_additions + stats.mixed_additions;
|
|
48
|
+
const hasBreakdown = Object.keys(stats.tool_model_breakdown ?? {}).length > 0;
|
|
49
|
+
const diffAdds = Math.max(0, Number(stats.git_diff_added_lines) || 0);
|
|
50
|
+
const hasIns = diffAdds > 0 || commitInsertions > 0;
|
|
51
|
+
if (!hasIns)
|
|
52
|
+
return true;
|
|
53
|
+
const showEmpty = NO_AUTHORSHIP_PLACEHOLDER.test((show ?? '').trim());
|
|
54
|
+
// Cursor/git-ai often updates `show` when authorship binds; avoid publishing while both are still "empty".
|
|
55
|
+
if (showEmpty && attributed === 0 && !hasBreakdown)
|
|
56
|
+
return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* After `post-commit`, git-ai stats and `git-ai show` can lag behind the commit.
|
|
61
|
+
* Poll both until attribution is reflected (or timeout), then return the last stats snapshot.
|
|
62
|
+
*/
|
|
63
|
+
export async function waitForGitAiStats(cwd, ref, commitInsertions, opts) {
|
|
64
|
+
const maxWaitMs = opts?.maxWaitMs ?? 90_000;
|
|
65
|
+
const pollMs = opts?.pollMs ?? 500;
|
|
66
|
+
const deadline = Date.now() + maxWaitMs;
|
|
67
|
+
let last = null;
|
|
68
|
+
while (Date.now() < deadline) {
|
|
69
|
+
const [stats, show] = await Promise.all([getStats(cwd, ref), getShow(cwd, ref)]);
|
|
70
|
+
last = stats;
|
|
71
|
+
if (publishDataReady(stats, show, commitInsertions))
|
|
72
|
+
return stats;
|
|
73
|
+
await sleep(pollMs);
|
|
74
|
+
}
|
|
75
|
+
return last;
|
|
76
|
+
}
|
|
77
|
+
export async function getDiff(cwd, ref = 'HEAD') {
|
|
78
|
+
const r = await exec('git-ai', ['diff', ref, '--json'], { cwd, timeout: 15_000 });
|
|
79
|
+
if (r.exitCode !== 0)
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(r.stdout);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function getShow(cwd, ref = 'HEAD') {
|
|
89
|
+
const r = await exec('git-ai', ['show', ref], { cwd, timeout: 15_000 });
|
|
90
|
+
return r.exitCode === 0 ? r.stdout.trim() : null;
|
|
91
|
+
}
|
|
92
|
+
export async function getPrompt(cwd, promptId, commitRef = 'HEAD') {
|
|
93
|
+
const r = await exec('git-ai', ['show-prompt', promptId, '--commit', commitRef], {
|
|
94
|
+
cwd,
|
|
95
|
+
timeout: 10_000,
|
|
96
|
+
});
|
|
97
|
+
if (r.exitCode !== 0)
|
|
98
|
+
return null;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(r.stdout);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface CommitInfo {
|
|
2
|
+
sha: string;
|
|
3
|
+
parentShas: string[];
|
|
4
|
+
subject: string;
|
|
5
|
+
body: string;
|
|
6
|
+
authorName: string;
|
|
7
|
+
authorEmail: string;
|
|
8
|
+
committerName: string;
|
|
9
|
+
committerEmail: string;
|
|
10
|
+
authoredAt: string;
|
|
11
|
+
committedAt: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
additions: number;
|
|
14
|
+
deletions: number;
|
|
15
|
+
changedFiles: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function getCommitInfo(cwd: string, ref?: string): Promise<CommitInfo>;
|
|
18
|
+
export declare function getBranch(cwd: string): Promise<string>;
|
|
19
|
+
export declare function getOriginUrl(cwd: string): Promise<string | null>;
|
|
20
|
+
export declare function getRepoRoot(cwd: string): Promise<string>;
|
|
21
|
+
export declare function getUserEmail(cwd: string): Promise<string>;
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { exec, execOrThrow } from './exec.js';
|
|
2
|
+
export async function getCommitInfo(cwd, ref = 'HEAD') {
|
|
3
|
+
const fmt = [
|
|
4
|
+
'%H', // sha
|
|
5
|
+
'%P', // parents (space-separated)
|
|
6
|
+
'%s', // subject
|
|
7
|
+
'%b', // body
|
|
8
|
+
'%an', // author name
|
|
9
|
+
'%ae', // author email
|
|
10
|
+
'%cn', // committer name
|
|
11
|
+
'%ce', // committer email
|
|
12
|
+
'%aI', // author date ISO
|
|
13
|
+
'%cI', // commit date ISO
|
|
14
|
+
].join('%x00');
|
|
15
|
+
const log = await execOrThrow('git', ['log', '-1', `--format=${fmt}`, ref], { cwd });
|
|
16
|
+
const parts = log.stdout.trimEnd().split('\0');
|
|
17
|
+
const branch = await getBranch(cwd);
|
|
18
|
+
const stat = await execOrThrow('git', ['diff-tree', '--shortstat', ref], { cwd });
|
|
19
|
+
const { additions, deletions, changedFiles } = parseShortstat(stat.stdout);
|
|
20
|
+
return {
|
|
21
|
+
sha: parts[0] ?? '',
|
|
22
|
+
parentShas: (parts[1] ?? '').split(' ').filter(Boolean),
|
|
23
|
+
subject: parts[2] ?? '',
|
|
24
|
+
body: parts[3] ?? '',
|
|
25
|
+
authorName: parts[4] ?? '',
|
|
26
|
+
authorEmail: parts[5] ?? '',
|
|
27
|
+
committerName: parts[6] ?? '',
|
|
28
|
+
committerEmail: parts[7] ?? '',
|
|
29
|
+
authoredAt: parts[8] ?? '',
|
|
30
|
+
committedAt: parts[9] ?? '',
|
|
31
|
+
branch,
|
|
32
|
+
additions,
|
|
33
|
+
deletions,
|
|
34
|
+
changedFiles,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function getBranch(cwd) {
|
|
38
|
+
const r = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
39
|
+
return r.exitCode === 0 ? r.stdout.trim() : 'unknown';
|
|
40
|
+
}
|
|
41
|
+
export async function getOriginUrl(cwd) {
|
|
42
|
+
const r = await exec('git', ['remote', 'get-url', 'origin'], { cwd });
|
|
43
|
+
return r.exitCode === 0 ? r.stdout.trim() : null;
|
|
44
|
+
}
|
|
45
|
+
export async function getRepoRoot(cwd) {
|
|
46
|
+
const r = await execOrThrow('git', ['rev-parse', '--show-toplevel'], { cwd });
|
|
47
|
+
return r.stdout.trim();
|
|
48
|
+
}
|
|
49
|
+
export async function getUserEmail(cwd) {
|
|
50
|
+
const r = await exec('git', ['config', 'user.email'], { cwd });
|
|
51
|
+
return r.exitCode === 0 ? r.stdout.trim() : 'unknown';
|
|
52
|
+
}
|
|
53
|
+
function parseShortstat(raw) {
|
|
54
|
+
let additions = 0;
|
|
55
|
+
let deletions = 0;
|
|
56
|
+
let changedFiles = 0;
|
|
57
|
+
const filesMatch = raw.match(/(\d+) files? changed/);
|
|
58
|
+
if (filesMatch?.[1])
|
|
59
|
+
changedFiles = Number.parseInt(filesMatch[1], 10);
|
|
60
|
+
const insertMatch = raw.match(/(\d+) insertions?\(\+\)/);
|
|
61
|
+
if (insertMatch?.[1])
|
|
62
|
+
additions = Number.parseInt(insertMatch[1], 10);
|
|
63
|
+
const delMatch = raw.match(/(\d+) deletions?\(-\)/);
|
|
64
|
+
if (delMatch?.[1])
|
|
65
|
+
deletions = Number.parseInt(delMatch[1], 10);
|
|
66
|
+
return { additions, deletions, changedFiles };
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
export async function promptSecret(message) {
|
|
3
|
+
if (!process.stdin.isTTY) {
|
|
4
|
+
throw new Error('No TTY available for interactive token prompt. Set GENTRACE_API_KEY instead.');
|
|
5
|
+
}
|
|
6
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
process.stderr.write(message);
|
|
9
|
+
const prev = process.stdin.isRaw;
|
|
10
|
+
process.stdin.setRawMode?.(true);
|
|
11
|
+
let buf = '';
|
|
12
|
+
const onData = (ch) => {
|
|
13
|
+
const s = ch.toString();
|
|
14
|
+
if (s === '\n' || s === '\r') {
|
|
15
|
+
process.stdin.setRawMode?.(prev ?? false);
|
|
16
|
+
process.stdin.removeListener('data', onData);
|
|
17
|
+
process.stderr.write('\n');
|
|
18
|
+
rl.close();
|
|
19
|
+
resolve(buf);
|
|
20
|
+
}
|
|
21
|
+
else if (s === '\u0003') {
|
|
22
|
+
// ctrl-c
|
|
23
|
+
process.stderr.write('\n');
|
|
24
|
+
rl.close();
|
|
25
|
+
process.exit(130);
|
|
26
|
+
}
|
|
27
|
+
else if (s === '\u007f' || s === '\b') {
|
|
28
|
+
buf = buf.slice(0, -1);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
buf += s;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
process.stdin.on('data', onData);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function promptConfirm(message) {
|
|
38
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
41
|
+
rl.close();
|
|
42
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches a repository identifier against a list of skip patterns.
|
|
3
|
+
* Patterns can be globs (simple * matching), exact URLs, or paths.
|
|
4
|
+
* Aligned with Git AI's exclude_repositories semantics.
|
|
5
|
+
*/
|
|
6
|
+
export declare function matchesSkipList(repoId: string, patterns: string[]): boolean;
|
package/dist/lib/skip.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches a repository identifier against a list of skip patterns.
|
|
3
|
+
* Patterns can be globs (simple * matching), exact URLs, or paths.
|
|
4
|
+
* Aligned with Git AI's exclude_repositories semantics.
|
|
5
|
+
*/
|
|
6
|
+
export function matchesSkipList(repoId, patterns) {
|
|
7
|
+
return patterns.some((p) => matchPattern(repoId, p));
|
|
8
|
+
}
|
|
9
|
+
function matchPattern(repoId, pattern) {
|
|
10
|
+
if (pattern === '*')
|
|
11
|
+
return true;
|
|
12
|
+
if (pattern === repoId)
|
|
13
|
+
return true;
|
|
14
|
+
if (pattern.includes('*')) {
|
|
15
|
+
const regex = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
|
|
16
|
+
return regex.test(repoId);
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface TelemetryPostResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
status: number;
|
|
4
|
+
responseText: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* POST telemetry batch with exponential backoff when the server is unavailable or overloaded.
|
|
8
|
+
* Does not retry 401/403/409 (caller handles terminal auth / duplicate semantics).
|
|
9
|
+
*/
|
|
10
|
+
export declare function postTelemetryWithRetries(url: string, apiKey: string, bodyJson: string, opts?: {
|
|
11
|
+
maxAttempts?: number;
|
|
12
|
+
baseDelayMs?: number;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): Promise<TelemetryPostResult>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { debugLog } from './debug.js';
|
|
2
|
+
function sleep(ms) {
|
|
3
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
}
|
|
5
|
+
function isRetryableHttpStatus(status) {
|
|
6
|
+
return (status === 408 ||
|
|
7
|
+
status === 425 ||
|
|
8
|
+
status === 429 ||
|
|
9
|
+
status === 500 ||
|
|
10
|
+
status === 502 ||
|
|
11
|
+
status === 503 ||
|
|
12
|
+
status === 504);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* POST telemetry batch with exponential backoff when the server is unavailable or overloaded.
|
|
16
|
+
* Does not retry 401/403/409 (caller handles terminal auth / duplicate semantics).
|
|
17
|
+
*/
|
|
18
|
+
export async function postTelemetryWithRetries(url, apiKey, bodyJson, opts) {
|
|
19
|
+
const maxAttempts = opts?.maxAttempts ?? 6;
|
|
20
|
+
const baseDelayMs = opts?.baseDelayMs ?? 1500;
|
|
21
|
+
const timeoutMs = opts?.timeoutMs ?? 30_000;
|
|
22
|
+
let last = { ok: false, status: 0, responseText: '' };
|
|
23
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'X-API-Key': apiKey,
|
|
30
|
+
},
|
|
31
|
+
body: bodyJson,
|
|
32
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
+
});
|
|
34
|
+
const responseText = await res.text();
|
|
35
|
+
last = { ok: res.ok, status: res.status, responseText };
|
|
36
|
+
if (res.ok || res.status === 401 || res.status === 403 || res.status === 409) {
|
|
37
|
+
return last;
|
|
38
|
+
}
|
|
39
|
+
if (attempt < maxAttempts && isRetryableHttpStatus(res.status)) {
|
|
40
|
+
const wait = baseDelayMs * 2 ** (attempt - 1);
|
|
41
|
+
debugLog(`telemetry HTTP ${res.status}, retry in ${wait}ms (${attempt}/${maxAttempts})`);
|
|
42
|
+
await sleep(wait);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
return last;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
last = { ok: false, status: 0, responseText: message };
|
|
50
|
+
if (attempt < maxAttempts) {
|
|
51
|
+
const wait = baseDelayMs * 2 ** (attempt - 1);
|
|
52
|
+
debugLog(`telemetry fetch error, retry in ${wait}ms (${attempt}/${maxAttempts})`, err);
|
|
53
|
+
await sleep(wait);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
return last;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return last;
|
|
60
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qodly/gentrace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gentrace": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prepack": "npm run build",
|
|
17
|
+
"install:global": "bash scripts/install-global.sh",
|
|
18
|
+
"version:patch": "npm version patch --no-workspaces",
|
|
19
|
+
"version:minor": "npm version minor --no-workspaces",
|
|
20
|
+
"version:major": "npm version major --no-workspaces",
|
|
21
|
+
"publish:npm": "npm publish --no-workspaces",
|
|
22
|
+
"install:npm": "npm install --workspaces=false",
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"dev": "bun src/cli.ts",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
26
|
+
"test": "bun test --pass-with-no-tests"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"package.json",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@gentrace/types": "file:../../packages/shared-types",
|
|
36
|
+
"typescript": "^6.0.3",
|
|
37
|
+
"@types/bun": "latest",
|
|
38
|
+
"@types/node": "^25.7.0"
|
|
39
|
+
}
|
|
40
|
+
}
|