git-watchtower 1.6.0 → 1.7.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/bin/git-watchtower.js +89 -9
- package/package.json +6 -1
- package/sounds/README.md +34 -0
- package/src/casino/index.js +721 -0
- package/src/casino/sounds.js +245 -0
- package/src/cli/args.js +239 -0
- package/src/config/loader.js +329 -0
- package/src/config/schema.js +305 -0
- package/src/git/branch.js +428 -0
- package/src/git/commands.js +416 -0
- package/src/git/pr.js +111 -0
- package/src/git/remote.js +127 -0
- package/src/index.js +179 -0
- package/src/polling/engine.js +157 -0
- package/src/server/process.js +329 -0
- package/src/server/static.js +95 -0
- package/src/state/store.js +527 -0
- package/src/telemetry/analytics.js +142 -0
- package/src/telemetry/config.js +123 -0
- package/src/telemetry/index.js +93 -0
- package/src/ui/actions.js +425 -0
- package/src/ui/ansi.js +498 -0
- package/src/ui/keybindings.js +198 -0
- package/src/ui/renderer.js +1326 -0
- package/src/utils/async.js +219 -0
- package/src/utils/browser.js +40 -0
- package/src/utils/errors.js +490 -0
- package/src/utils/gitignore.js +174 -0
- package/src/utils/sound.js +33 -0
- package/src/utils/time.js +27 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git command execution module
|
|
3
|
+
* Provides safe, timeout-aware git command execution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { execFile } = require('child_process');
|
|
7
|
+
const { GitError } = require('../utils/errors');
|
|
8
|
+
const { withTimeout } = require('../utils/async');
|
|
9
|
+
|
|
10
|
+
// Default timeout for git operations (30 seconds)
|
|
11
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
12
|
+
|
|
13
|
+
// Longer timeout for fetch operations (60 seconds)
|
|
14
|
+
const FETCH_TIMEOUT = 60000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Execute a git command safely using execFile (no shell).
|
|
18
|
+
* @param {string | string[]} args - Git arguments as an array (e.g. ['log', '--oneline'])
|
|
19
|
+
* or a legacy command string (e.g. 'git --version') for backwards compatibility
|
|
20
|
+
* @param {Object} [options] - Execution options
|
|
21
|
+
* @param {number} [options.timeout] - Command timeout in ms
|
|
22
|
+
* @param {string} [options.cwd] - Working directory
|
|
23
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
24
|
+
* @throws {GitError}
|
|
25
|
+
*/
|
|
26
|
+
async function execGit(args, options = {}) {
|
|
27
|
+
const { timeout = DEFAULT_TIMEOUT, cwd = process.cwd() } = options;
|
|
28
|
+
|
|
29
|
+
// Backwards compatibility: accept a full command string for
|
|
30
|
+
// simple constant commands (no user-controlled data).
|
|
31
|
+
if (typeof args === 'string') {
|
|
32
|
+
const parts = /** @type {string} */ (args).split(/\s+/);
|
|
33
|
+
// Strip leading 'git' if present so callers can pass 'git --version'
|
|
34
|
+
if (parts[0] === 'git') {
|
|
35
|
+
args = parts.slice(1);
|
|
36
|
+
} else {
|
|
37
|
+
args = parts;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const command = `git ${args.join(' ')}`;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const promise = new Promise((resolve, reject) => {
|
|
45
|
+
execFile('git', args, {
|
|
46
|
+
cwd,
|
|
47
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large outputs
|
|
48
|
+
}, (error, stdout, stderr) => {
|
|
49
|
+
if (error) {
|
|
50
|
+
error.stderr = stderr;
|
|
51
|
+
reject(error);
|
|
52
|
+
} else {
|
|
53
|
+
resolve({ stdout, stderr });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await withTimeout(
|
|
59
|
+
promise,
|
|
60
|
+
timeout,
|
|
61
|
+
`Git command timed out after ${timeout}ms: ${command}`
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
stdout: result.stdout.trim(),
|
|
66
|
+
stderr: result.stderr.trim(),
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Handle timeout error
|
|
70
|
+
if (error.message && error.message.includes('timed out')) {
|
|
71
|
+
throw new GitError(error.message, 'GIT_TIMEOUT', { command });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle exec error
|
|
75
|
+
throw GitError.fromExecError(error, command, error.stderr);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Execute git command silently (suppress errors)
|
|
81
|
+
* @param {string | string[]} command - Git arguments (array or legacy string)
|
|
82
|
+
* @param {Object} [options] - Execution options
|
|
83
|
+
* @returns {Promise<{stdout: string, stderr: string}|null>}
|
|
84
|
+
*/
|
|
85
|
+
async function execGitSilent(command, options = {}) {
|
|
86
|
+
try {
|
|
87
|
+
return await execGit(command, options);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if git is available
|
|
95
|
+
* @returns {Promise<boolean>}
|
|
96
|
+
*/
|
|
97
|
+
async function isGitAvailable() {
|
|
98
|
+
try {
|
|
99
|
+
await execGit(['--version'], { timeout: 5000 });
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if current directory is a git repository
|
|
108
|
+
* @param {string} [cwd] - Working directory
|
|
109
|
+
* @returns {Promise<boolean>}
|
|
110
|
+
*/
|
|
111
|
+
async function isGitRepository(cwd) {
|
|
112
|
+
try {
|
|
113
|
+
await execGit(['rev-parse', '--git-dir'], { cwd, timeout: 5000 });
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get list of remotes
|
|
122
|
+
* @param {string} [cwd] - Working directory
|
|
123
|
+
* @returns {Promise<string[]>}
|
|
124
|
+
*/
|
|
125
|
+
async function getRemotes(cwd) {
|
|
126
|
+
try {
|
|
127
|
+
const { stdout } = await execGit(['remote'], { cwd, timeout: 5000 });
|
|
128
|
+
return stdout.split('\n').filter(Boolean);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if a specific remote exists
|
|
136
|
+
* @param {string} remoteName - Remote name to check
|
|
137
|
+
* @param {string} [cwd] - Working directory
|
|
138
|
+
* @returns {Promise<boolean>}
|
|
139
|
+
*/
|
|
140
|
+
async function remoteExists(remoteName, cwd) {
|
|
141
|
+
const remotes = await getRemotes(cwd);
|
|
142
|
+
return remotes.includes(remoteName);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch from remote with timeout
|
|
147
|
+
* @param {string} [remoteName='origin'] - Remote name
|
|
148
|
+
* @param {Object} [options] - Fetch options
|
|
149
|
+
* @param {boolean} [options.prune=true] - Prune deleted branches
|
|
150
|
+
* @param {boolean} [options.all=true] - Fetch all branches
|
|
151
|
+
* @param {string} [options.cwd] - Working directory
|
|
152
|
+
* @returns {Promise<{success: boolean, error?: GitError}>}
|
|
153
|
+
*/
|
|
154
|
+
async function fetch(remoteName = 'origin', options = {}) {
|
|
155
|
+
const { prune = true, all = true, cwd } = options;
|
|
156
|
+
|
|
157
|
+
const args = ['fetch'];
|
|
158
|
+
if (all) args.push('--all');
|
|
159
|
+
if (prune) args.push('--prune');
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await execGit(args, { cwd, timeout: FETCH_TIMEOUT });
|
|
163
|
+
return { success: true };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return { success: false, error };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pull from remote
|
|
171
|
+
* @param {string} remoteName - Remote name
|
|
172
|
+
* @param {string} branchName - Branch to pull
|
|
173
|
+
* @param {string} [cwd] - Working directory
|
|
174
|
+
* @returns {Promise<{success: boolean, error?: GitError}>}
|
|
175
|
+
*/
|
|
176
|
+
async function pull(remoteName, branchName, cwd) {
|
|
177
|
+
try {
|
|
178
|
+
await execGit(['pull', remoteName, branchName], {
|
|
179
|
+
cwd,
|
|
180
|
+
timeout: FETCH_TIMEOUT,
|
|
181
|
+
});
|
|
182
|
+
return { success: true };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return { success: false, error };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get commit log for a branch
|
|
190
|
+
* @param {string} branchName - Branch name
|
|
191
|
+
* @param {Object} [options] - Log options
|
|
192
|
+
* @param {number} [options.count=10] - Number of commits
|
|
193
|
+
* @param {string} [options.format] - Format string
|
|
194
|
+
* @param {string} [options.cwd] - Working directory
|
|
195
|
+
* @returns {Promise<string>}
|
|
196
|
+
*/
|
|
197
|
+
async function log(branchName, options = {}) {
|
|
198
|
+
const {
|
|
199
|
+
count = 10,
|
|
200
|
+
format = '%h|%s|%cr',
|
|
201
|
+
cwd,
|
|
202
|
+
} = options;
|
|
203
|
+
|
|
204
|
+
const { stdout } = await execGit(
|
|
205
|
+
['log', branchName, '-n', String(count), `--format=${format}`],
|
|
206
|
+
{ cwd }
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return stdout;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get commit count by day for sparkline
|
|
214
|
+
* @param {string} branchName - Branch name
|
|
215
|
+
* @param {number} [days=7] - Number of days
|
|
216
|
+
* @param {string} [cwd] - Working directory
|
|
217
|
+
* @returns {Promise<number[]>} - Array of commit counts per day
|
|
218
|
+
*/
|
|
219
|
+
async function getCommitsByDay(branchName, days = 7, cwd) {
|
|
220
|
+
const counts = new Array(days).fill(0);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const { stdout } = await execGit(
|
|
224
|
+
['log', branchName, '--format=%ci', `--since=${days} days ago`],
|
|
225
|
+
{ cwd, timeout: 10000 }
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (!stdout) return counts;
|
|
229
|
+
|
|
230
|
+
const today = new Date();
|
|
231
|
+
today.setHours(0, 0, 0, 0);
|
|
232
|
+
|
|
233
|
+
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
234
|
+
const commitDate = new Date(line);
|
|
235
|
+
commitDate.setHours(0, 0, 0, 0);
|
|
236
|
+
const daysDiff = Math.floor((today.getTime() - commitDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
237
|
+
if (daysDiff >= 0 && daysDiff < days) {
|
|
238
|
+
counts[days - 1 - daysDiff]++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Return zeros on error
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return counts;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Stash all uncommitted changes (tracked and untracked)
|
|
250
|
+
* @param {Object} [options] - Stash options
|
|
251
|
+
* @param {string} [options.message] - Optional stash message
|
|
252
|
+
* @param {boolean} [options.includeUntracked=true] - Include untracked files
|
|
253
|
+
* @param {string} [options.cwd] - Working directory
|
|
254
|
+
* @returns {Promise<{success: boolean, error?: GitError}>}
|
|
255
|
+
*/
|
|
256
|
+
async function stash(options = {}) {
|
|
257
|
+
const { message, includeUntracked = true, cwd } = options;
|
|
258
|
+
|
|
259
|
+
const args = ['stash', 'push'];
|
|
260
|
+
if (includeUntracked) args.push('--include-untracked');
|
|
261
|
+
if (message) {
|
|
262
|
+
args.push('-m', message);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const result = await execGit(args, { cwd });
|
|
267
|
+
// git stash returns "No local changes to save" if there's nothing to stash
|
|
268
|
+
if (result.stdout.includes('No local changes')) {
|
|
269
|
+
return { success: false, error: new GitError('No local changes to stash', 'GIT_STASH_EMPTY') };
|
|
270
|
+
}
|
|
271
|
+
return { success: true };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return {
|
|
274
|
+
success: false,
|
|
275
|
+
error: error instanceof GitError ? error : GitError.fromExecError(error, 'stash'),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Pop the most recent stash entry
|
|
282
|
+
* @param {Object} [options] - Options
|
|
283
|
+
* @param {string} [options.cwd] - Working directory
|
|
284
|
+
* @returns {Promise<{success: boolean, error?: GitError}>}
|
|
285
|
+
*/
|
|
286
|
+
async function stashPop(options = {}) {
|
|
287
|
+
const { cwd } = options;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
await execGit(['stash', 'pop'], { cwd });
|
|
291
|
+
return { success: true };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return {
|
|
294
|
+
success: false,
|
|
295
|
+
error: error instanceof GitError ? error : GitError.fromExecError(error, 'stash pop'),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Check if working directory has uncommitted changes
|
|
302
|
+
* @param {string} [cwd] - Working directory
|
|
303
|
+
* @returns {Promise<boolean>}
|
|
304
|
+
*/
|
|
305
|
+
async function hasUncommittedChanges(cwd) {
|
|
306
|
+
try {
|
|
307
|
+
const { stdout } = await execGit(['status', '--porcelain'], {
|
|
308
|
+
cwd,
|
|
309
|
+
timeout: 5000,
|
|
310
|
+
});
|
|
311
|
+
return stdout.length > 0;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get changed files for a branch compared to another
|
|
319
|
+
* @param {string} branchName - Branch to compare
|
|
320
|
+
* @param {string} [baseBranch] - Base branch (defaults to current)
|
|
321
|
+
* @param {string} [cwd] - Working directory
|
|
322
|
+
* @returns {Promise<string[]>}
|
|
323
|
+
*/
|
|
324
|
+
async function getChangedFiles(branchName, baseBranch = 'HEAD', cwd) {
|
|
325
|
+
try {
|
|
326
|
+
const { stdout } = await execGit(
|
|
327
|
+
['diff', '--name-only', `${baseBranch}...${branchName}`],
|
|
328
|
+
{ cwd }
|
|
329
|
+
);
|
|
330
|
+
return stdout.split('\n').filter(Boolean);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Parse git diff --stat output into added/deleted line counts
|
|
338
|
+
* @param {string} diffStatOutput - Output from `git diff --stat`
|
|
339
|
+
* @returns {{added: number, deleted: number}}
|
|
340
|
+
*/
|
|
341
|
+
function parseDiffStats(diffStatOutput) {
|
|
342
|
+
// Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
|
|
343
|
+
const match = diffStatOutput.match(/(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)/);
|
|
344
|
+
if (match) {
|
|
345
|
+
return { added: parseInt(match[1], 10), deleted: parseInt(match[2], 10) };
|
|
346
|
+
}
|
|
347
|
+
// Try to match just insertions or just deletions
|
|
348
|
+
const insertMatch = diffStatOutput.match(/(\d+) insertions?\(\+\)/);
|
|
349
|
+
const deleteMatch = diffStatOutput.match(/(\d+) deletions?\(-\)/);
|
|
350
|
+
return {
|
|
351
|
+
added: insertMatch ? parseInt(insertMatch[1], 10) : 0,
|
|
352
|
+
deleted: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get diff stats between two commits
|
|
358
|
+
* @param {string} fromCommit - Starting commit
|
|
359
|
+
* @param {string} [toCommit='HEAD'] - Ending commit
|
|
360
|
+
* @param {Object} [options] - Options
|
|
361
|
+
* @param {string} [options.cwd] - Working directory
|
|
362
|
+
* @returns {Promise<{added: number, deleted: number}>}
|
|
363
|
+
*/
|
|
364
|
+
async function getDiffStats(fromCommit, toCommit = 'HEAD', options = {}) {
|
|
365
|
+
try {
|
|
366
|
+
const { stdout } = await execGit(['diff', '--stat', `${fromCommit}..${toCommit}`], options);
|
|
367
|
+
return parseDiffStats(stdout);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
return { added: 0, deleted: 0 };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Delete a local branch
|
|
375
|
+
* @param {string} branchName - Branch to delete
|
|
376
|
+
* @param {Object} [options] - Options
|
|
377
|
+
* @param {boolean} [options.force=false] - Force delete (git branch -D) even if not fully merged
|
|
378
|
+
* @param {string} [options.cwd] - Working directory
|
|
379
|
+
* @returns {Promise<{success: boolean, error?: GitError}>}
|
|
380
|
+
*/
|
|
381
|
+
async function deleteLocalBranch(branchName, options = {}) {
|
|
382
|
+
const { force = false, cwd } = options;
|
|
383
|
+
const flag = force ? '-D' : '-d';
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
await execGit(['branch', flag, branchName], { cwd });
|
|
387
|
+
return { success: true };
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return {
|
|
390
|
+
success: false,
|
|
391
|
+
error: error instanceof GitError ? error : GitError.fromExecError(error, `branch ${flag}`),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
module.exports = {
|
|
397
|
+
execGit,
|
|
398
|
+
execGitSilent,
|
|
399
|
+
isGitAvailable,
|
|
400
|
+
isGitRepository,
|
|
401
|
+
getRemotes,
|
|
402
|
+
remoteExists,
|
|
403
|
+
fetch,
|
|
404
|
+
pull,
|
|
405
|
+
log,
|
|
406
|
+
getCommitsByDay,
|
|
407
|
+
hasUncommittedChanges,
|
|
408
|
+
stash,
|
|
409
|
+
stashPop,
|
|
410
|
+
getChangedFiles,
|
|
411
|
+
parseDiffStats,
|
|
412
|
+
getDiffStats,
|
|
413
|
+
deleteLocalBranch,
|
|
414
|
+
DEFAULT_TIMEOUT,
|
|
415
|
+
FETCH_TIMEOUT,
|
|
416
|
+
};
|
package/src/git/pr.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR/CI integration for GitHub (gh) and GitLab (glab) CLIs
|
|
3
|
+
* @module git/pr
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse GitHub PR JSON response into normalized PR info.
|
|
8
|
+
* @param {Array} prs - Array of PR objects from gh CLI
|
|
9
|
+
* @returns {object|null} Normalized PR info
|
|
10
|
+
*/
|
|
11
|
+
function parseGitHubPr(prs) {
|
|
12
|
+
if (!prs || prs.length === 0) return null;
|
|
13
|
+
const pr = prs[0];
|
|
14
|
+
const checks = pr.statusCheckRollup || [];
|
|
15
|
+
const checksPass = checks.length > 0 && checks.every(c => c.conclusion === 'SUCCESS');
|
|
16
|
+
const checksFail = checks.some(c => c.conclusion === 'FAILURE');
|
|
17
|
+
return {
|
|
18
|
+
number: pr.number,
|
|
19
|
+
title: pr.title,
|
|
20
|
+
state: pr.state,
|
|
21
|
+
approved: pr.reviewDecision === 'APPROVED',
|
|
22
|
+
checksPass,
|
|
23
|
+
checksFail,
|
|
24
|
+
checksCount: checks.length,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse GitLab MR JSON response into normalized PR info.
|
|
30
|
+
* @param {Array} mrs - Array of MR objects from glab CLI
|
|
31
|
+
* @returns {object|null} Normalized PR info
|
|
32
|
+
*/
|
|
33
|
+
function parseGitLabMr(mrs) {
|
|
34
|
+
if (!mrs || mrs.length === 0) return null;
|
|
35
|
+
const mr = mrs[0];
|
|
36
|
+
return {
|
|
37
|
+
number: mr.iid,
|
|
38
|
+
title: mr.title,
|
|
39
|
+
state: mr.state === 'merged' ? 'MERGED' : mr.state === 'opened' ? 'OPEN' : 'CLOSED',
|
|
40
|
+
approved: false,
|
|
41
|
+
checksPass: false,
|
|
42
|
+
checksFail: false,
|
|
43
|
+
checksCount: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse bulk GitHub PR list into a Map of branch -> PR status.
|
|
49
|
+
* @param {Array} prs - Array of PR objects from gh CLI
|
|
50
|
+
* @returns {Map<string, {state: string, number: number, title: string}>}
|
|
51
|
+
*/
|
|
52
|
+
function parseGitHubPrList(prs) {
|
|
53
|
+
const map = new Map();
|
|
54
|
+
if (!prs || !Array.isArray(prs)) return map;
|
|
55
|
+
for (const pr of prs) {
|
|
56
|
+
const existing = map.get(pr.headRefName);
|
|
57
|
+
if (!existing || pr.number > existing.number) {
|
|
58
|
+
map.set(pr.headRefName, {
|
|
59
|
+
state: pr.state,
|
|
60
|
+
number: pr.number,
|
|
61
|
+
title: pr.title,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return map;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse bulk GitLab MR list into a Map of branch -> PR status.
|
|
70
|
+
* @param {Array} mrs - Array of MR objects from glab CLI
|
|
71
|
+
* @returns {Map<string, {state: string, number: number, title: string}>}
|
|
72
|
+
*/
|
|
73
|
+
function parseGitLabMrList(mrs) {
|
|
74
|
+
const map = new Map();
|
|
75
|
+
if (!mrs || !Array.isArray(mrs)) return map;
|
|
76
|
+
for (const mr of mrs) {
|
|
77
|
+
const branchName = mr.source_branch;
|
|
78
|
+
const existing = map.get(branchName);
|
|
79
|
+
if (!existing || mr.iid > existing.number) {
|
|
80
|
+
map.set(branchName, {
|
|
81
|
+
state: mr.state === 'merged' ? 'MERGED' : mr.state === 'opened' ? 'OPEN' : 'CLOSED',
|
|
82
|
+
number: mr.iid,
|
|
83
|
+
title: mr.title,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return map;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Default/base branches that should never get "merged" treatment.
|
|
92
|
+
*/
|
|
93
|
+
const BASE_BRANCH_RE = /^(main|master|develop|development|staging|production|trunk|release)$/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if a branch name is a base/default branch.
|
|
97
|
+
* @param {string} name
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function isBaseBranch(name) {
|
|
101
|
+
return BASE_BRANCH_RE.test(name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
parseGitHubPr,
|
|
106
|
+
parseGitLabMr,
|
|
107
|
+
parseGitHubPrList,
|
|
108
|
+
parseGitLabMrList,
|
|
109
|
+
BASE_BRANCH_RE,
|
|
110
|
+
isBaseBranch,
|
|
111
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git remote URL parsing and branch URL building
|
|
3
|
+
* @module git/remote
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse a git remote URL into { host, path } components.
|
|
8
|
+
* Supports SSH (git@host:path), HTTPS, and ssh:// protocol formats.
|
|
9
|
+
* @param {string} remoteUrl - The raw remote URL from git
|
|
10
|
+
* @returns {{ host: string, path: string } | null}
|
|
11
|
+
*/
|
|
12
|
+
function parseRemoteUrl(remoteUrl) {
|
|
13
|
+
const url = (remoteUrl || '').trim();
|
|
14
|
+
|
|
15
|
+
// SSH format: git@host:user/repo.git
|
|
16
|
+
const sshMatch = url.match(/^[\w-]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
17
|
+
if (sshMatch) return { host: sshMatch[1], path: sshMatch[2] };
|
|
18
|
+
|
|
19
|
+
// HTTPS/HTTP format: https://host/path.git
|
|
20
|
+
const httpMatch = url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
21
|
+
if (httpMatch) return { host: httpMatch[1], path: httpMatch[2] };
|
|
22
|
+
|
|
23
|
+
// ssh:// format: ssh://git@host/user/repo.git or ssh://git@host:port/user/repo.git
|
|
24
|
+
const sshProtoMatch = url.match(/^ssh:\/\/[\w-]+@([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?$/);
|
|
25
|
+
if (sshProtoMatch) return { host: sshProtoMatch[1], path: sshProtoMatch[2] };
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a branch URL for the appropriate git hosting service.
|
|
32
|
+
* Each service has its own URL format for viewing branches.
|
|
33
|
+
* @param {string} baseUrl - Repository base URL (e.g., https://github.com/user/repo)
|
|
34
|
+
* @param {string} host - Hostname of the git hosting service
|
|
35
|
+
* @param {string} branchName - Name of the branch
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function buildBranchUrl(baseUrl, host, branchName) {
|
|
39
|
+
const branch = encodeURIComponent(branchName);
|
|
40
|
+
|
|
41
|
+
// Azure DevOps: dev.azure.com/org/project/_git/repo or org.visualstudio.com
|
|
42
|
+
if (host === 'dev.azure.com' || host.endsWith('.visualstudio.com')) {
|
|
43
|
+
return `${baseUrl}?version=GB${branch}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Bitbucket Cloud
|
|
47
|
+
if (host === 'bitbucket.org') {
|
|
48
|
+
return `${baseUrl}/src/${branch}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// AWS CodeCommit
|
|
52
|
+
if (host.match(/codecommit\..+\.amazonaws\.com/)) {
|
|
53
|
+
return `${baseUrl}/browse/refs/heads/${branch}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// SourceHut
|
|
57
|
+
if (host === 'git.sr.ht') {
|
|
58
|
+
return `${baseUrl}/tree/${branch}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// GitHub, GitLab, Codeberg, Gitea, Forgejo, Gogs, and self-hosted instances
|
|
62
|
+
// All use /tree/<branch>
|
|
63
|
+
return `${baseUrl}/tree/${branch}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detect the git hosting platform from a web URL.
|
|
68
|
+
* @param {string|null} webUrl - Web URL of the repository
|
|
69
|
+
* @returns {string|null} Platform name: 'github' | 'gitlab' | 'bitbucket' | 'azure' | null
|
|
70
|
+
*/
|
|
71
|
+
function detectPlatform(webUrl) {
|
|
72
|
+
if (!webUrl) return null;
|
|
73
|
+
try {
|
|
74
|
+
const host = new URL(webUrl).hostname;
|
|
75
|
+
if (host === 'github.com' || host.includes('github')) return 'github';
|
|
76
|
+
if (host === 'gitlab.com' || host.includes('gitlab')) return 'gitlab';
|
|
77
|
+
if (host === 'bitbucket.org' || host.includes('bitbucket')) return 'bitbucket';
|
|
78
|
+
if (host === 'dev.azure.com' || host.includes('visualstudio.com')) return 'azure';
|
|
79
|
+
} catch (e) { /* ignore */ }
|
|
80
|
+
return 'github'; // default assumption for self-hosted
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a web URL for a repository from a parsed remote.
|
|
85
|
+
* Handles Azure DevOps SSH special case.
|
|
86
|
+
* @param {{ host: string, path: string }} parsed - Parsed remote URL
|
|
87
|
+
* @param {string|null} branchName - Optional branch name
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
function buildWebUrl(parsed, branchName) {
|
|
91
|
+
if (!parsed) return null;
|
|
92
|
+
|
|
93
|
+
let baseUrl;
|
|
94
|
+
|
|
95
|
+
// Azure DevOps SSH uses org@ssh.dev.azure.com:v3/org/project/repo
|
|
96
|
+
if (parsed.host === 'ssh.dev.azure.com') {
|
|
97
|
+
const parts = parsed.path.replace(/^v3\//, '').split('/');
|
|
98
|
+
if (parts.length >= 3) {
|
|
99
|
+
baseUrl = `https://dev.azure.com/${parts[0]}/${parts[1]}/_git/${parts.slice(2).join('/')}`;
|
|
100
|
+
if (branchName) return buildBranchUrl(baseUrl, 'dev.azure.com', branchName);
|
|
101
|
+
return baseUrl;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
baseUrl = `https://${parsed.host}/${parsed.path}`;
|
|
107
|
+
if (branchName) return buildBranchUrl(baseUrl, parsed.host, branchName);
|
|
108
|
+
return baseUrl;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract a Claude Code session URL from a commit message body.
|
|
113
|
+
* @param {string} commitBody - Full commit message body
|
|
114
|
+
* @returns {string|null} Session URL or null
|
|
115
|
+
*/
|
|
116
|
+
function extractSessionUrl(commitBody) {
|
|
117
|
+
const match = (commitBody || '').match(/https:\/\/claude\.ai\/code\/session_[\w]+/);
|
|
118
|
+
return match ? match[0] : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
parseRemoteUrl,
|
|
123
|
+
buildBranchUrl,
|
|
124
|
+
detectPlatform,
|
|
125
|
+
buildWebUrl,
|
|
126
|
+
extractSessionUrl,
|
|
127
|
+
};
|