@aikotools/repo-maintenance 1.0.2
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 +227 -0
- package/bin/loader.js +10 -0
- package/bin/repohub.js +5 -0
- package/dist/client/assets/index-Bh_JYZxI.js +15 -0
- package/dist/client/assets/index-D-CxJxP4.css +1 -0
- package/dist/client/index.html +13 -0
- package/dist/server/server/index.js +118 -0
- package/dist/server/server/services/bulk-service.js +157 -0
- package/dist/server/server/services/cascade-service.js +474 -0
- package/dist/server/server/services/config-service.js +343 -0
- package/dist/server/server/services/dependency-resolver.js +144 -0
- package/dist/server/server/services/git-service.js +320 -0
- package/dist/server/server/services/package-service.js +152 -0
- package/dist/server/server/services/process.js +51 -0
- package/dist/server/server/services/pull-all-service.js +415 -0
- package/dist/server/server/services/repo-scanner.js +230 -0
- package/dist/server/server/services/task-queue.js +29 -0
- package/dist/server/server/trpc/context.js +4 -0
- package/dist/server/server/trpc/init.js +7 -0
- package/dist/server/server/trpc/procedures/bulk.js +110 -0
- package/dist/server/server/trpc/procedures/cascade.js +207 -0
- package/dist/server/server/trpc/procedures/dependencies.js +26 -0
- package/dist/server/server/trpc/procedures/git.js +151 -0
- package/dist/server/server/trpc/procedures/package.js +43 -0
- package/dist/server/server/trpc/procedures/project.js +181 -0
- package/dist/server/server/trpc/procedures/repos.js +42 -0
- package/dist/server/server/trpc/router.js +20 -0
- package/dist/server/shared/types.js +3 -0
- package/package.json +68 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service for cascade updates - propagates changes through all transitive dependents
|
|
3
|
+
* in topological order. Supports parallel execution within layers, CI monitoring,
|
|
4
|
+
* and pause/abort controls.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { spawnProcess } from './process';
|
|
9
|
+
import { TaskQueue } from './task-queue';
|
|
10
|
+
export class CascadeService {
|
|
11
|
+
configService;
|
|
12
|
+
parallelLimit;
|
|
13
|
+
executions = new Map();
|
|
14
|
+
executionRepos = new Map();
|
|
15
|
+
abortSignals = new Map();
|
|
16
|
+
pauseSignals = new Map();
|
|
17
|
+
constructor(configService, parallelLimit) {
|
|
18
|
+
this.configService = configService;
|
|
19
|
+
this.parallelLimit = parallelLimit;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build a cascade plan from a source repo through all affected repos.
|
|
23
|
+
* Resolves the source repo's published version from npm (single call).
|
|
24
|
+
* For transitive deps, keeps the current version spec (fast, no network).
|
|
25
|
+
*/
|
|
26
|
+
async createPlan(sourceRepoId, repos, resolver, options) {
|
|
27
|
+
const affected = resolver.getAffected(sourceRepoId);
|
|
28
|
+
const repoMap = new Map(repos.map((r) => [r.id, r]));
|
|
29
|
+
const sourceRepo = repoMap.get(sourceRepoId);
|
|
30
|
+
// Resolve the source repo's latest published version (single npm call)
|
|
31
|
+
const sourcePublishedVersion = sourceRepo
|
|
32
|
+
? await this.resolvePublishedVersion(sourceRepo.npmPackage)
|
|
33
|
+
: null;
|
|
34
|
+
const sourceVersion = sourcePublishedVersion || sourceRepo?.version || '0.0.0';
|
|
35
|
+
console.log(`[Cascade] Source ${sourceRepoId}: local=${sourceRepo?.version}, published=${sourcePublishedVersion}`);
|
|
36
|
+
// Group affected repos by layer
|
|
37
|
+
const layerMap = new Map();
|
|
38
|
+
for (const a of affected.affected) {
|
|
39
|
+
const existing = layerMap.get(a.layer) || [];
|
|
40
|
+
existing.push(a.id);
|
|
41
|
+
layerMap.set(a.layer, existing);
|
|
42
|
+
}
|
|
43
|
+
const layers = [];
|
|
44
|
+
const sortedLayers = Array.from(layerMap.keys()).sort((a, b) => a - b);
|
|
45
|
+
// Track resolved versions: only the source repo's published version is known
|
|
46
|
+
// Other repos' versions will be resolved at execution time (after CI publishes them)
|
|
47
|
+
const resolvedVersions = new Map();
|
|
48
|
+
if (sourceRepo) {
|
|
49
|
+
resolvedVersions.set(sourceRepo.npmPackage, sourceVersion);
|
|
50
|
+
}
|
|
51
|
+
for (const layerIndex of sortedLayers) {
|
|
52
|
+
const repoIds = layerMap.get(layerIndex) || [];
|
|
53
|
+
const steps = repoIds.map((repoId) => {
|
|
54
|
+
const repo = repoMap.get(repoId);
|
|
55
|
+
const depsToUpdate = [];
|
|
56
|
+
if (repo) {
|
|
57
|
+
for (const dep of repo.dependencies) {
|
|
58
|
+
const resolvedVersion = resolvedVersions.get(dep.npmName);
|
|
59
|
+
if (resolvedVersion) {
|
|
60
|
+
depsToUpdate.push({
|
|
61
|
+
npmName: dep.npmName,
|
|
62
|
+
fromVersion: dep.versionSpec,
|
|
63
|
+
toVersion: resolvedVersion,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const depNames = depsToUpdate.map((d) => d.npmName.split('/').pop()).join(', ');
|
|
69
|
+
const commitMessage = `${options.commitPrefix}update ${depNames || repoId}`;
|
|
70
|
+
return {
|
|
71
|
+
repoId,
|
|
72
|
+
status: 'pending',
|
|
73
|
+
commitMessage,
|
|
74
|
+
depsToUpdate,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
// For next layers: use the current version spec of repos in this layer
|
|
78
|
+
// (they haven't been republished yet, so we keep their existing version)
|
|
79
|
+
for (const step of steps) {
|
|
80
|
+
const repo = repoMap.get(step.repoId);
|
|
81
|
+
if (repo) {
|
|
82
|
+
resolvedVersions.set(repo.npmPackage, repo.version);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
layers.push({
|
|
86
|
+
layerIndex,
|
|
87
|
+
mode: steps.length > 1 ? 'parallel' : 'sequential',
|
|
88
|
+
steps,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
sourceRepoId,
|
|
93
|
+
sourceCommitMessage: `Source: ${sourceRepoId} v${sourceVersion}`,
|
|
94
|
+
layers,
|
|
95
|
+
totalRepos: affected.totalCount,
|
|
96
|
+
waitForCi: options.waitForCi,
|
|
97
|
+
runTests: options.runTests,
|
|
98
|
+
commitPrefix: options.commitPrefix,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Start executing a cascade plan. Returns execution ID.
|
|
103
|
+
* The execution runs asynchronously in the background.
|
|
104
|
+
*/
|
|
105
|
+
startExecution(plan, repos) {
|
|
106
|
+
const id = `cascade-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
107
|
+
const execution = {
|
|
108
|
+
id,
|
|
109
|
+
plan,
|
|
110
|
+
status: 'running',
|
|
111
|
+
currentLayerIndex: 0,
|
|
112
|
+
completedCount: 0,
|
|
113
|
+
failedCount: 0,
|
|
114
|
+
skippedCount: 0,
|
|
115
|
+
startedAt: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
this.executions.set(id, execution);
|
|
118
|
+
this.executionRepos.set(id, repos);
|
|
119
|
+
this.abortSignals.set(id, false);
|
|
120
|
+
this.pauseSignals.set(id, false);
|
|
121
|
+
// Start async execution loop (fire and forget)
|
|
122
|
+
this.executeLoop(id).catch((err) => {
|
|
123
|
+
console.error(`[Cascade] Execution ${id} failed:`, err);
|
|
124
|
+
const exec = this.executions.get(id);
|
|
125
|
+
if (exec) {
|
|
126
|
+
exec.status = 'failed';
|
|
127
|
+
exec.error = err instanceof Error ? err.message : String(err);
|
|
128
|
+
exec.completedAt = new Date().toISOString();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return id;
|
|
132
|
+
}
|
|
133
|
+
getExecution(id) {
|
|
134
|
+
return this.executions.get(id);
|
|
135
|
+
}
|
|
136
|
+
listExecutions() {
|
|
137
|
+
return Array.from(this.executions.values()).sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
138
|
+
}
|
|
139
|
+
abort(id) {
|
|
140
|
+
const exec = this.executions.get(id);
|
|
141
|
+
if (!exec || exec.status !== 'running')
|
|
142
|
+
return false;
|
|
143
|
+
this.abortSignals.set(id, true);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
pause(id) {
|
|
147
|
+
const exec = this.executions.get(id);
|
|
148
|
+
if (!exec || exec.status !== 'running')
|
|
149
|
+
return false;
|
|
150
|
+
this.pauseSignals.set(id, true);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
resume(id) {
|
|
154
|
+
const exec = this.executions.get(id);
|
|
155
|
+
if (!exec || exec.status !== 'paused')
|
|
156
|
+
return false;
|
|
157
|
+
this.pauseSignals.set(id, false);
|
|
158
|
+
exec.status = 'running';
|
|
159
|
+
// Re-enter the execution loop with stored repos
|
|
160
|
+
this.executeLoop(id).catch((err) => {
|
|
161
|
+
console.error(`[Cascade] Resume failed for ${id}:`, err);
|
|
162
|
+
});
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
skipStep(executionId, repoId) {
|
|
166
|
+
const exec = this.executions.get(executionId);
|
|
167
|
+
if (!exec)
|
|
168
|
+
return false;
|
|
169
|
+
for (const layer of exec.plan.layers) {
|
|
170
|
+
const step = layer.steps.find((s) => s.repoId === repoId);
|
|
171
|
+
if (step && step.status === 'failed') {
|
|
172
|
+
step.status = 'skipped';
|
|
173
|
+
step.completedAt = new Date().toISOString();
|
|
174
|
+
exec.skippedCount++;
|
|
175
|
+
exec.failedCount--;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
setPublishedVersion(executionId, repoId, version) {
|
|
182
|
+
const exec = this.executions.get(executionId);
|
|
183
|
+
if (!exec)
|
|
184
|
+
return false;
|
|
185
|
+
for (const layer of exec.plan.layers) {
|
|
186
|
+
const step = layer.steps.find((s) => s.repoId === repoId);
|
|
187
|
+
if (step) {
|
|
188
|
+
step.publishedVersion = version;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
// ── Private execution logic ──
|
|
195
|
+
async executeLoop(id) {
|
|
196
|
+
const exec = this.executions.get(id);
|
|
197
|
+
if (!exec)
|
|
198
|
+
return;
|
|
199
|
+
const repos = this.executionRepos.get(id) || [];
|
|
200
|
+
const repoMap = new Map(repos.map((r) => [r.id, r]));
|
|
201
|
+
for (let i = exec.currentLayerIndex; i < exec.plan.layers.length; i++) {
|
|
202
|
+
// Check abort
|
|
203
|
+
if (this.abortSignals.get(id)) {
|
|
204
|
+
exec.status = 'aborted';
|
|
205
|
+
exec.completedAt = new Date().toISOString();
|
|
206
|
+
await this.saveToHistory(exec);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Check pause
|
|
210
|
+
if (this.pauseSignals.get(id)) {
|
|
211
|
+
exec.status = 'paused';
|
|
212
|
+
exec.currentLayerIndex = i;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
exec.currentLayerIndex = i;
|
|
216
|
+
const layer = exec.plan.layers[i];
|
|
217
|
+
// Skip layers where all steps are already done/skipped
|
|
218
|
+
const pendingSteps = layer.steps.filter((s) => s.status !== 'done' && s.status !== 'skipped');
|
|
219
|
+
if (pendingSteps.length === 0)
|
|
220
|
+
continue;
|
|
221
|
+
// Execute steps in the layer
|
|
222
|
+
const queue = new TaskQueue(layer.mode === 'parallel'
|
|
223
|
+
? Math.min(this.parallelLimit, pendingSteps.length)
|
|
224
|
+
: 1);
|
|
225
|
+
await queue.run(pendingSteps, async (step) => {
|
|
226
|
+
// Check abort before each step
|
|
227
|
+
if (this.abortSignals.get(id))
|
|
228
|
+
return;
|
|
229
|
+
await this.executeStep(step, repoMap, exec);
|
|
230
|
+
});
|
|
231
|
+
// After layer: check if any failures should stop execution
|
|
232
|
+
const failedSteps = layer.steps.filter((s) => s.status === 'failed');
|
|
233
|
+
if (failedSteps.length > 0) {
|
|
234
|
+
// Don't auto-fail entire cascade; user can skip or abort
|
|
235
|
+
// But pause to let user decide
|
|
236
|
+
exec.status = 'paused';
|
|
237
|
+
exec.currentLayerIndex = i;
|
|
238
|
+
console.log(`[Cascade] Layer ${i} has ${failedSteps.length} failed steps. Pausing for user action.`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Wait for CI if enabled
|
|
242
|
+
if (exec.plan.waitForCi) {
|
|
243
|
+
const ciSteps = layer.steps.filter((s) => s.status === 'done' && !s.publishedVersion);
|
|
244
|
+
for (const step of ciSteps) {
|
|
245
|
+
step.ciStatus = 'pending';
|
|
246
|
+
await this.waitForCi(step, repoMap, id);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// All layers complete
|
|
251
|
+
exec.status = 'completed';
|
|
252
|
+
exec.completedAt = new Date().toISOString();
|
|
253
|
+
await this.saveToHistory(exec);
|
|
254
|
+
console.log(`[Cascade] Execution ${id} completed successfully.`);
|
|
255
|
+
}
|
|
256
|
+
async executeStep(step, repoMap, exec) {
|
|
257
|
+
const repo = repoMap.get(step.repoId);
|
|
258
|
+
if (!repo) {
|
|
259
|
+
step.status = 'failed';
|
|
260
|
+
step.error = 'Repo not found';
|
|
261
|
+
exec.failedCount++;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
step.startedAt = new Date().toISOString();
|
|
265
|
+
try {
|
|
266
|
+
// 1. Update deps in package.json
|
|
267
|
+
step.status = 'updating-deps';
|
|
268
|
+
await this.updatePackageJsonDeps(repo.absolutePath, step.depsToUpdate);
|
|
269
|
+
// 2. Install
|
|
270
|
+
step.status = 'installing';
|
|
271
|
+
await this.runCommand(repo.absolutePath, ['pnpm', 'install', '--no-frozen-lockfile']);
|
|
272
|
+
// 3. Test (optional)
|
|
273
|
+
if (exec.plan.runTests) {
|
|
274
|
+
step.status = 'testing';
|
|
275
|
+
await this.runCommand(repo.absolutePath, ['pnpm', 'test']);
|
|
276
|
+
}
|
|
277
|
+
// 4. Commit (skip if nothing changed)
|
|
278
|
+
step.status = 'committing';
|
|
279
|
+
await this.runCommand(repo.absolutePath, ['git', 'add', '-A']);
|
|
280
|
+
const statusOutput = await this.runCommand(repo.absolutePath, [
|
|
281
|
+
'git',
|
|
282
|
+
'status',
|
|
283
|
+
'--porcelain',
|
|
284
|
+
]);
|
|
285
|
+
if (!statusOutput.trim()) {
|
|
286
|
+
// No changes to commit (version was already up to date)
|
|
287
|
+
step.status = 'done';
|
|
288
|
+
step.completedAt = new Date().toISOString();
|
|
289
|
+
exec.completedCount++;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
await this.runCommand(repo.absolutePath, ['git', 'commit', '-m', step.commitMessage]);
|
|
293
|
+
// 5. Pull rebase + Push
|
|
294
|
+
step.status = 'pushing';
|
|
295
|
+
try {
|
|
296
|
+
await this.runCommand(repo.absolutePath, ['git', 'pull', '--rebase']);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// pull may fail if no upstream tracking, ignore
|
|
300
|
+
}
|
|
301
|
+
await this.runCommand(repo.absolutePath, ['git', 'push']);
|
|
302
|
+
// Done
|
|
303
|
+
step.status = 'done';
|
|
304
|
+
step.completedAt = new Date().toISOString();
|
|
305
|
+
exec.completedCount++;
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
step.status = 'failed';
|
|
309
|
+
step.error = err instanceof Error ? err.message : String(err);
|
|
310
|
+
step.completedAt = new Date().toISOString();
|
|
311
|
+
exec.failedCount++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async updatePackageJsonDeps(repoPath, depsToUpdate) {
|
|
315
|
+
if (depsToUpdate.length === 0)
|
|
316
|
+
return;
|
|
317
|
+
const pkgPath = path.join(repoPath, 'package.json');
|
|
318
|
+
const content = await readFile(pkgPath, 'utf-8');
|
|
319
|
+
const pkg = JSON.parse(content);
|
|
320
|
+
for (const dep of depsToUpdate) {
|
|
321
|
+
if (pkg.dependencies?.[dep.npmName]) {
|
|
322
|
+
pkg.dependencies[dep.npmName] = dep.toVersion;
|
|
323
|
+
}
|
|
324
|
+
if (pkg.devDependencies?.[dep.npmName]) {
|
|
325
|
+
pkg.devDependencies[dep.npmName] = dep.toVersion;
|
|
326
|
+
}
|
|
327
|
+
if (pkg.peerDependencies?.[dep.npmName]) {
|
|
328
|
+
pkg.peerDependencies[dep.npmName] = dep.toVersion;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
|
332
|
+
}
|
|
333
|
+
async runCommand(cwd, cmd) {
|
|
334
|
+
const { promise } = spawnProcess(cmd, { cwd });
|
|
335
|
+
const result = await promise;
|
|
336
|
+
if (result.exitCode !== 0) {
|
|
337
|
+
throw new Error(`Command failed (${cmd.join(' ')}): ${result.stderr || result.stdout}`);
|
|
338
|
+
}
|
|
339
|
+
return result.stdout;
|
|
340
|
+
}
|
|
341
|
+
async waitForCi(step, repoMap, executionId) {
|
|
342
|
+
const repo = repoMap.get(step.repoId);
|
|
343
|
+
if (!repo)
|
|
344
|
+
return;
|
|
345
|
+
const slug = await this.getGitHubSlug(repo.absolutePath);
|
|
346
|
+
if (!slug) {
|
|
347
|
+
step.ciStatus = 'skipped';
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Poll CI status every 15 seconds, max 20 minutes
|
|
351
|
+
const maxAttempts = 80;
|
|
352
|
+
step.ciStatus = 'pending';
|
|
353
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
354
|
+
// Check abort
|
|
355
|
+
if (this.abortSignals.get(executionId))
|
|
356
|
+
return;
|
|
357
|
+
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
358
|
+
try {
|
|
359
|
+
const result = await this.checkCiStatus(slug);
|
|
360
|
+
step.ciStatus = result.status;
|
|
361
|
+
step.ciRunUrl = result.url;
|
|
362
|
+
if (result.status === 'success') {
|
|
363
|
+
// Try to resolve published version
|
|
364
|
+
step.publishedVersion = (await this.resolvePublishedVersion(repo.npmPackage)) || undefined;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (result.status === 'failure') {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// gh CLI not available or error
|
|
373
|
+
step.ciStatus = 'skipped';
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Timeout
|
|
378
|
+
step.ciStatus = 'failure';
|
|
379
|
+
step.error = 'CI monitoring timed out after 20 minutes';
|
|
380
|
+
}
|
|
381
|
+
async getGitHubSlug(repoPath) {
|
|
382
|
+
try {
|
|
383
|
+
const result = await this.runCommand(repoPath, [
|
|
384
|
+
'git',
|
|
385
|
+
'remote',
|
|
386
|
+
'get-url',
|
|
387
|
+
'origin',
|
|
388
|
+
]);
|
|
389
|
+
const url = result.trim();
|
|
390
|
+
// Parse: https://github.com/org/repo.git or git@github.com:org/repo.git
|
|
391
|
+
const match = url.match(/github\.com[/:](.+?)(?:\.git)?$/) ||
|
|
392
|
+
url.match(/github\.com[/:](.+)$/);
|
|
393
|
+
return match?.[1] || null;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async checkCiStatus(slug) {
|
|
400
|
+
try {
|
|
401
|
+
const output = await this.runCommand('.', [
|
|
402
|
+
'gh',
|
|
403
|
+
'run',
|
|
404
|
+
'list',
|
|
405
|
+
'--repo',
|
|
406
|
+
slug,
|
|
407
|
+
'--limit',
|
|
408
|
+
'1',
|
|
409
|
+
'--json',
|
|
410
|
+
'status,conclusion,url',
|
|
411
|
+
]);
|
|
412
|
+
const runs = JSON.parse(output);
|
|
413
|
+
if (!runs || runs.length === 0) {
|
|
414
|
+
return { status: 'pending' };
|
|
415
|
+
}
|
|
416
|
+
const run = runs[0];
|
|
417
|
+
const url = run.url;
|
|
418
|
+
if (run.status === 'completed') {
|
|
419
|
+
return {
|
|
420
|
+
status: run.conclusion === 'success' ? 'success' : 'failure',
|
|
421
|
+
url,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (run.status === 'in_progress' || run.status === 'queued') {
|
|
425
|
+
return { status: 'running', url };
|
|
426
|
+
}
|
|
427
|
+
return { status: 'pending', url };
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
throw new Error('gh CLI unavailable');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Resolve the latest published version of an npm package from the registry.
|
|
435
|
+
* Returns null if not found or registry unavailable.
|
|
436
|
+
*/
|
|
437
|
+
async resolvePublishedVersion(npmPackage) {
|
|
438
|
+
try {
|
|
439
|
+
const config = await this.configService.getProjectConfig();
|
|
440
|
+
const registry = config.npmRegistry || 'https://npm.pkg.github.com';
|
|
441
|
+
const output = await this.runCommand('.', [
|
|
442
|
+
'npm',
|
|
443
|
+
'view',
|
|
444
|
+
`${npmPackage}@latest`,
|
|
445
|
+
'version',
|
|
446
|
+
`--registry=${registry}`,
|
|
447
|
+
]);
|
|
448
|
+
const version = output.trim();
|
|
449
|
+
return version || null;
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async saveToHistory(exec) {
|
|
456
|
+
const entry = {
|
|
457
|
+
id: exec.id,
|
|
458
|
+
sourceRepoId: exec.plan.sourceRepoId,
|
|
459
|
+
status: exec.status,
|
|
460
|
+
totalRepos: exec.plan.totalRepos,
|
|
461
|
+
completedCount: exec.completedCount,
|
|
462
|
+
failedCount: exec.failedCount,
|
|
463
|
+
startedAt: exec.startedAt,
|
|
464
|
+
completedAt: exec.completedAt,
|
|
465
|
+
layers: exec.plan.layers,
|
|
466
|
+
};
|
|
467
|
+
try {
|
|
468
|
+
await this.configService.saveHistory(entry);
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
console.error('[Cascade] Failed to save history:', err);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|