@yuaone/core 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/LICENSE +663 -0
- package/README.md +15 -0
- package/dist/__tests__/context-manager.test.d.ts +6 -0
- package/dist/__tests__/context-manager.test.d.ts.map +1 -0
- package/dist/__tests__/context-manager.test.js +220 -0
- package/dist/__tests__/context-manager.test.js.map +1 -0
- package/dist/__tests__/governor.test.d.ts +6 -0
- package/dist/__tests__/governor.test.d.ts.map +1 -0
- package/dist/__tests__/governor.test.js +210 -0
- package/dist/__tests__/governor.test.js.map +1 -0
- package/dist/__tests__/model-router.test.d.ts +6 -0
- package/dist/__tests__/model-router.test.d.ts.map +1 -0
- package/dist/__tests__/model-router.test.js +329 -0
- package/dist/__tests__/model-router.test.js.map +1 -0
- package/dist/agent-logger.d.ts +384 -0
- package/dist/agent-logger.d.ts.map +1 -0
- package/dist/agent-logger.js +820 -0
- package/dist/agent-logger.js.map +1 -0
- package/dist/agent-loop.d.ts +163 -0
- package/dist/agent-loop.d.ts.map +1 -0
- package/dist/agent-loop.js +609 -0
- package/dist/agent-loop.js.map +1 -0
- package/dist/agent-modes.d.ts +85 -0
- package/dist/agent-modes.d.ts.map +1 -0
- package/dist/agent-modes.js +418 -0
- package/dist/agent-modes.js.map +1 -0
- package/dist/approval.d.ts +137 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +299 -0
- package/dist/approval.js.map +1 -0
- package/dist/async-completion-queue.d.ts +56 -0
- package/dist/async-completion-queue.d.ts.map +1 -0
- package/dist/async-completion-queue.js +77 -0
- package/dist/async-completion-queue.js.map +1 -0
- package/dist/auto-fix.d.ts +174 -0
- package/dist/auto-fix.d.ts.map +1 -0
- package/dist/auto-fix.js +319 -0
- package/dist/auto-fix.js.map +1 -0
- package/dist/codebase-context.d.ts +396 -0
- package/dist/codebase-context.d.ts.map +1 -0
- package/dist/codebase-context.js +1260 -0
- package/dist/codebase-context.js.map +1 -0
- package/dist/conflict-resolver.d.ts +191 -0
- package/dist/conflict-resolver.d.ts.map +1 -0
- package/dist/conflict-resolver.js +524 -0
- package/dist/conflict-resolver.js.map +1 -0
- package/dist/constants.d.ts +52 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +141 -0
- package/dist/constants.js.map +1 -0
- package/dist/context-budget.d.ts +435 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +903 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/context-compressor.d.ts +143 -0
- package/dist/context-compressor.d.ts.map +1 -0
- package/dist/context-compressor.js +511 -0
- package/dist/context-compressor.js.map +1 -0
- package/dist/context-manager.d.ts +112 -0
- package/dist/context-manager.d.ts.map +1 -0
- package/dist/context-manager.js +247 -0
- package/dist/context-manager.js.map +1 -0
- package/dist/continuous-reflection.d.ts +267 -0
- package/dist/continuous-reflection.d.ts.map +1 -0
- package/dist/continuous-reflection.js +338 -0
- package/dist/continuous-reflection.js.map +1 -0
- package/dist/cross-file-refactor.d.ts +352 -0
- package/dist/cross-file-refactor.d.ts.map +1 -0
- package/dist/cross-file-refactor.js +1544 -0
- package/dist/cross-file-refactor.js.map +1 -0
- package/dist/dag-orchestrator.d.ts +138 -0
- package/dist/dag-orchestrator.d.ts.map +1 -0
- package/dist/dag-orchestrator.js +379 -0
- package/dist/dag-orchestrator.js.map +1 -0
- package/dist/debate-orchestrator.d.ts +301 -0
- package/dist/debate-orchestrator.d.ts.map +1 -0
- package/dist/debate-orchestrator.js +719 -0
- package/dist/debate-orchestrator.js.map +1 -0
- package/dist/dependency-analyzer.d.ts +113 -0
- package/dist/dependency-analyzer.d.ts.map +1 -0
- package/dist/dependency-analyzer.js +444 -0
- package/dist/dependency-analyzer.js.map +1 -0
- package/dist/design-loop.d.ts +59 -0
- package/dist/design-loop.d.ts.map +1 -0
- package/dist/design-loop.js +344 -0
- package/dist/design-loop.js.map +1 -0
- package/dist/doc-intelligence.d.ts +383 -0
- package/dist/doc-intelligence.d.ts.map +1 -0
- package/dist/doc-intelligence.js +1307 -0
- package/dist/doc-intelligence.js.map +1 -0
- package/dist/dynamic-role-generator.d.ts +76 -0
- package/dist/dynamic-role-generator.d.ts.map +1 -0
- package/dist/dynamic-role-generator.js +194 -0
- package/dist/dynamic-role-generator.js.map +1 -0
- package/dist/errors.d.ts +69 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +102 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-bus.d.ts +159 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +305 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/execution-engine.d.ts +425 -0
- package/dist/execution-engine.d.ts.map +1 -0
- package/dist/execution-engine.js +1555 -0
- package/dist/execution-engine.js.map +1 -0
- package/dist/git-intelligence.d.ts +306 -0
- package/dist/git-intelligence.d.ts.map +1 -0
- package/dist/git-intelligence.js +1099 -0
- package/dist/git-intelligence.js.map +1 -0
- package/dist/governor.d.ts +77 -0
- package/dist/governor.d.ts.map +1 -0
- package/dist/governor.js +161 -0
- package/dist/governor.js.map +1 -0
- package/dist/hierarchical-planner.d.ts +313 -0
- package/dist/hierarchical-planner.d.ts.map +1 -0
- package/dist/hierarchical-planner.js +981 -0
- package/dist/hierarchical-planner.js.map +1 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +123 -0
- package/dist/index.js.map +1 -0
- package/dist/intent-inference.d.ts +103 -0
- package/dist/intent-inference.d.ts.map +1 -0
- package/dist/intent-inference.js +605 -0
- package/dist/intent-inference.js.map +1 -0
- package/dist/interrupt-manager.d.ts +143 -0
- package/dist/interrupt-manager.d.ts.map +1 -0
- package/dist/interrupt-manager.js +196 -0
- package/dist/interrupt-manager.js.map +1 -0
- package/dist/kernel.d.ts +564 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1419 -0
- package/dist/kernel.js.map +1 -0
- package/dist/language-support.d.ts +232 -0
- package/dist/language-support.d.ts.map +1 -0
- package/dist/language-support.js +1134 -0
- package/dist/language-support.js.map +1 -0
- package/dist/llm-client.d.ts +82 -0
- package/dist/llm-client.d.ts.map +1 -0
- package/dist/llm-client.js +475 -0
- package/dist/llm-client.js.map +1 -0
- package/dist/mcp-client.d.ts +232 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +718 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/memory-manager.d.ts +200 -0
- package/dist/memory-manager.d.ts.map +1 -0
- package/dist/memory-manager.js +568 -0
- package/dist/memory-manager.js.map +1 -0
- package/dist/memory.d.ts +87 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +341 -0
- package/dist/memory.js.map +1 -0
- package/dist/model-router.d.ts +245 -0
- package/dist/model-router.d.ts.map +1 -0
- package/dist/model-router.js +632 -0
- package/dist/model-router.js.map +1 -0
- package/dist/parallel-executor.d.ts +125 -0
- package/dist/parallel-executor.d.ts.map +1 -0
- package/dist/parallel-executor.js +201 -0
- package/dist/parallel-executor.js.map +1 -0
- package/dist/perf-optimizer.d.ts +212 -0
- package/dist/perf-optimizer.d.ts.map +1 -0
- package/dist/perf-optimizer.js +721 -0
- package/dist/perf-optimizer.js.map +1 -0
- package/dist/persona.d.ts +305 -0
- package/dist/persona.d.ts.map +1 -0
- package/dist/persona.js +887 -0
- package/dist/persona.js.map +1 -0
- package/dist/planner.d.ts +70 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +264 -0
- package/dist/planner.js.map +1 -0
- package/dist/qa-pipeline.d.ts +365 -0
- package/dist/qa-pipeline.d.ts.map +1 -0
- package/dist/qa-pipeline.js +1352 -0
- package/dist/qa-pipeline.js.map +1 -0
- package/dist/reasoning-adapter.d.ts +116 -0
- package/dist/reasoning-adapter.d.ts.map +1 -0
- package/dist/reasoning-adapter.js +187 -0
- package/dist/reasoning-adapter.js.map +1 -0
- package/dist/role-registry.d.ts +55 -0
- package/dist/role-registry.d.ts.map +1 -0
- package/dist/role-registry.js +192 -0
- package/dist/role-registry.js.map +1 -0
- package/dist/sandbox-tiers.d.ts +327 -0
- package/dist/sandbox-tiers.d.ts.map +1 -0
- package/dist/sandbox-tiers.js +928 -0
- package/dist/sandbox-tiers.js.map +1 -0
- package/dist/security-scanner.d.ts +222 -0
- package/dist/security-scanner.d.ts.map +1 -0
- package/dist/security-scanner.js +1129 -0
- package/dist/security-scanner.js.map +1 -0
- package/dist/security.d.ts +93 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +393 -0
- package/dist/security.js.map +1 -0
- package/dist/self-reflection.d.ts +397 -0
- package/dist/self-reflection.d.ts.map +1 -0
- package/dist/self-reflection.js +908 -0
- package/dist/self-reflection.js.map +1 -0
- package/dist/session-persistence.d.ts +191 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +395 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/speculative-executor.d.ts +210 -0
- package/dist/speculative-executor.d.ts.map +1 -0
- package/dist/speculative-executor.js +618 -0
- package/dist/speculative-executor.js.map +1 -0
- package/dist/state-machine.d.ts +289 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +695 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/sub-agent.d.ts +177 -0
- package/dist/sub-agent.d.ts.map +1 -0
- package/dist/sub-agent.js +303 -0
- package/dist/sub-agent.js.map +1 -0
- package/dist/system-prompt.d.ts +26 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/system-prompt.js +84 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/test-intelligence.d.ts +439 -0
- package/dist/test-intelligence.d.ts.map +1 -0
- package/dist/test-intelligence.js +1165 -0
- package/dist/test-intelligence.js.map +1 -0
- package/dist/types.d.ts +632 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/vector-index.d.ts +314 -0
- package/dist/vector-index.d.ts.map +1 -0
- package/dist/vector-index.js +618 -0
- package/dist/vector-index.js.map +1 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module git-intelligence
|
|
3
|
+
* @description Git Intelligence module for the YUAN coding agent.
|
|
4
|
+
* Provides smart commit message generation, PR description synthesis,
|
|
5
|
+
* conflict prediction, history analysis, and branch management.
|
|
6
|
+
*
|
|
7
|
+
* Uses only `node:child_process` and `node:path` — no external dependencies.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
const execFile = promisify(execFileCb);
|
|
12
|
+
// ─── Constants ───
|
|
13
|
+
const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
|
|
14
|
+
const DOCS_FILE_RE = /\.(md|mdx|txt|rst)$|README|CHANGELOG|LICENSE/i;
|
|
15
|
+
const CONFIG_FILE_RE = /(package\.json|tsconfig.*\.json|\.eslintrc|\.prettierrc|jest\.config|vitest\.config|webpack\.config|vite\.config|rollup\.config|\.gitignore|\.npmrc|pnpm-workspace)/i;
|
|
16
|
+
const STYLE_FILE_RE = /\.(css|scss|sass|less|styl)$/;
|
|
17
|
+
const CI_FILE_RE = /(\.(github|gitlab)|Dockerfile|docker-compose|\.circleci|Jenkinsfile|\.travis)/i;
|
|
18
|
+
const BUILD_FILE_RE = /(Makefile|CMakeLists|\.cmake|build\.gradle|pom\.xml)/i;
|
|
19
|
+
const FIX_KEYWORDS_RE = /\b(fix|bug|error|crash|issue|patch|resolve|hotfix|regression|broken|typo|wrong|incorrect|NaN|undefined|null\s+check)\b/i;
|
|
20
|
+
const FEAT_KEYWORDS_RE = /\b(add|create|implement|introduce|new|support|enable|feature)\b/i;
|
|
21
|
+
const REFACTOR_KEYWORDS_RE = /\b(refactor|restructure|reorganize|simplify|extract|inline|rename|move|clean\s*up)\b/i;
|
|
22
|
+
const PERF_KEYWORDS_RE = /\b(perf|performance|optimize|speed|fast|cache|memoize|lazy|debounce|throttle)\b/i;
|
|
23
|
+
/** Pattern for detecting removed exports. */
|
|
24
|
+
const REMOVED_EXPORT_RE = /^-\s*export\s+(function|class|interface|type|enum|const|let|var)\s+(\w+)/gm;
|
|
25
|
+
/** Pattern for detecting renamed/changed exports. */
|
|
26
|
+
const CHANGED_SIGNATURE_RE = /^-\s*export\s+(?:function|const)\s+(\w+)\s*\(([^)]*)\)/gm;
|
|
27
|
+
const MAX_BRANCH_NAME_LEN = 50;
|
|
28
|
+
// ─── GitIntelligence Class ───
|
|
29
|
+
/**
|
|
30
|
+
* Git Intelligence engine for the YUAN coding agent.
|
|
31
|
+
*
|
|
32
|
+
* Analyzes git history, diffs, and blame data to generate smart commit messages,
|
|
33
|
+
* PR descriptions, conflict predictions, and codebase hotspot analysis.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const gi = new GitIntelligence({ projectPath: "/home/user/project" });
|
|
38
|
+
* const msg = await gi.generateCommitMessage(true);
|
|
39
|
+
* console.log(msg.fullMessage);
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class GitIntelligence {
|
|
43
|
+
config;
|
|
44
|
+
defaultBranch = null;
|
|
45
|
+
constructor(config) {
|
|
46
|
+
this.config = {
|
|
47
|
+
projectPath: config.projectPath,
|
|
48
|
+
defaultBranch: config.defaultBranch ?? "",
|
|
49
|
+
maxHistoryDepth: config.maxHistoryDepth ?? 100,
|
|
50
|
+
conventionalCommits: config.conventionalCommits ?? true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// ─── Commit Intelligence ───
|
|
54
|
+
/**
|
|
55
|
+
* Analyze staged or unstaged changes and generate a smart commit message.
|
|
56
|
+
* @param staged - If true, analyze staged changes (`--cached`). Default: true.
|
|
57
|
+
*/
|
|
58
|
+
async generateCommitMessage(staged = true) {
|
|
59
|
+
const diffArgs = staged ? ["diff", "--cached"] : ["diff"];
|
|
60
|
+
const diff = await this.git(diffArgs);
|
|
61
|
+
if (!diff.trim()) {
|
|
62
|
+
return {
|
|
63
|
+
subject: "chore: empty commit",
|
|
64
|
+
body: null,
|
|
65
|
+
footer: null,
|
|
66
|
+
fullMessage: "chore: empty commit",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const analysis = this.analyzeDiff(diff);
|
|
70
|
+
// Build subject
|
|
71
|
+
const scopePart = analysis.scope ? `(${analysis.scope})` : "";
|
|
72
|
+
const subject = `${analysis.type}${scopePart}: ${analysis.description}`;
|
|
73
|
+
// Build body (list changed files if > 3)
|
|
74
|
+
let body = null;
|
|
75
|
+
if (analysis.filesChanged > 3) {
|
|
76
|
+
const statDiff = staged
|
|
77
|
+
? await this.git(["diff", "--cached", "--stat"])
|
|
78
|
+
: await this.git(["diff", "--stat"]);
|
|
79
|
+
const lines = statDiff
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter((l) => l.includes("|"))
|
|
82
|
+
.map((l) => `- ${l.trim()}`)
|
|
83
|
+
.slice(0, 10);
|
|
84
|
+
body = lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
if (analysis.body) {
|
|
87
|
+
body = analysis.body + (body ? "\n\n" + body : "");
|
|
88
|
+
}
|
|
89
|
+
// Build footer (breaking changes)
|
|
90
|
+
let footer = null;
|
|
91
|
+
if (analysis.breakingChange) {
|
|
92
|
+
footer = `BREAKING CHANGE: ${analysis.breakingChange.description}`;
|
|
93
|
+
if (analysis.breakingChange.affectedExports.length > 0) {
|
|
94
|
+
footer += `\nAffected exports: ${analysis.breakingChange.affectedExports.join(", ")}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Combine
|
|
98
|
+
const parts = [subject];
|
|
99
|
+
if (body)
|
|
100
|
+
parts.push("", body);
|
|
101
|
+
if (footer)
|
|
102
|
+
parts.push("", footer);
|
|
103
|
+
return {
|
|
104
|
+
subject,
|
|
105
|
+
body,
|
|
106
|
+
footer,
|
|
107
|
+
fullMessage: parts.join("\n"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Analyze a raw diff string and produce a CommitAnalysis.
|
|
112
|
+
* @param diff - Raw `git diff` output.
|
|
113
|
+
*/
|
|
114
|
+
analyzeDiff(diff) {
|
|
115
|
+
const parsed = this.parseDiff(diff);
|
|
116
|
+
const files = parsed.files.map((f) => f.path);
|
|
117
|
+
const allHunks = parsed.files.flatMap((f) => f.hunks).join("\n");
|
|
118
|
+
// Count insertions/deletions from diff lines
|
|
119
|
+
let insertions = 0;
|
|
120
|
+
let deletions = 0;
|
|
121
|
+
for (const line of diff.split("\n")) {
|
|
122
|
+
if (line.startsWith("+") && !line.startsWith("+++"))
|
|
123
|
+
insertions++;
|
|
124
|
+
else if (line.startsWith("-") && !line.startsWith("---"))
|
|
125
|
+
deletions++;
|
|
126
|
+
}
|
|
127
|
+
const type = this.inferCommitType(diff, files);
|
|
128
|
+
const scope = this.inferScope(files);
|
|
129
|
+
const description = this.generateDescription(allHunks, type);
|
|
130
|
+
const breakingChanges = this.detectBreakingChanges(diff);
|
|
131
|
+
const breakingChange = breakingChanges.length > 0 ? breakingChanges[0] : null;
|
|
132
|
+
return {
|
|
133
|
+
type,
|
|
134
|
+
scope,
|
|
135
|
+
description,
|
|
136
|
+
body: null,
|
|
137
|
+
breakingChange,
|
|
138
|
+
filesChanged: files.length,
|
|
139
|
+
insertions,
|
|
140
|
+
deletions,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Detect breaking changes in a diff by looking for removed/changed exports.
|
|
145
|
+
* @param diff - Raw `git diff` output.
|
|
146
|
+
*/
|
|
147
|
+
detectBreakingChanges(diff) {
|
|
148
|
+
const results = [];
|
|
149
|
+
const affectedExports = [];
|
|
150
|
+
// Detect removed exports
|
|
151
|
+
let match;
|
|
152
|
+
const removedRe = new RegExp(REMOVED_EXPORT_RE.source, "gm");
|
|
153
|
+
while ((match = removedRe.exec(diff)) !== null) {
|
|
154
|
+
affectedExports.push(match[2]);
|
|
155
|
+
}
|
|
156
|
+
// Detect changed function signatures
|
|
157
|
+
const changedRe = new RegExp(CHANGED_SIGNATURE_RE.source, "gm");
|
|
158
|
+
const changedNames = [];
|
|
159
|
+
while ((match = changedRe.exec(diff)) !== null) {
|
|
160
|
+
changedNames.push(match[1]);
|
|
161
|
+
}
|
|
162
|
+
// Check if the same function was re-added with different signature
|
|
163
|
+
for (const name of changedNames) {
|
|
164
|
+
const addedRe = new RegExp(`^\\+\\s*export\\s+(?:function|const)\\s+${name}\\s*\\(([^)]*)\\)`, "m");
|
|
165
|
+
const addedMatch = addedRe.exec(diff);
|
|
166
|
+
if (addedMatch) {
|
|
167
|
+
// Signature changed
|
|
168
|
+
if (!affectedExports.includes(name)) {
|
|
169
|
+
affectedExports.push(name);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (affectedExports.length > 0) {
|
|
174
|
+
results.push({
|
|
175
|
+
description: `Exported API changed: ${affectedExports.join(", ")}`,
|
|
176
|
+
affectedExports,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return results;
|
|
180
|
+
}
|
|
181
|
+
// ─── PR Intelligence ───
|
|
182
|
+
/**
|
|
183
|
+
* Generate a PR description by analyzing the diff between current branch and base.
|
|
184
|
+
* @param baseBranch - Base branch to diff against; auto-detected if omitted.
|
|
185
|
+
*/
|
|
186
|
+
async generatePRDescription(baseBranch) {
|
|
187
|
+
const base = baseBranch ?? (await this.detectDefaultBranch());
|
|
188
|
+
const currentBranch = (await this.git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
189
|
+
// Get commits on this branch
|
|
190
|
+
const logOutput = await this.git([
|
|
191
|
+
"log",
|
|
192
|
+
`${base}..HEAD`,
|
|
193
|
+
"--oneline",
|
|
194
|
+
"--format=%H|%s|%an|%aI",
|
|
195
|
+
]);
|
|
196
|
+
const commits = this.parseLog(logOutput);
|
|
197
|
+
// Get diff summary
|
|
198
|
+
const diffSummary = await this.getDiffSummary(base);
|
|
199
|
+
// Analyze each commit
|
|
200
|
+
const analyses = [];
|
|
201
|
+
for (const commit of commits.slice(0, 20)) {
|
|
202
|
+
const commitDiff = await this.git(["show", commit.hash, "--format="]);
|
|
203
|
+
if (commitDiff.trim()) {
|
|
204
|
+
analyses.push(this.analyzeDiff(commitDiff));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Determine primary type
|
|
208
|
+
const typeCounts = new Map();
|
|
209
|
+
for (const a of analyses) {
|
|
210
|
+
typeCounts.set(a.type, (typeCounts.get(a.type) ?? 0) + 1);
|
|
211
|
+
}
|
|
212
|
+
const primaryType = [...typeCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "chore";
|
|
213
|
+
// Primary scope
|
|
214
|
+
const scopeCounts = new Map();
|
|
215
|
+
for (const a of analyses) {
|
|
216
|
+
if (a.scope)
|
|
217
|
+
scopeCounts.set(a.scope, (scopeCounts.get(a.scope) ?? 0) + 1);
|
|
218
|
+
}
|
|
219
|
+
const primaryScope = [...scopeCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
220
|
+
// Title
|
|
221
|
+
const scopePart = primaryScope ? `(${primaryScope})` : "";
|
|
222
|
+
const title = `${primaryType}${scopePart}: ${commits[0]?.subject ?? currentBranch}`;
|
|
223
|
+
// Summary bullets
|
|
224
|
+
const summary = commits.map((c) => c.subject);
|
|
225
|
+
// Changes breakdown
|
|
226
|
+
const changesBreakdown = diffSummary.map((entry) => {
|
|
227
|
+
const desc = entry.status === "A"
|
|
228
|
+
? "New file"
|
|
229
|
+
: entry.status === "D"
|
|
230
|
+
? "Deleted"
|
|
231
|
+
: `Modified (+${entry.insertions}/-${entry.deletions})`;
|
|
232
|
+
return { file: entry.file, description: desc };
|
|
233
|
+
});
|
|
234
|
+
// Breaking changes
|
|
235
|
+
const breakingChanges = [];
|
|
236
|
+
for (const a of analyses) {
|
|
237
|
+
if (a.breakingChange) {
|
|
238
|
+
breakingChanges.push(a.breakingChange.description);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Reviewers
|
|
242
|
+
const changedFiles = diffSummary.map((d) => d.file);
|
|
243
|
+
const reviewers = await this.suggestReviewers(changedFiles);
|
|
244
|
+
// Labels
|
|
245
|
+
const labels = this.suggestLabelsFromAnalyses(analyses);
|
|
246
|
+
// Test plan
|
|
247
|
+
const testPlan = this.generateTestPlan(diffSummary, analyses);
|
|
248
|
+
return {
|
|
249
|
+
title: title.length > 70 ? title.slice(0, 67) + "..." : title,
|
|
250
|
+
summary,
|
|
251
|
+
changesBreakdown,
|
|
252
|
+
testPlan,
|
|
253
|
+
breakingChanges,
|
|
254
|
+
reviewers,
|
|
255
|
+
labels,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Suggest reviewers based on git blame of the given files.
|
|
260
|
+
* @param files - File paths to check; if omitted, uses files changed vs default branch.
|
|
261
|
+
*/
|
|
262
|
+
async suggestReviewers(files) {
|
|
263
|
+
const fileList = files ??
|
|
264
|
+
(await this.git(["diff", "--name-only", `${await this.detectDefaultBranch()}..HEAD`]))
|
|
265
|
+
.trim()
|
|
266
|
+
.split("\n")
|
|
267
|
+
.filter(Boolean);
|
|
268
|
+
const authorCounts = new Map();
|
|
269
|
+
const currentUser = (await this.git(["config", "user.name"]).catch(() => "")).trim();
|
|
270
|
+
for (const file of fileList.slice(0, 10)) {
|
|
271
|
+
try {
|
|
272
|
+
const blameOutput = await this.git(["blame", "--porcelain", "-L", "1,50", "--", file]);
|
|
273
|
+
const parsed = this.parseBlame(blameOutput);
|
|
274
|
+
for (const entry of parsed) {
|
|
275
|
+
if (entry.author && entry.author !== currentUser && entry.author !== "Not Committed Yet") {
|
|
276
|
+
authorCounts.set(entry.author, (authorCounts.get(entry.author) ?? 0) + 1);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// File may not exist on current branch, skip
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return [...authorCounts.entries()]
|
|
285
|
+
.sort((a, b) => b[1] - a[1])
|
|
286
|
+
.slice(0, 5)
|
|
287
|
+
.map(([name]) => name);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Suggest labels based on a CommitAnalysis.
|
|
291
|
+
* @param analysis - The commit analysis to derive labels from.
|
|
292
|
+
*/
|
|
293
|
+
suggestLabels(analysis) {
|
|
294
|
+
return this.suggestLabelsFromAnalyses([analysis]);
|
|
295
|
+
}
|
|
296
|
+
// ─── Branch Intelligence ───
|
|
297
|
+
/**
|
|
298
|
+
* Suggest a branch name based on a task description.
|
|
299
|
+
* @param taskDescription - Natural-language description of the task.
|
|
300
|
+
*/
|
|
301
|
+
suggestBranchName(taskDescription) {
|
|
302
|
+
const lower = taskDescription.toLowerCase();
|
|
303
|
+
let type;
|
|
304
|
+
if (FIX_KEYWORDS_RE.test(lower)) {
|
|
305
|
+
type = "fix";
|
|
306
|
+
}
|
|
307
|
+
else if (FEAT_KEYWORDS_RE.test(lower)) {
|
|
308
|
+
type = "feat";
|
|
309
|
+
}
|
|
310
|
+
else if (REFACTOR_KEYWORDS_RE.test(lower)) {
|
|
311
|
+
type = "refactor";
|
|
312
|
+
}
|
|
313
|
+
else if (PERF_KEYWORDS_RE.test(lower)) {
|
|
314
|
+
type = "perf";
|
|
315
|
+
}
|
|
316
|
+
else if (/\b(doc|readme|changelog)\b/i.test(lower)) {
|
|
317
|
+
type = "docs";
|
|
318
|
+
}
|
|
319
|
+
else if (/\b(test|spec|coverage)\b/i.test(lower)) {
|
|
320
|
+
type = "test";
|
|
321
|
+
}
|
|
322
|
+
else if (/\b(ci|deploy|pipeline|workflow)\b/i.test(lower)) {
|
|
323
|
+
type = "ci";
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
type = "feat";
|
|
327
|
+
}
|
|
328
|
+
const slug = this.sanitizeBranchName(taskDescription);
|
|
329
|
+
const name = `${type}/${slug}`;
|
|
330
|
+
const basedOn = this.config.defaultBranch || "main";
|
|
331
|
+
return {
|
|
332
|
+
name,
|
|
333
|
+
basedOn,
|
|
334
|
+
reason: `Detected "${type}" intent from task description`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Detect the default branch of the repository (main, master, develop, etc.).
|
|
339
|
+
* Caches the result after first detection.
|
|
340
|
+
*/
|
|
341
|
+
async detectDefaultBranch() {
|
|
342
|
+
if (this.config.defaultBranch)
|
|
343
|
+
return this.config.defaultBranch;
|
|
344
|
+
if (this.defaultBranch)
|
|
345
|
+
return this.defaultBranch;
|
|
346
|
+
// Try origin/HEAD
|
|
347
|
+
try {
|
|
348
|
+
const ref = (await this.git(["symbolic-ref", "refs/remotes/origin/HEAD"])).trim();
|
|
349
|
+
const branch = ref.replace("refs/remotes/origin/", "");
|
|
350
|
+
if (branch) {
|
|
351
|
+
this.defaultBranch = branch;
|
|
352
|
+
return branch;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Not set
|
|
357
|
+
}
|
|
358
|
+
// Check for common branch names
|
|
359
|
+
for (const candidate of ["main", "master", "develop"]) {
|
|
360
|
+
try {
|
|
361
|
+
await this.git(["rev-parse", "--verify", candidate]);
|
|
362
|
+
this.defaultBranch = candidate;
|
|
363
|
+
return candidate;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Branch doesn't exist
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
this.defaultBranch = "main";
|
|
370
|
+
return "main";
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Predict potential merge conflicts between current branch and a target branch.
|
|
374
|
+
* @param targetBranch - Branch to check against; defaults to default branch.
|
|
375
|
+
*/
|
|
376
|
+
async predictConflicts(targetBranch) {
|
|
377
|
+
const target = targetBranch ?? (await this.detectDefaultBranch());
|
|
378
|
+
const predictions = [];
|
|
379
|
+
// Files changed on current branch
|
|
380
|
+
let ourFiles;
|
|
381
|
+
try {
|
|
382
|
+
ourFiles = (await this.git(["diff", "--name-only", `${target}...HEAD`]))
|
|
383
|
+
.trim()
|
|
384
|
+
.split("\n")
|
|
385
|
+
.filter(Boolean);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
// Files changed on target branch since merge base
|
|
391
|
+
let theirFiles;
|
|
392
|
+
try {
|
|
393
|
+
theirFiles = (await this.git(["diff", "--name-only", `HEAD...${target}`]))
|
|
394
|
+
.trim()
|
|
395
|
+
.split("\n")
|
|
396
|
+
.filter(Boolean);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
// Find intersection
|
|
402
|
+
const theirSet = new Set(theirFiles);
|
|
403
|
+
const overlapping = ourFiles.filter((f) => theirSet.has(f));
|
|
404
|
+
for (const file of overlapping) {
|
|
405
|
+
let lastModifiedBy;
|
|
406
|
+
let lastModifiedAt;
|
|
407
|
+
try {
|
|
408
|
+
const logLine = (await this.git(["log", "-1", `--format=%an|%aI`, target, "--", file])).trim();
|
|
409
|
+
const parts = logLine.split("|");
|
|
410
|
+
if (parts.length >= 2) {
|
|
411
|
+
lastModifiedBy = parts[0];
|
|
412
|
+
lastModifiedAt = parts[1];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// skip
|
|
417
|
+
}
|
|
418
|
+
// Estimate risk by checking if same hunks are modified
|
|
419
|
+
let risk = "medium";
|
|
420
|
+
let reason = `File modified on both branches`;
|
|
421
|
+
try {
|
|
422
|
+
// Get our changes to the file
|
|
423
|
+
const ourDiff = await this.git(["diff", `${target}...HEAD`, "--", file]);
|
|
424
|
+
const theirDiff = await this.git(["diff", `HEAD...${target}`, "--", file]);
|
|
425
|
+
// Extract line ranges from hunks
|
|
426
|
+
const ourRanges = this.extractHunkRanges(ourDiff);
|
|
427
|
+
const theirRanges = this.extractHunkRanges(theirDiff);
|
|
428
|
+
// Check for overlapping ranges
|
|
429
|
+
const hasOverlap = ourRanges.some((ourRange) => theirRanges.some((theirRange) => ourRange.start <= theirRange.end && theirRange.start <= ourRange.end));
|
|
430
|
+
if (hasOverlap) {
|
|
431
|
+
risk = "high";
|
|
432
|
+
reason = "Same code regions modified on both branches";
|
|
433
|
+
}
|
|
434
|
+
else if (ourRanges.length > 0 && theirRanges.length > 0) {
|
|
435
|
+
risk = "medium";
|
|
436
|
+
reason = "Different regions modified in the same file";
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
risk = "low";
|
|
440
|
+
reason = "Changes are in non-overlapping areas";
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// Could not do detailed analysis, keep medium
|
|
445
|
+
}
|
|
446
|
+
predictions.push({
|
|
447
|
+
file,
|
|
448
|
+
risk,
|
|
449
|
+
reason,
|
|
450
|
+
otherBranch: target,
|
|
451
|
+
lastModifiedBy,
|
|
452
|
+
lastModifiedAt,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
// Sort by risk: high → medium → low
|
|
456
|
+
const riskOrder = { high: 0, medium: 1, low: 2 };
|
|
457
|
+
predictions.sort((a, b) => (riskOrder[a.risk] ?? 1) - (riskOrder[b.risk] ?? 1));
|
|
458
|
+
return predictions;
|
|
459
|
+
}
|
|
460
|
+
// ─── History Analysis ───
|
|
461
|
+
/**
|
|
462
|
+
* Find frequently-changed files (hotspots) in the repository.
|
|
463
|
+
* @param days - Number of days to look back; default 30.
|
|
464
|
+
*/
|
|
465
|
+
async findHotspots(days = 30) {
|
|
466
|
+
const since = `${days} days ago`;
|
|
467
|
+
// Get file change counts
|
|
468
|
+
const logOutput = await this.git([
|
|
469
|
+
"log",
|
|
470
|
+
`--since=${since}`,
|
|
471
|
+
"--name-only",
|
|
472
|
+
"--format=COMMIT:%H|%aI|%s",
|
|
473
|
+
]);
|
|
474
|
+
const lines = logOutput.split("\n");
|
|
475
|
+
const fileCounts = new Map();
|
|
476
|
+
const fileAuthors = new Map();
|
|
477
|
+
let currentDate = "";
|
|
478
|
+
let currentSubject = "";
|
|
479
|
+
for (const line of lines) {
|
|
480
|
+
if (line.startsWith("COMMIT:")) {
|
|
481
|
+
const parts = line.slice(7).split("|");
|
|
482
|
+
currentDate = parts[1] ?? "";
|
|
483
|
+
currentSubject = parts.slice(2).join("|");
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const file = line.trim();
|
|
487
|
+
if (!file)
|
|
488
|
+
continue;
|
|
489
|
+
const entry = fileCounts.get(file) ?? { count: 0, dates: [], subjects: [] };
|
|
490
|
+
entry.count++;
|
|
491
|
+
if (currentDate)
|
|
492
|
+
entry.dates.push(currentDate);
|
|
493
|
+
entry.subjects.push(currentSubject);
|
|
494
|
+
fileCounts.set(file, entry);
|
|
495
|
+
// Track authors per file
|
|
496
|
+
if (!fileAuthors.has(file))
|
|
497
|
+
fileAuthors.set(file, new Set());
|
|
498
|
+
}
|
|
499
|
+
// Get authors for top files
|
|
500
|
+
const sorted = [...fileCounts.entries()].sort((a, b) => b[1].count - a[1].count);
|
|
501
|
+
const topFiles = sorted.slice(0, 30);
|
|
502
|
+
for (const [file] of topFiles) {
|
|
503
|
+
try {
|
|
504
|
+
const authorLog = await this.git([
|
|
505
|
+
"log",
|
|
506
|
+
`--since=${since}`,
|
|
507
|
+
"--format=%an",
|
|
508
|
+
"--",
|
|
509
|
+
file,
|
|
510
|
+
]);
|
|
511
|
+
const authors = new Set(authorLog.trim().split("\n").filter(Boolean));
|
|
512
|
+
fileAuthors.set(file, authors);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// skip
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const weeks = Math.max(days / 7, 1);
|
|
519
|
+
return topFiles.map(([file, data]) => {
|
|
520
|
+
const bugFixCount = data.subjects.filter((s) => FIX_KEYWORDS_RE.test(s)).length;
|
|
521
|
+
const lastDate = data.dates.sort().pop() ?? new Date().toISOString();
|
|
522
|
+
return {
|
|
523
|
+
file,
|
|
524
|
+
changeCount: data.count,
|
|
525
|
+
authors: [...(fileAuthors.get(file) ?? [])],
|
|
526
|
+
lastChanged: lastDate,
|
|
527
|
+
churnRate: Math.round((data.count / weeks) * 100) / 100,
|
|
528
|
+
bugFixRate: data.count > 0 ? Math.round((bugFixCount / data.count) * 100) : 0,
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Get the change frequency (commit count) for specific files.
|
|
534
|
+
* @param files - File paths to check.
|
|
535
|
+
*/
|
|
536
|
+
async getChangeFrequency(files) {
|
|
537
|
+
const result = new Map();
|
|
538
|
+
const depth = this.config.maxHistoryDepth;
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
try {
|
|
541
|
+
const output = await this.git([
|
|
542
|
+
"log",
|
|
543
|
+
`--max-count=${depth}`,
|
|
544
|
+
"--oneline",
|
|
545
|
+
"--",
|
|
546
|
+
file,
|
|
547
|
+
]);
|
|
548
|
+
const count = output.trim().split("\n").filter(Boolean).length;
|
|
549
|
+
result.set(file, count);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
result.set(file, 0);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Get contributors for a specific file.
|
|
559
|
+
* @param file - File path relative to repo root.
|
|
560
|
+
*/
|
|
561
|
+
async getFileContributors(file) {
|
|
562
|
+
try {
|
|
563
|
+
const output = await this.git([
|
|
564
|
+
"log",
|
|
565
|
+
`--max-count=${this.config.maxHistoryDepth}`,
|
|
566
|
+
"--format=%an|%aI",
|
|
567
|
+
"--",
|
|
568
|
+
file,
|
|
569
|
+
]);
|
|
570
|
+
const authorMap = new Map();
|
|
571
|
+
for (const line of output.trim().split("\n").filter(Boolean)) {
|
|
572
|
+
const [name, date] = line.split("|");
|
|
573
|
+
if (!name || !date)
|
|
574
|
+
continue;
|
|
575
|
+
const entry = authorMap.get(name);
|
|
576
|
+
if (entry) {
|
|
577
|
+
entry.commits++;
|
|
578
|
+
if (date > entry.lastCommit)
|
|
579
|
+
entry.lastCommit = date;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
authorMap.set(name, { commits: 1, lastCommit: date });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return [...authorMap.entries()]
|
|
586
|
+
.map(([name, data]) => ({ name, ...data }))
|
|
587
|
+
.sort((a, b) => b.commits - a.commits);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return [];
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Analyze commit patterns for the repo or a specific file.
|
|
595
|
+
* @param file - Optional file path to narrow analysis.
|
|
596
|
+
*/
|
|
597
|
+
async analyzeCommitPatterns(file) {
|
|
598
|
+
const args = [
|
|
599
|
+
"log",
|
|
600
|
+
`--max-count=${this.config.maxHistoryDepth}`,
|
|
601
|
+
"--format=%aI|%s",
|
|
602
|
+
];
|
|
603
|
+
if (file)
|
|
604
|
+
args.push("--", file);
|
|
605
|
+
const output = await this.git(args);
|
|
606
|
+
const lines = output.trim().split("\n").filter(Boolean);
|
|
607
|
+
const typeBreakdown = {};
|
|
608
|
+
const dayCounts = new Map();
|
|
609
|
+
const dates = [];
|
|
610
|
+
for (const line of lines) {
|
|
611
|
+
const pipeIdx = line.indexOf("|");
|
|
612
|
+
if (pipeIdx < 0)
|
|
613
|
+
continue;
|
|
614
|
+
const dateStr = line.slice(0, pipeIdx);
|
|
615
|
+
const subject = line.slice(pipeIdx + 1);
|
|
616
|
+
const day = dateStr.slice(0, 10); // YYYY-MM-DD
|
|
617
|
+
dates.push(dateStr);
|
|
618
|
+
dayCounts.set(day, (dayCounts.get(day) ?? 0) + 1);
|
|
619
|
+
// Detect type from conventional commit prefix
|
|
620
|
+
const conventionalMatch = /^(\w+)(?:\([^)]*\))?!?:/.exec(subject);
|
|
621
|
+
const type = conventionalMatch ? conventionalMatch[1] : "other";
|
|
622
|
+
typeBreakdown[type] = (typeBreakdown[type] ?? 0) + 1;
|
|
623
|
+
}
|
|
624
|
+
// Busy days (top 5)
|
|
625
|
+
const busyDays = [...dayCounts.entries()]
|
|
626
|
+
.sort((a, b) => b[1] - a[1])
|
|
627
|
+
.slice(0, 5)
|
|
628
|
+
.map(([day]) => day);
|
|
629
|
+
// Average commits per week
|
|
630
|
+
let avgCommitsPerWeek = 0;
|
|
631
|
+
if (dates.length >= 2) {
|
|
632
|
+
const oldest = new Date(dates[dates.length - 1]);
|
|
633
|
+
const newest = new Date(dates[0]);
|
|
634
|
+
const diffMs = newest.getTime() - oldest.getTime();
|
|
635
|
+
const weeks = Math.max(diffMs / (7 * 24 * 60 * 60 * 1000), 1);
|
|
636
|
+
avgCommitsPerWeek = Math.round((lines.length / weeks) * 100) / 100;
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
totalCommits: lines.length,
|
|
640
|
+
typeBreakdown,
|
|
641
|
+
busyDays,
|
|
642
|
+
avgCommitsPerWeek,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
// ─── Status ───
|
|
646
|
+
/**
|
|
647
|
+
* Get a summary of the current git status.
|
|
648
|
+
*/
|
|
649
|
+
async getStatus() {
|
|
650
|
+
const branch = (await this.git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
651
|
+
// Count staged/unstaged
|
|
652
|
+
const statusOutput = await this.git(["status", "--porcelain"]);
|
|
653
|
+
const statusLines = statusOutput.trim().split("\n").filter(Boolean);
|
|
654
|
+
let stagedChanges = 0;
|
|
655
|
+
let uncommittedChanges = 0;
|
|
656
|
+
for (const line of statusLines) {
|
|
657
|
+
const index = line[0];
|
|
658
|
+
const worktree = line[1];
|
|
659
|
+
if (index && index !== " " && index !== "?")
|
|
660
|
+
stagedChanges++;
|
|
661
|
+
if (worktree && worktree !== " " && worktree !== "?")
|
|
662
|
+
uncommittedChanges++;
|
|
663
|
+
if (index === "?")
|
|
664
|
+
uncommittedChanges++;
|
|
665
|
+
}
|
|
666
|
+
// Ahead/behind
|
|
667
|
+
let aheadOfRemote = 0;
|
|
668
|
+
let behindRemote = 0;
|
|
669
|
+
try {
|
|
670
|
+
const abOutput = (await this.git(["rev-list", "--left-right", "--count", `HEAD...@{upstream}`])).trim();
|
|
671
|
+
const parts = abOutput.split(/\s+/);
|
|
672
|
+
aheadOfRemote = parseInt(parts[0] ?? "0", 10) || 0;
|
|
673
|
+
behindRemote = parseInt(parts[1] ?? "0", 10) || 0;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// No upstream configured
|
|
677
|
+
}
|
|
678
|
+
// Recent commits
|
|
679
|
+
const logOutput = await this.git([
|
|
680
|
+
"log",
|
|
681
|
+
"--max-count=10",
|
|
682
|
+
"--format=%H|%s|%an|%aI",
|
|
683
|
+
]);
|
|
684
|
+
const recentCommits = this.parseLog(logOutput);
|
|
685
|
+
return {
|
|
686
|
+
currentBranch: branch,
|
|
687
|
+
uncommittedChanges,
|
|
688
|
+
stagedChanges,
|
|
689
|
+
aheadOfRemote,
|
|
690
|
+
behindRemote,
|
|
691
|
+
recentCommits,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Check whether the working directory is clean (no staged or unstaged changes).
|
|
696
|
+
*/
|
|
697
|
+
async isClean() {
|
|
698
|
+
const status = await this.git(["status", "--porcelain"]);
|
|
699
|
+
return status.trim().length === 0;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Get a per-file diff summary against a base branch.
|
|
703
|
+
* @param baseBranch - Base branch for comparison; defaults to default branch.
|
|
704
|
+
*/
|
|
705
|
+
async getDiffSummary(baseBranch) {
|
|
706
|
+
const base = baseBranch ?? (await this.detectDefaultBranch());
|
|
707
|
+
const output = await this.git(["diff", "--numstat", `${base}..HEAD`]);
|
|
708
|
+
const results = [];
|
|
709
|
+
for (const line of output.trim().split("\n").filter(Boolean)) {
|
|
710
|
+
const parts = line.split("\t");
|
|
711
|
+
if (parts.length < 3)
|
|
712
|
+
continue;
|
|
713
|
+
const insertions = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
|
|
714
|
+
const deletions = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
|
|
715
|
+
const file = parts[2];
|
|
716
|
+
// Determine status
|
|
717
|
+
let status = "M";
|
|
718
|
+
try {
|
|
719
|
+
const nameStatus = await this.git([
|
|
720
|
+
"diff",
|
|
721
|
+
"--name-status",
|
|
722
|
+
`${base}..HEAD`,
|
|
723
|
+
"--",
|
|
724
|
+
file,
|
|
725
|
+
]);
|
|
726
|
+
const s = nameStatus.trim().split("\t")[0];
|
|
727
|
+
if (s)
|
|
728
|
+
status = s[0];
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// keep M
|
|
732
|
+
}
|
|
733
|
+
results.push({ file, insertions, deletions, status });
|
|
734
|
+
}
|
|
735
|
+
return results;
|
|
736
|
+
}
|
|
737
|
+
// ─── Private Methods ───
|
|
738
|
+
/**
|
|
739
|
+
* Execute a git command in the project directory.
|
|
740
|
+
* @param args - Arguments to pass to `git`.
|
|
741
|
+
* @returns stdout output.
|
|
742
|
+
*/
|
|
743
|
+
async git(args) {
|
|
744
|
+
try {
|
|
745
|
+
const { stdout } = await execFile("git", args, {
|
|
746
|
+
cwd: this.config.projectPath,
|
|
747
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
748
|
+
timeout: 30_000,
|
|
749
|
+
});
|
|
750
|
+
return stdout;
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
const error = err;
|
|
754
|
+
throw new Error(`git ${args.join(" ")} failed: ${error.stderr ?? error.message ?? "unknown"}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Parse raw `git diff` output into structured file/hunk data.
|
|
759
|
+
* @param diff - Raw diff output.
|
|
760
|
+
*/
|
|
761
|
+
parseDiff(diff) {
|
|
762
|
+
const files = [];
|
|
763
|
+
const fileSections = diff.split(/^diff --git /m).filter(Boolean);
|
|
764
|
+
for (const section of fileSections) {
|
|
765
|
+
// Extract file path from "a/path b/path"
|
|
766
|
+
const headerMatch = /^a\/(.+?) b\/(.+)/m.exec(section);
|
|
767
|
+
const path = headerMatch?.[2] ?? headerMatch?.[1] ?? "unknown";
|
|
768
|
+
// Extract hunks (@@...@@)
|
|
769
|
+
const hunks = [];
|
|
770
|
+
const hunkParts = section.split(/^@@/m);
|
|
771
|
+
for (let i = 1; i < hunkParts.length; i++) {
|
|
772
|
+
hunks.push("@@" + hunkParts[i]);
|
|
773
|
+
}
|
|
774
|
+
files.push({ path, hunks });
|
|
775
|
+
}
|
|
776
|
+
return { files };
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Infer the conventional commit type from diff content and file paths.
|
|
780
|
+
*/
|
|
781
|
+
inferCommitType(diff, files) {
|
|
782
|
+
// Check file patterns first
|
|
783
|
+
const allTest = files.length > 0 && files.every((f) => TEST_FILE_RE.test(f));
|
|
784
|
+
if (allTest)
|
|
785
|
+
return "test";
|
|
786
|
+
const allDocs = files.length > 0 && files.every((f) => DOCS_FILE_RE.test(f));
|
|
787
|
+
if (allDocs)
|
|
788
|
+
return "docs";
|
|
789
|
+
const allStyle = files.length > 0 && files.every((f) => STYLE_FILE_RE.test(f));
|
|
790
|
+
if (allStyle)
|
|
791
|
+
return "style";
|
|
792
|
+
const allCI = files.length > 0 && files.every((f) => CI_FILE_RE.test(f));
|
|
793
|
+
if (allCI)
|
|
794
|
+
return "ci";
|
|
795
|
+
const allBuild = files.length > 0 && files.every((f) => BUILD_FILE_RE.test(f));
|
|
796
|
+
if (allBuild)
|
|
797
|
+
return "build";
|
|
798
|
+
const allConfig = files.length > 0 && files.every((f) => CONFIG_FILE_RE.test(f));
|
|
799
|
+
if (allConfig)
|
|
800
|
+
return "chore";
|
|
801
|
+
// Check diff content for keywords
|
|
802
|
+
const hunkContent = diff
|
|
803
|
+
.split("\n")
|
|
804
|
+
.filter((l) => l.startsWith("+") || l.startsWith("-"))
|
|
805
|
+
.join("\n");
|
|
806
|
+
if (FIX_KEYWORDS_RE.test(hunkContent))
|
|
807
|
+
return "fix";
|
|
808
|
+
if (PERF_KEYWORDS_RE.test(hunkContent))
|
|
809
|
+
return "perf";
|
|
810
|
+
// Count additions vs deletions
|
|
811
|
+
let additions = 0;
|
|
812
|
+
let deletions = 0;
|
|
813
|
+
for (const line of diff.split("\n")) {
|
|
814
|
+
if (line.startsWith("+") && !line.startsWith("+++"))
|
|
815
|
+
additions++;
|
|
816
|
+
else if (line.startsWith("-") && !line.startsWith("---"))
|
|
817
|
+
deletions++;
|
|
818
|
+
}
|
|
819
|
+
// Mostly new files or more additions = feat
|
|
820
|
+
const hasNewFiles = diff.includes("new file mode");
|
|
821
|
+
if (hasNewFiles || (additions > deletions * 2))
|
|
822
|
+
return "feat";
|
|
823
|
+
// More deletions than additions = refactor
|
|
824
|
+
if (deletions > additions)
|
|
825
|
+
return "refactor";
|
|
826
|
+
return "feat";
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Infer a scope from file paths by finding a common directory.
|
|
830
|
+
*/
|
|
831
|
+
inferScope(files) {
|
|
832
|
+
if (files.length === 0)
|
|
833
|
+
return null;
|
|
834
|
+
// Extract meaningful directory segments
|
|
835
|
+
const segments = files.map((f) => {
|
|
836
|
+
const parts = f.split("/").filter(Boolean);
|
|
837
|
+
// Skip top-level generic dirs
|
|
838
|
+
const skip = new Set(["src", "lib", "dist", "build", "packages"]);
|
|
839
|
+
const meaningful = parts.filter((p) => !skip.has(p));
|
|
840
|
+
return meaningful.length > 0 ? meaningful[0] : parts[parts.length - 1] ?? null;
|
|
841
|
+
});
|
|
842
|
+
// If all files share the same segment, use it
|
|
843
|
+
const unique = [...new Set(segments.filter(Boolean))];
|
|
844
|
+
if (unique.length === 1) {
|
|
845
|
+
return this.cleanScopeName(unique[0]);
|
|
846
|
+
}
|
|
847
|
+
// If there are 2 segments, use the more specific one
|
|
848
|
+
if (unique.length === 2 && files.length <= 5) {
|
|
849
|
+
// Check for a common parent
|
|
850
|
+
const dirs = files.map((f) => f.split("/").slice(0, -1).join("/"));
|
|
851
|
+
const commonDir = this.longestCommonPrefix(dirs);
|
|
852
|
+
if (commonDir) {
|
|
853
|
+
const lastPart = commonDir.split("/").filter(Boolean).pop();
|
|
854
|
+
if (lastPart)
|
|
855
|
+
return this.cleanScopeName(lastPart);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Clean a scope name by removing file extensions and trimming.
|
|
862
|
+
*/
|
|
863
|
+
cleanScopeName(name) {
|
|
864
|
+
return name.replace(/\.\w+$/, "").replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase();
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Find the longest common prefix among strings.
|
|
868
|
+
*/
|
|
869
|
+
longestCommonPrefix(strs) {
|
|
870
|
+
if (strs.length === 0)
|
|
871
|
+
return "";
|
|
872
|
+
let prefix = strs[0];
|
|
873
|
+
for (let i = 1; i < strs.length; i++) {
|
|
874
|
+
while (!strs[i].startsWith(prefix)) {
|
|
875
|
+
prefix = prefix.slice(0, -1);
|
|
876
|
+
if (!prefix)
|
|
877
|
+
return "";
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return prefix;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Generate a short description from diff hunks and commit type.
|
|
884
|
+
*/
|
|
885
|
+
generateDescription(hunks, type) {
|
|
886
|
+
const lines = hunks.split("\n");
|
|
887
|
+
// Collect added lines (skip diff metadata)
|
|
888
|
+
const added = [];
|
|
889
|
+
const removed = [];
|
|
890
|
+
for (const line of lines) {
|
|
891
|
+
if (line.startsWith("+") && !line.startsWith("+++") && !line.startsWith("@@")) {
|
|
892
|
+
const content = line.slice(1).trim();
|
|
893
|
+
if (content && !content.startsWith("//") && !content.startsWith("*")) {
|
|
894
|
+
added.push(content);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else if (line.startsWith("-") && !line.startsWith("---") && !line.startsWith("@@")) {
|
|
898
|
+
const content = line.slice(1).trim();
|
|
899
|
+
if (content && !content.startsWith("//") && !content.startsWith("*")) {
|
|
900
|
+
removed.push(content);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Try to find a meaningful first added line
|
|
905
|
+
const significantAdded = added.find((l) => {
|
|
906
|
+
return (/^(export\s+)?(function|class|interface|type|enum|const|let|var)\s+\w+/.test(l) ||
|
|
907
|
+
/^(async\s+)?(\w+)\s*\(/.test(l) ||
|
|
908
|
+
l.length > 10);
|
|
909
|
+
});
|
|
910
|
+
if (significantAdded) {
|
|
911
|
+
// Extract a symbol name
|
|
912
|
+
const symbolMatch = /^(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var)\s+(\w+)/.exec(significantAdded);
|
|
913
|
+
if (symbolMatch) {
|
|
914
|
+
const verb = type === "feat" ? "add" : type === "fix" ? "fix" : type === "refactor" ? "refactor" : "update";
|
|
915
|
+
return `${verb} ${symbolMatch[1]}`;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Fall back to describing the action
|
|
919
|
+
if (added.length > 0 && removed.length === 0) {
|
|
920
|
+
return `add new code (${added.length} lines)`;
|
|
921
|
+
}
|
|
922
|
+
if (removed.length > 0 && added.length === 0) {
|
|
923
|
+
return `remove unused code (${removed.length} lines)`;
|
|
924
|
+
}
|
|
925
|
+
if (added.length > 0 && removed.length > 0) {
|
|
926
|
+
return `update code (+${added.length}/-${removed.length} lines)`;
|
|
927
|
+
}
|
|
928
|
+
return "update files";
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Parse git log output in the format `%H|%s|%an|%aI`.
|
|
932
|
+
*/
|
|
933
|
+
parseLog(output) {
|
|
934
|
+
return output
|
|
935
|
+
.trim()
|
|
936
|
+
.split("\n")
|
|
937
|
+
.filter(Boolean)
|
|
938
|
+
.map((line) => {
|
|
939
|
+
const parts = line.split("|");
|
|
940
|
+
return {
|
|
941
|
+
hash: parts[0] ?? "",
|
|
942
|
+
subject: parts[1] ?? "",
|
|
943
|
+
author: parts[2] ?? "",
|
|
944
|
+
date: parts[3] ?? "",
|
|
945
|
+
};
|
|
946
|
+
})
|
|
947
|
+
.filter((e) => e.hash.length > 0);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Parse git blame --porcelain output.
|
|
951
|
+
*/
|
|
952
|
+
parseBlame(output) {
|
|
953
|
+
const results = [];
|
|
954
|
+
const lines = output.split("\n");
|
|
955
|
+
let currentAuthor = "";
|
|
956
|
+
let currentDate = "";
|
|
957
|
+
let currentLine = 0;
|
|
958
|
+
for (const line of lines) {
|
|
959
|
+
// Header line: "hash origLine finalLine numLines"
|
|
960
|
+
const headerMatch = /^[0-9a-f]{40}\s+\d+\s+(\d+)/.exec(line);
|
|
961
|
+
if (headerMatch) {
|
|
962
|
+
currentLine = parseInt(headerMatch[1], 10);
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (line.startsWith("author ")) {
|
|
966
|
+
currentAuthor = line.slice(7);
|
|
967
|
+
}
|
|
968
|
+
else if (line.startsWith("author-time ")) {
|
|
969
|
+
const ts = parseInt(line.slice(12), 10);
|
|
970
|
+
currentDate = new Date(ts * 1000).toISOString();
|
|
971
|
+
}
|
|
972
|
+
else if (line.startsWith("\t")) {
|
|
973
|
+
// Content line — marks end of this entry
|
|
974
|
+
results.push({ author: currentAuthor, line: currentLine, date: currentDate });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return results;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Sanitize a string into a valid git branch name.
|
|
981
|
+
*/
|
|
982
|
+
sanitizeBranchName(name) {
|
|
983
|
+
return name
|
|
984
|
+
.toLowerCase()
|
|
985
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
986
|
+
.replace(/\s+/g, "-")
|
|
987
|
+
.replace(/-+/g, "-")
|
|
988
|
+
.replace(/^-|-$/g, "")
|
|
989
|
+
.slice(0, MAX_BRANCH_NAME_LEN);
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Extract hunk line ranges from a diff for conflict overlap analysis.
|
|
993
|
+
*/
|
|
994
|
+
extractHunkRanges(diff) {
|
|
995
|
+
const ranges = [];
|
|
996
|
+
const hunkRe = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
|
|
997
|
+
let match;
|
|
998
|
+
while ((match = hunkRe.exec(diff)) !== null) {
|
|
999
|
+
const start = parseInt(match[1], 10);
|
|
1000
|
+
const count = parseInt(match[2] ?? "1", 10);
|
|
1001
|
+
ranges.push({ start, end: start + count - 1 });
|
|
1002
|
+
}
|
|
1003
|
+
return ranges;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Suggest labels from multiple commit analyses.
|
|
1007
|
+
*/
|
|
1008
|
+
suggestLabelsFromAnalyses(analyses) {
|
|
1009
|
+
const labels = new Set();
|
|
1010
|
+
for (const a of analyses) {
|
|
1011
|
+
// Type-based labels
|
|
1012
|
+
switch (a.type) {
|
|
1013
|
+
case "feat":
|
|
1014
|
+
labels.add("enhancement");
|
|
1015
|
+
break;
|
|
1016
|
+
case "fix":
|
|
1017
|
+
labels.add("bug");
|
|
1018
|
+
break;
|
|
1019
|
+
case "docs":
|
|
1020
|
+
labels.add("documentation");
|
|
1021
|
+
break;
|
|
1022
|
+
case "test":
|
|
1023
|
+
labels.add("testing");
|
|
1024
|
+
break;
|
|
1025
|
+
case "perf":
|
|
1026
|
+
labels.add("performance");
|
|
1027
|
+
break;
|
|
1028
|
+
case "refactor":
|
|
1029
|
+
labels.add("refactor");
|
|
1030
|
+
break;
|
|
1031
|
+
case "ci":
|
|
1032
|
+
labels.add("ci/cd");
|
|
1033
|
+
break;
|
|
1034
|
+
case "build":
|
|
1035
|
+
labels.add("build");
|
|
1036
|
+
break;
|
|
1037
|
+
case "chore":
|
|
1038
|
+
labels.add("chore");
|
|
1039
|
+
break;
|
|
1040
|
+
case "style":
|
|
1041
|
+
labels.add("style");
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
// Size-based labels
|
|
1045
|
+
const totalChanges = a.insertions + a.deletions;
|
|
1046
|
+
if (totalChanges > 500)
|
|
1047
|
+
labels.add("size/large");
|
|
1048
|
+
else if (totalChanges > 100)
|
|
1049
|
+
labels.add("size/medium");
|
|
1050
|
+
else
|
|
1051
|
+
labels.add("size/small");
|
|
1052
|
+
// Breaking change
|
|
1053
|
+
if (a.breakingChange)
|
|
1054
|
+
labels.add("breaking-change");
|
|
1055
|
+
}
|
|
1056
|
+
return [...labels];
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Generate a test plan checklist based on changed files and analyses.
|
|
1060
|
+
*/
|
|
1061
|
+
generateTestPlan(diffSummary, analyses) {
|
|
1062
|
+
const plan = [];
|
|
1063
|
+
// Check if any source files changed
|
|
1064
|
+
const hasSourceChanges = diffSummary.some((d) => !TEST_FILE_RE.test(d.file) && !DOCS_FILE_RE.test(d.file) && !CONFIG_FILE_RE.test(d.file));
|
|
1065
|
+
if (hasSourceChanges) {
|
|
1066
|
+
plan.push("[ ] Verify TypeScript compilation (`tsc --noEmit`)");
|
|
1067
|
+
plan.push("[ ] Run existing test suite");
|
|
1068
|
+
}
|
|
1069
|
+
// Check for test file changes
|
|
1070
|
+
const hasTestChanges = diffSummary.some((d) => TEST_FILE_RE.test(d.file));
|
|
1071
|
+
if (hasTestChanges) {
|
|
1072
|
+
plan.push("[ ] Verify new/updated tests pass");
|
|
1073
|
+
}
|
|
1074
|
+
else if (hasSourceChanges) {
|
|
1075
|
+
plan.push("[ ] Consider adding tests for new functionality");
|
|
1076
|
+
}
|
|
1077
|
+
// Breaking changes
|
|
1078
|
+
const hasBreaking = analyses.some((a) => a.breakingChange);
|
|
1079
|
+
if (hasBreaking) {
|
|
1080
|
+
plan.push("[ ] Verify backward compatibility or document migration path");
|
|
1081
|
+
plan.push("[ ] Check downstream consumers for breakage");
|
|
1082
|
+
}
|
|
1083
|
+
// Config changes
|
|
1084
|
+
const hasConfigChanges = diffSummary.some((d) => CONFIG_FILE_RE.test(d.file));
|
|
1085
|
+
if (hasConfigChanges) {
|
|
1086
|
+
plan.push("[ ] Verify configuration changes work in dev and prod");
|
|
1087
|
+
}
|
|
1088
|
+
// New files
|
|
1089
|
+
const newFiles = diffSummary.filter((d) => d.status === "A");
|
|
1090
|
+
if (newFiles.length > 0) {
|
|
1091
|
+
plan.push(`[ ] Review ${newFiles.length} new file(s) for correctness`);
|
|
1092
|
+
}
|
|
1093
|
+
if (plan.length === 0) {
|
|
1094
|
+
plan.push("[ ] Smoke test the affected area");
|
|
1095
|
+
}
|
|
1096
|
+
return plan;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
//# sourceMappingURL=git-intelligence.js.map
|