@ezmodo/mcp-server 0.13.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 +305 -0
- package/config/development.js +20 -0
- package/config/endpoint-map.js +351 -0
- package/config/index.js +34 -0
- package/config/production.js +18 -0
- package/config/staging.js +18 -0
- package/handlers/access.js +141 -0
- package/handlers/activity.js +112 -0
- package/handlers/agents.js +95 -0
- package/handlers/ai-intelligence.js +55 -0
- package/handlers/attachments.js +30 -0
- package/handlers/catalogs.js +169 -0
- package/handlers/components.js +282 -0
- package/handlers/context-manifest.js +1150 -0
- package/handlers/decisions.js +114 -0
- package/handlers/designs.js +118 -0
- package/handlers/documents.js +227 -0
- package/handlers/entities.js +95 -0
- package/handlers/epics.js +190 -0
- package/handlers/facts.js +62 -0
- package/handlers/feature-flags.js +142 -0
- package/handlers/features.js +137 -0
- package/handlers/folders.js +127 -0
- package/handlers/git-context.js +917 -0
- package/handlers/github.js +72 -0
- package/handlers/graph.js +23 -0
- package/handlers/index.js +205 -0
- package/handlers/links.js +156 -0
- package/handlers/milestones.js +131 -0
- package/handlers/organizations.js +14 -0
- package/handlers/projects.js +122 -0
- package/handlers/recurring-tasks.js +33 -0
- package/handlers/tags.js +124 -0
- package/handlers/tasks.js +561 -0
- package/handlers/testing.js +116 -0
- package/handlers/todos.js +43 -0
- package/handlers/watchers.js +54 -0
- package/handlers/work-templates.js +32 -0
- package/index.js +175 -0
- package/lib/active-session.js +86 -0
- package/lib/auto-assign.js +93 -0
- package/lib/autolink.js +176 -0
- package/lib/changed-files.js +22 -0
- package/lib/env.js +45 -0
- package/lib/git-helpers.js +553 -0
- package/lib/git-utils.js +73 -0
- package/lib/http-client.js +164 -0
- package/lib/links-at-create.js +94 -0
- package/lib/local-cache.js +140 -0
- package/lib/logger.js +109 -0
- package/lib/manifest-loader.js +182 -0
- package/lib/manifest-query.js +686 -0
- package/lib/repo-config-dir.js +118 -0
- package/lib/version.js +10 -0
- package/lib/web-url.js +69 -0
- package/lib/worktree-tools.js +950 -0
- package/package.json +62 -0
- package/prompts/ai-workflow-automation.js +96 -0
- package/prompts/index.js +39 -0
- package/prompts/zephly-usage-guide-content.txt +631 -0
- package/prompts/zephly-usage-guide.js +119 -0
- package/tools/access-entity-types.js +28 -0
- package/tools/access.js +152 -0
- package/tools/activity.js +38 -0
- package/tools/agents.js +208 -0
- package/tools/ai-intelligence.js +111 -0
- package/tools/attachments.js +92 -0
- package/tools/catalogs.js +341 -0
- package/tools/components.js +249 -0
- package/tools/context-manifest.js +236 -0
- package/tools/decisions.js +168 -0
- package/tools/designs.js +222 -0
- package/tools/documents.js +287 -0
- package/tools/entities.js +223 -0
- package/tools/epics.js +267 -0
- package/tools/facts.js +70 -0
- package/tools/feature-flags.js +300 -0
- package/tools/features.js +246 -0
- package/tools/folders.js +122 -0
- package/tools/git-context.js +109 -0
- package/tools/github.js +172 -0
- package/tools/graph.js +70 -0
- package/tools/index.js +77 -0
- package/tools/link-params.js +93 -0
- package/tools/linkable-types.js +36 -0
- package/tools/links.js +199 -0
- package/tools/milestones.js +176 -0
- package/tools/organizations.js +23 -0
- package/tools/projects.js +172 -0
- package/tools/recurring-tasks.js +115 -0
- package/tools/tags.js +219 -0
- package/tools/task-item-schema.js +57 -0
- package/tools/task-type.js +33 -0
- package/tools/tasks.js +680 -0
- package/tools/testing.js +344 -0
- package/tools/todos.js +69 -0
- package/tools/watchers.js +81 -0
- package/tools/work-templates.js +96 -0
package/lib/env.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-variable accessors with EZMODO_* primary / ZEPHLY_* legacy fallback.
|
|
3
|
+
*
|
|
4
|
+
* During the rebrand transition (epic E-141, Phase 5) both names are accepted.
|
|
5
|
+
* The legacy `ZEPHLY_*` reads emit a one-time deprecation warning per process.
|
|
6
|
+
* New code should call these helpers instead of reading `process.env.ZEPHLY_*`
|
|
7
|
+
* directly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let warnedLegacyApiKey = false;
|
|
11
|
+
let warnedLegacyApiUrl = false;
|
|
12
|
+
|
|
13
|
+
/** Resolve the API key from EZMODO_API_KEY (preferred) or ZEPHLY_API_KEY (legacy). */
|
|
14
|
+
export function getApiKey() {
|
|
15
|
+
if (process.env.EZMODO_API_KEY) {
|
|
16
|
+
return process.env.EZMODO_API_KEY;
|
|
17
|
+
}
|
|
18
|
+
if (process.env.ZEPHLY_API_KEY) {
|
|
19
|
+
if (!warnedLegacyApiKey) {
|
|
20
|
+
console.error(
|
|
21
|
+
'⚠️ ZEPHLY_API_KEY is deprecated and will be removed in a future release. Rename to EZMODO_API_KEY.'
|
|
22
|
+
);
|
|
23
|
+
warnedLegacyApiKey = true;
|
|
24
|
+
}
|
|
25
|
+
return process.env.ZEPHLY_API_KEY;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve an optional API URL override from EZMODO_API_URL or ZEPHLY_API_URL. */
|
|
31
|
+
export function getApiUrl() {
|
|
32
|
+
if (process.env.EZMODO_API_URL) {
|
|
33
|
+
return process.env.EZMODO_API_URL;
|
|
34
|
+
}
|
|
35
|
+
if (process.env.ZEPHLY_API_URL) {
|
|
36
|
+
if (!warnedLegacyApiUrl) {
|
|
37
|
+
console.error(
|
|
38
|
+
'⚠️ ZEPHLY_API_URL is deprecated and will be removed in a future release. Rename to EZMODO_API_URL.'
|
|
39
|
+
);
|
|
40
|
+
warnedLegacyApiUrl = true;
|
|
41
|
+
}
|
|
42
|
+
return process.env.ZEPHLY_API_URL;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Helper Functions
|
|
3
|
+
* Utility functions for git operations used by worktree tools
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute git command with proper error handling
|
|
10
|
+
* @param {string} command - Git command to execute
|
|
11
|
+
* @param {string} cwd - Working directory
|
|
12
|
+
* @returns {string} Command output
|
|
13
|
+
*/
|
|
14
|
+
export function execGit(command, cwd) {
|
|
15
|
+
try {
|
|
16
|
+
return execSync(command, {
|
|
17
|
+
cwd,
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
stdio: 'pipe',
|
|
20
|
+
}).trim();
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(`Git command failed: ${command}\n${error.message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if directory is a git repository
|
|
28
|
+
* @param {string} dir - Directory path
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function isGitRepository(dir) {
|
|
32
|
+
try {
|
|
33
|
+
execSync('git rev-parse --git-dir', {
|
|
34
|
+
cwd: dir,
|
|
35
|
+
encoding: 'utf-8',
|
|
36
|
+
stdio: 'pipe',
|
|
37
|
+
});
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get repository root directory
|
|
46
|
+
* @param {string} dir - Directory path
|
|
47
|
+
* @returns {string|null} Repository root path or null
|
|
48
|
+
*/
|
|
49
|
+
export function getRepositoryRoot(dir) {
|
|
50
|
+
try {
|
|
51
|
+
return execSync('git rev-parse --show-toplevel', {
|
|
52
|
+
cwd: dir,
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
stdio: 'pipe',
|
|
55
|
+
}).trim();
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get remote URL for repository
|
|
63
|
+
* @param {string} repoPath - Repository path
|
|
64
|
+
* @returns {string|null} Remote URL or null
|
|
65
|
+
*/
|
|
66
|
+
export function getRemoteUrl(repoPath) {
|
|
67
|
+
try {
|
|
68
|
+
return execGit('git config --get remote.origin.url', repoPath);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get current branch name
|
|
76
|
+
* @param {string} repoPath - Repository path
|
|
77
|
+
* @returns {string|null} Branch name or null
|
|
78
|
+
*/
|
|
79
|
+
export function getCurrentBranch(repoPath) {
|
|
80
|
+
try {
|
|
81
|
+
return execGit('git branch --show-current', repoPath);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if branch exists (local or remote)
|
|
89
|
+
* @param {string} repoPath - Repository path
|
|
90
|
+
* @param {string} branchName - Branch name
|
|
91
|
+
* @returns {object} { local: boolean, remote: boolean }
|
|
92
|
+
*/
|
|
93
|
+
export function branchExists(repoPath, branchName) {
|
|
94
|
+
const result = { local: false, remote: false };
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
execGit(`git show-ref --verify refs/heads/${branchName}`, repoPath);
|
|
98
|
+
result.local = true;
|
|
99
|
+
} catch {
|
|
100
|
+
// Branch doesn't exist locally
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
execGit(`git show-ref --verify refs/remotes/origin/${branchName}`, repoPath);
|
|
105
|
+
result.remote = true;
|
|
106
|
+
} catch {
|
|
107
|
+
// Branch doesn't exist remotely
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create a new branch from base branch
|
|
115
|
+
* @param {string} repoPath - Repository path
|
|
116
|
+
* @param {string} branchName - New branch name
|
|
117
|
+
* @param {string} baseBranch - Base branch to branch from
|
|
118
|
+
* @returns {string} Commit hash
|
|
119
|
+
*/
|
|
120
|
+
export function createBranch(repoPath, branchName, baseBranch) {
|
|
121
|
+
// Ensure base branch is up to date
|
|
122
|
+
try {
|
|
123
|
+
execGit(`git fetch origin ${baseBranch}`, repoPath);
|
|
124
|
+
} catch {
|
|
125
|
+
// Fetch might fail if no remote, that's ok
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Create branch
|
|
129
|
+
execGit(`git branch ${branchName} ${baseBranch}`, repoPath);
|
|
130
|
+
|
|
131
|
+
// Get commit hash
|
|
132
|
+
return execGit(`git rev-parse ${branchName}`, repoPath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create a worktree
|
|
137
|
+
* @param {string} repoPath - Repository path
|
|
138
|
+
* @param {string} worktreePath - Path for new worktree
|
|
139
|
+
* @param {string} branchName - Branch name
|
|
140
|
+
* @param {boolean} createBranch - Whether to create the branch
|
|
141
|
+
* @returns {void}
|
|
142
|
+
*/
|
|
143
|
+
export function createWorktree(repoPath, worktreePath, branchName, createBranch = false) {
|
|
144
|
+
const branchFlag = createBranch ? '-b' : '';
|
|
145
|
+
execGit(`git worktree add ${branchFlag} "${worktreePath}" ${branchName}`, repoPath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* List all worktrees
|
|
150
|
+
* @param {string} repoPath - Repository path
|
|
151
|
+
* @returns {Array<{path: string, branch: string, commit: string}>}
|
|
152
|
+
*/
|
|
153
|
+
export function listWorktrees(repoPath) {
|
|
154
|
+
try {
|
|
155
|
+
const output = execGit('git worktree list --porcelain', repoPath);
|
|
156
|
+
const worktrees = [];
|
|
157
|
+
const lines = output.split('\n');
|
|
158
|
+
|
|
159
|
+
let current = {};
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (line.startsWith('worktree ')) {
|
|
162
|
+
if (current.path) {
|
|
163
|
+
worktrees.push(current);
|
|
164
|
+
}
|
|
165
|
+
current = { path: line.substring(9) };
|
|
166
|
+
} else if (line.startsWith('HEAD ')) {
|
|
167
|
+
current.commit = line.substring(5);
|
|
168
|
+
} else if (line.startsWith('branch ')) {
|
|
169
|
+
current.branch = line.substring(7).replace('refs/heads/', '');
|
|
170
|
+
} else if (line.startsWith('detached')) {
|
|
171
|
+
current.branch = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (current.path) {
|
|
176
|
+
worktrees.push(current);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return worktrees;
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Remove a worktree
|
|
187
|
+
* @param {string} repoPath - Repository path
|
|
188
|
+
* @param {string} worktreePath - Worktree path to remove
|
|
189
|
+
* @param {boolean} force - Force removal even with uncommitted changes
|
|
190
|
+
* @returns {void}
|
|
191
|
+
*/
|
|
192
|
+
export function removeWorktree(repoPath, worktreePath, force = false) {
|
|
193
|
+
const forceFlag = force ? '--force' : '';
|
|
194
|
+
execGit(`git worktree remove ${forceFlag} "${worktreePath}"`, repoPath);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get git status for a worktree
|
|
199
|
+
* @param {string} worktreePath - Worktree path
|
|
200
|
+
* @returns {object} Status information
|
|
201
|
+
*/
|
|
202
|
+
export function getWorktreeStatus(worktreePath) {
|
|
203
|
+
const status = {
|
|
204
|
+
isDirty: false,
|
|
205
|
+
hasUntracked: false,
|
|
206
|
+
hasStaged: false,
|
|
207
|
+
hasUnstaged: false,
|
|
208
|
+
files: [],
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const output = execGit('git status --porcelain', worktreePath);
|
|
213
|
+
|
|
214
|
+
if (output) {
|
|
215
|
+
status.isDirty = true;
|
|
216
|
+
const lines = output.split('\n').filter(l => l);
|
|
217
|
+
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
const statusCode = line.substring(0, 2);
|
|
220
|
+
const file = line.substring(3);
|
|
221
|
+
|
|
222
|
+
status.files.push({ status: statusCode, file });
|
|
223
|
+
|
|
224
|
+
if (statusCode.trim().startsWith('?')) {
|
|
225
|
+
status.hasUntracked = true;
|
|
226
|
+
} else if (statusCode[0] !== ' ') {
|
|
227
|
+
status.hasStaged = true;
|
|
228
|
+
} else if (statusCode[1] !== ' ') {
|
|
229
|
+
status.hasUnstaged = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// Error getting status
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return status;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get commits ahead/behind counts
|
|
242
|
+
* @param {string} worktreePath - Worktree path
|
|
243
|
+
* @param {string} localBranch - Local branch name
|
|
244
|
+
* @param {string} remoteBranch - Remote branch name (e.g., 'origin/main')
|
|
245
|
+
* @returns {object} { ahead: number, behind: number }
|
|
246
|
+
*/
|
|
247
|
+
export function getAheadBehindCounts(worktreePath, localBranch, remoteBranch) {
|
|
248
|
+
const counts = { ahead: 0, behind: 0 };
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
// Fetch latest
|
|
252
|
+
execGit('git fetch origin', worktreePath);
|
|
253
|
+
|
|
254
|
+
// Get ahead count
|
|
255
|
+
const aheadOutput = execGit(
|
|
256
|
+
`git rev-list --count ${remoteBranch}..${localBranch}`,
|
|
257
|
+
worktreePath
|
|
258
|
+
);
|
|
259
|
+
counts.ahead = parseInt(aheadOutput, 10) || 0;
|
|
260
|
+
|
|
261
|
+
// Get behind count
|
|
262
|
+
const behindOutput = execGit(
|
|
263
|
+
`git rev-list --count ${localBranch}..${remoteBranch}`,
|
|
264
|
+
worktreePath
|
|
265
|
+
);
|
|
266
|
+
counts.behind = parseInt(behindOutput, 10) || 0;
|
|
267
|
+
} catch {
|
|
268
|
+
// Can't determine ahead/behind, remote might not exist
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return counts;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check if branch has conflicts
|
|
276
|
+
* @param {string} worktreePath - Worktree path
|
|
277
|
+
* @returns {object} { hasConflicts: boolean, files: string[] }
|
|
278
|
+
*/
|
|
279
|
+
export function checkForConflicts(worktreePath) {
|
|
280
|
+
const result = { hasConflicts: false, files: [] };
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const output = execGit('git status --porcelain', worktreePath);
|
|
284
|
+
const lines = output.split('\n').filter(l => l);
|
|
285
|
+
|
|
286
|
+
for (const line of lines) {
|
|
287
|
+
const statusCode = line.substring(0, 2);
|
|
288
|
+
const file = line.substring(3);
|
|
289
|
+
|
|
290
|
+
// UU = both modified (unmerged)
|
|
291
|
+
// AA = both added
|
|
292
|
+
// DD = both deleted
|
|
293
|
+
if (statusCode === 'UU' || statusCode === 'AA' || statusCode === 'DD') {
|
|
294
|
+
result.hasConflicts = true;
|
|
295
|
+
result.files.push(file);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// Error checking conflicts
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Delete a branch
|
|
307
|
+
* @param {string} repoPath - Repository path
|
|
308
|
+
* @param {string} branchName - Branch name to delete
|
|
309
|
+
* @param {boolean} force - Force deletion
|
|
310
|
+
* @returns {void}
|
|
311
|
+
*/
|
|
312
|
+
export function deleteBranch(repoPath, branchName, force = false) {
|
|
313
|
+
const flag = force ? '-D' : '-d';
|
|
314
|
+
execGit(`git branch ${flag} ${branchName}`, repoPath);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Check if branch is merged into base branch
|
|
319
|
+
* @param {string} repoPath - Repository path
|
|
320
|
+
* @param {string} branchName - Branch to check
|
|
321
|
+
* @param {string} baseBranch - Base branch
|
|
322
|
+
* @returns {boolean}
|
|
323
|
+
*/
|
|
324
|
+
export function isBranchMerged(repoPath, branchName, baseBranch) {
|
|
325
|
+
try {
|
|
326
|
+
const output = execGit(`git branch --merged ${baseBranch}`, repoPath);
|
|
327
|
+
return output.includes(branchName);
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get last commit info for a branch
|
|
335
|
+
* @param {string} repoPath - Repository path
|
|
336
|
+
* @param {string} branchName - Branch name
|
|
337
|
+
* @returns {object|null} { hash, author, date, message }
|
|
338
|
+
*/
|
|
339
|
+
export function getLastCommit(repoPath, branchName) {
|
|
340
|
+
try {
|
|
341
|
+
const hash = execGit(`git rev-parse ${branchName}`, repoPath);
|
|
342
|
+
const author = execGit(`git log -1 --format=%an ${branchName}`, repoPath);
|
|
343
|
+
const timestamp = execGit(`git log -1 --format=%ct ${branchName}`, repoPath);
|
|
344
|
+
const message = execGit(`git log -1 --format=%s ${branchName}`, repoPath);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
hash,
|
|
348
|
+
author,
|
|
349
|
+
date: new Date(parseInt(timestamp, 10) * 1000),
|
|
350
|
+
message,
|
|
351
|
+
};
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Generate a branch name from task metadata
|
|
359
|
+
* @param {object} task - Task object
|
|
360
|
+
* @param {string} defaultPrefix - Default prefix if can't determine from labels
|
|
361
|
+
* @returns {string} Branch name
|
|
362
|
+
*/
|
|
363
|
+
export function generateBranchName(task, defaultPrefix = 'feature') {
|
|
364
|
+
// Determine prefix from labels
|
|
365
|
+
let prefix = defaultPrefix;
|
|
366
|
+
if (task.labels && task.labels.length > 0) {
|
|
367
|
+
const labels = task.labels.map(l => l.toLowerCase());
|
|
368
|
+
if (labels.includes('bug') || labels.includes('fix')) {
|
|
369
|
+
prefix = 'fix';
|
|
370
|
+
} else if (labels.includes('refactor') || labels.includes('refactoring')) {
|
|
371
|
+
prefix = 'refactor';
|
|
372
|
+
} else if (labels.includes('docs') || labels.includes('documentation')) {
|
|
373
|
+
prefix = 'docs';
|
|
374
|
+
} else if (labels.includes('feature')) {
|
|
375
|
+
prefix = 'feature';
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Slugify title (max 50 chars)
|
|
380
|
+
const slug = task.title
|
|
381
|
+
.toLowerCase()
|
|
382
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
383
|
+
.replace(/\s+/g, '-')
|
|
384
|
+
.substring(0, 50)
|
|
385
|
+
.replace(/-+$/, ''); // Remove trailing dashes
|
|
386
|
+
|
|
387
|
+
// Format: {prefix}/{task-id}-{slug}
|
|
388
|
+
return `${prefix}/${task.id}-${slug}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Find the merge base between two branches (where they diverged)
|
|
393
|
+
* @param {string} repoPath - Repository path
|
|
394
|
+
* @param {string} branch1 - First branch
|
|
395
|
+
* @param {string} branch2 - Second branch
|
|
396
|
+
* @returns {string|null} Merge base commit hash or null
|
|
397
|
+
*/
|
|
398
|
+
export function getMergeBase(repoPath, branch1, branch2) {
|
|
399
|
+
try {
|
|
400
|
+
return execGit(`git merge-base ${branch1} ${branch2}`, repoPath);
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Detect the default branch (main or master)
|
|
408
|
+
* @param {string} repoPath - Repository path
|
|
409
|
+
* @returns {string} Default branch name
|
|
410
|
+
*/
|
|
411
|
+
export function getDefaultBranch(repoPath) {
|
|
412
|
+
// Try to get from remote HEAD
|
|
413
|
+
try {
|
|
414
|
+
const ref = execGit('git symbolic-ref refs/remotes/origin/HEAD', repoPath);
|
|
415
|
+
return ref.replace('refs/remotes/origin/', '');
|
|
416
|
+
} catch {
|
|
417
|
+
// Fall back to checking if main or master exists
|
|
418
|
+
try {
|
|
419
|
+
execGit('git show-ref --verify refs/heads/main', repoPath);
|
|
420
|
+
return 'main';
|
|
421
|
+
} catch {
|
|
422
|
+
try {
|
|
423
|
+
execGit('git show-ref --verify refs/heads/master', repoPath);
|
|
424
|
+
return 'master';
|
|
425
|
+
} catch {
|
|
426
|
+
return 'main'; // Default fallback
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Get commits since a specific commit hash
|
|
434
|
+
* @param {string} repoPath - Repository path
|
|
435
|
+
* @param {string} sinceCommit - Commit hash to start from (exclusive)
|
|
436
|
+
* @param {string} branchName - Branch name to get commits from
|
|
437
|
+
* @returns {Array<{sha: string, shortSha: string, author: string, email: string, timestamp: Date, message: string}>}
|
|
438
|
+
*/
|
|
439
|
+
export function getCommitsSince(repoPath, sinceCommit, branchName) {
|
|
440
|
+
try {
|
|
441
|
+
// Format: hash|short|author|email|timestamp|subject
|
|
442
|
+
const format = '%H|%h|%an|%ae|%ct|%s';
|
|
443
|
+
const range = sinceCommit ? `${sinceCommit}..${branchName}` : branchName;
|
|
444
|
+
|
|
445
|
+
const output = execGit(
|
|
446
|
+
`git log --format="${format}" ${range}`,
|
|
447
|
+
repoPath
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
if (!output) {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return output.split('\n').filter(line => line).map(line => {
|
|
455
|
+
const [sha, shortSha, author, email, timestamp, message] = line.split('|');
|
|
456
|
+
return {
|
|
457
|
+
sha,
|
|
458
|
+
shortSha,
|
|
459
|
+
author,
|
|
460
|
+
email,
|
|
461
|
+
timestamp: new Date(parseInt(timestamp, 10) * 1000),
|
|
462
|
+
message,
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
} catch {
|
|
466
|
+
// If the sinceCommit doesn't exist (maybe it was force-pushed away), return empty
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A commit SHA, and nothing else. The value reaches execGit inside a shell
|
|
473
|
+
* string, so anything that is not hex never gets there.
|
|
474
|
+
*/
|
|
475
|
+
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Get the files a commit changed.
|
|
479
|
+
*
|
|
480
|
+
* This is what makes auto-linking work without anyone remembering to pass a
|
|
481
|
+
* file list: the commit's file set is a FACT recorded in the repository, not
|
|
482
|
+
* something a caller should have to supply. The API cannot read it — it has no
|
|
483
|
+
* clone and, measured on 2026-07-27, zero git installations — but the MCP
|
|
484
|
+
* server runs in the working tree where the commit was just made, so here it is
|
|
485
|
+
* one command away.
|
|
486
|
+
*
|
|
487
|
+
* Merge commits report no files (diff-tree without -m is empty for them), which
|
|
488
|
+
* is the honest answer: a merge introduces no changes of its own.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} repoPath - Repository path
|
|
491
|
+
* @param {string} sha - Full or abbreviated commit hash
|
|
492
|
+
* @returns {string[]} Repo-relative paths, or [] if anything at all went wrong
|
|
493
|
+
*/
|
|
494
|
+
export function getCommitFiles(repoPath, sha) {
|
|
495
|
+
if (!repoPath || !sha || !SHA_PATTERN.test(sha)) {
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const output = execGit(
|
|
500
|
+
`git diff-tree --no-commit-id --name-only -r ${sha}`,
|
|
501
|
+
repoPath
|
|
502
|
+
);
|
|
503
|
+
return output ? output.split('\n').map(line => line.trim()).filter(Boolean) : [];
|
|
504
|
+
} catch {
|
|
505
|
+
// Unknown SHA, shallow clone, not a repo — all mean "cannot derive", never
|
|
506
|
+
// "fail the commit link".
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Get the remote URL formatted for commit links
|
|
513
|
+
* @param {string} repoPath - Repository path
|
|
514
|
+
* @returns {string|null} Base URL for commit links (e.g., "https://github.com/org/repo")
|
|
515
|
+
*/
|
|
516
|
+
export function getCommitUrlBase(repoPath) {
|
|
517
|
+
try {
|
|
518
|
+
const remoteUrl = getRemoteUrl(repoPath);
|
|
519
|
+
if (!remoteUrl) return null;
|
|
520
|
+
|
|
521
|
+
// Convert git URLs to HTTPS
|
|
522
|
+
// git@github.com:org/repo.git -> https://github.com/org/repo
|
|
523
|
+
// https://github.com/org/repo.git -> https://github.com/org/repo
|
|
524
|
+
let url = remoteUrl
|
|
525
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
526
|
+
.replace(/\.git$/, '');
|
|
527
|
+
|
|
528
|
+
return url;
|
|
529
|
+
} catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Generate a branch name from epic metadata
|
|
536
|
+
* @param {object} epic - Epic object
|
|
537
|
+
* @returns {string} Branch name
|
|
538
|
+
*/
|
|
539
|
+
export function generateEpicBranchName(epic) {
|
|
540
|
+
// Always use 'feature' prefix for epics
|
|
541
|
+
const prefix = 'feature';
|
|
542
|
+
|
|
543
|
+
// Slugify title (max 50 chars)
|
|
544
|
+
const slug = epic.title
|
|
545
|
+
.toLowerCase()
|
|
546
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
547
|
+
.replace(/\s+/g, '-')
|
|
548
|
+
.substring(0, 50)
|
|
549
|
+
.replace(/-+$/, ''); // Remove trailing dashes
|
|
550
|
+
|
|
551
|
+
// Format: feature/epic-{epic-id}-{slug}
|
|
552
|
+
return `${prefix}/epic-${epic.id}-${slug}`;
|
|
553
|
+
}
|
package/lib/git-utils.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Utilities
|
|
3
|
+
* Functions for normalizing git URLs and calculating match confidence
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize git URL to a standard format for comparison
|
|
8
|
+
* Supports: https://github.com/user/repo.git, git@github.com:user/repo.git, etc.
|
|
9
|
+
* @param {string} url - Git URL to normalize
|
|
10
|
+
* @returns {string|null} - Normalized URL or null if invalid
|
|
11
|
+
*/
|
|
12
|
+
export function normalizeGitUrl(url) {
|
|
13
|
+
if (!url) return null;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
let normalized = url.trim().toLowerCase();
|
|
17
|
+
|
|
18
|
+
// Remove .git suffix
|
|
19
|
+
normalized = normalized.replace(/\.git$/i, '');
|
|
20
|
+
|
|
21
|
+
// Convert git@ format to https://
|
|
22
|
+
// git@github.com:user/repo -> https://github.com/user/repo
|
|
23
|
+
if (normalized.startsWith('git@')) {
|
|
24
|
+
normalized = normalized
|
|
25
|
+
.replace(/^git@/, 'https://')
|
|
26
|
+
.replace(/:([^/])/, '/$1');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Remove protocol for comparison
|
|
30
|
+
normalized = normalized
|
|
31
|
+
.replace(/^https?:\/\//, '')
|
|
32
|
+
.replace(/^git:\/\//, '');
|
|
33
|
+
|
|
34
|
+
// Remove trailing slash
|
|
35
|
+
normalized = normalized.replace(/\/$/, '');
|
|
36
|
+
|
|
37
|
+
return normalized;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Calculate match confidence between two git URLs
|
|
45
|
+
* @param {string} repoUrl - Repository URL to compare
|
|
46
|
+
* @param {string} projectUrl - Project URL to compare against
|
|
47
|
+
* @returns {number} - Confidence score between 0 and 1
|
|
48
|
+
*/
|
|
49
|
+
export function calculateGitMatchConfidence(repoUrl, projectUrl) {
|
|
50
|
+
const repoNorm = normalizeGitUrl(repoUrl);
|
|
51
|
+
const projNorm = normalizeGitUrl(projectUrl);
|
|
52
|
+
|
|
53
|
+
if (!repoNorm || !projNorm) return 0;
|
|
54
|
+
|
|
55
|
+
// Exact match
|
|
56
|
+
if (repoNorm === projNorm) return 1.0;
|
|
57
|
+
|
|
58
|
+
// Same host and path (different protocols)
|
|
59
|
+
if (repoNorm.includes(projNorm) || projNorm.includes(repoNorm)) {
|
|
60
|
+
return 0.95;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract repo name (last part of path)
|
|
64
|
+
const repoName = repoNorm.split('/').pop();
|
|
65
|
+
const projName = projNorm.split('/').pop();
|
|
66
|
+
|
|
67
|
+
// Same repo name
|
|
68
|
+
if (repoName && projName && repoName === projName) {
|
|
69
|
+
return 0.5;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return 0;
|
|
73
|
+
}
|