@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.
Files changed (98) hide show
  1. package/README.md +305 -0
  2. package/config/development.js +20 -0
  3. package/config/endpoint-map.js +351 -0
  4. package/config/index.js +34 -0
  5. package/config/production.js +18 -0
  6. package/config/staging.js +18 -0
  7. package/handlers/access.js +141 -0
  8. package/handlers/activity.js +112 -0
  9. package/handlers/agents.js +95 -0
  10. package/handlers/ai-intelligence.js +55 -0
  11. package/handlers/attachments.js +30 -0
  12. package/handlers/catalogs.js +169 -0
  13. package/handlers/components.js +282 -0
  14. package/handlers/context-manifest.js +1150 -0
  15. package/handlers/decisions.js +114 -0
  16. package/handlers/designs.js +118 -0
  17. package/handlers/documents.js +227 -0
  18. package/handlers/entities.js +95 -0
  19. package/handlers/epics.js +190 -0
  20. package/handlers/facts.js +62 -0
  21. package/handlers/feature-flags.js +142 -0
  22. package/handlers/features.js +137 -0
  23. package/handlers/folders.js +127 -0
  24. package/handlers/git-context.js +917 -0
  25. package/handlers/github.js +72 -0
  26. package/handlers/graph.js +23 -0
  27. package/handlers/index.js +205 -0
  28. package/handlers/links.js +156 -0
  29. package/handlers/milestones.js +131 -0
  30. package/handlers/organizations.js +14 -0
  31. package/handlers/projects.js +122 -0
  32. package/handlers/recurring-tasks.js +33 -0
  33. package/handlers/tags.js +124 -0
  34. package/handlers/tasks.js +561 -0
  35. package/handlers/testing.js +116 -0
  36. package/handlers/todos.js +43 -0
  37. package/handlers/watchers.js +54 -0
  38. package/handlers/work-templates.js +32 -0
  39. package/index.js +175 -0
  40. package/lib/active-session.js +86 -0
  41. package/lib/auto-assign.js +93 -0
  42. package/lib/autolink.js +176 -0
  43. package/lib/changed-files.js +22 -0
  44. package/lib/env.js +45 -0
  45. package/lib/git-helpers.js +553 -0
  46. package/lib/git-utils.js +73 -0
  47. package/lib/http-client.js +164 -0
  48. package/lib/links-at-create.js +94 -0
  49. package/lib/local-cache.js +140 -0
  50. package/lib/logger.js +109 -0
  51. package/lib/manifest-loader.js +182 -0
  52. package/lib/manifest-query.js +686 -0
  53. package/lib/repo-config-dir.js +118 -0
  54. package/lib/version.js +10 -0
  55. package/lib/web-url.js +69 -0
  56. package/lib/worktree-tools.js +950 -0
  57. package/package.json +62 -0
  58. package/prompts/ai-workflow-automation.js +96 -0
  59. package/prompts/index.js +39 -0
  60. package/prompts/zephly-usage-guide-content.txt +631 -0
  61. package/prompts/zephly-usage-guide.js +119 -0
  62. package/tools/access-entity-types.js +28 -0
  63. package/tools/access.js +152 -0
  64. package/tools/activity.js +38 -0
  65. package/tools/agents.js +208 -0
  66. package/tools/ai-intelligence.js +111 -0
  67. package/tools/attachments.js +92 -0
  68. package/tools/catalogs.js +341 -0
  69. package/tools/components.js +249 -0
  70. package/tools/context-manifest.js +236 -0
  71. package/tools/decisions.js +168 -0
  72. package/tools/designs.js +222 -0
  73. package/tools/documents.js +287 -0
  74. package/tools/entities.js +223 -0
  75. package/tools/epics.js +267 -0
  76. package/tools/facts.js +70 -0
  77. package/tools/feature-flags.js +300 -0
  78. package/tools/features.js +246 -0
  79. package/tools/folders.js +122 -0
  80. package/tools/git-context.js +109 -0
  81. package/tools/github.js +172 -0
  82. package/tools/graph.js +70 -0
  83. package/tools/index.js +77 -0
  84. package/tools/link-params.js +93 -0
  85. package/tools/linkable-types.js +36 -0
  86. package/tools/links.js +199 -0
  87. package/tools/milestones.js +176 -0
  88. package/tools/organizations.js +23 -0
  89. package/tools/projects.js +172 -0
  90. package/tools/recurring-tasks.js +115 -0
  91. package/tools/tags.js +219 -0
  92. package/tools/task-item-schema.js +57 -0
  93. package/tools/task-type.js +33 -0
  94. package/tools/tasks.js +680 -0
  95. package/tools/testing.js +344 -0
  96. package/tools/todos.js +69 -0
  97. package/tools/watchers.js +81 -0
  98. 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
+ }
@@ -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
+ }