@aspruyt/xfg 3.13.1 → 4.0.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 +1 -4
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -1
- package/dist/cli/program.js +0 -12
- package/dist/cli/sync-command.d.ts +2 -3
- package/dist/cli/sync-command.js +143 -4
- package/dist/cli/types.d.ts +11 -4
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/validator.d.ts +0 -5
- package/dist/config/validator.js +13 -30
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/lifecycle/github-lifecycle-provider.d.ts +9 -0
- package/dist/lifecycle/github-lifecycle-provider.js +31 -0
- package/dist/settings/labels/diff.d.ts +2 -2
- package/dist/settings/labels/diff.js +15 -19
- package/dist/settings/labels/processor.d.ts +0 -10
- package/dist/settings/labels/processor.js +3 -16
- package/dist/settings/rulesets/diff.d.ts +2 -2
- package/dist/settings/rulesets/diff.js +6 -8
- package/dist/settings/rulesets/processor.d.ts +0 -10
- package/dist/settings/rulesets/processor.js +3 -18
- package/dist/sync/index.d.ts +1 -2
- package/dist/sync/index.js +1 -2
- package/dist/sync/manifest.d.ts +3 -45
- package/dist/sync/manifest.js +46 -166
- package/dist/sync/repository-processor.d.ts +1 -6
- package/dist/sync/repository-processor.js +2 -52
- package/dist/sync/types.d.ts +0 -4
- package/dist/vcs/graphql-commit-strategy.js +15 -1
- package/package.json +1 -1
- package/dist/cli/settings/lifecycle-checks.d.ts +0 -11
- package/dist/cli/settings/lifecycle-checks.js +0 -64
- package/dist/cli/settings/process-labels.d.ts +0 -9
- package/dist/cli/settings/process-labels.js +0 -125
- package/dist/cli/settings/process-repo-settings.d.ts +0 -9
- package/dist/cli/settings/process-repo-settings.js +0 -80
- package/dist/cli/settings/process-rulesets.d.ts +0 -9
- package/dist/cli/settings/process-rulesets.js +0 -118
- package/dist/cli/settings-command.d.ts +0 -11
- package/dist/cli/settings-command.js +0 -90
- package/dist/sync/manifest-strategy.d.ts +0 -21
- package/dist/sync/manifest-strategy.js +0 -67
package/README.md
CHANGED
|
@@ -47,11 +47,8 @@ npm install -g @aspruyt/xfg
|
|
|
47
47
|
# Authenticate (GitHub)
|
|
48
48
|
gh auth login
|
|
49
49
|
|
|
50
|
-
# Sync files across repos
|
|
50
|
+
# Sync files, settings, rulesets, and labels across repos
|
|
51
51
|
xfg sync --config ./config.yaml
|
|
52
|
-
|
|
53
|
-
# Apply repository settings and rulesets
|
|
54
|
-
xfg settings --config ./config.yaml
|
|
55
52
|
```
|
|
56
53
|
|
|
57
54
|
### Example Config
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
export { runSync } from "./sync-command.js";
|
|
2
|
-
export { runSettings } from "./settings-command.js";
|
|
3
2
|
export { program } from "./program.js";
|
|
4
3
|
export { type IRepositoryProcessor, type ProcessorFactory, type IRulesetProcessor, type RulesetProcessorFactory, type RepoSettingsProcessorFactory, type IRepoSettingsProcessor, type ILabelsProcessor, type LabelsProcessorFactory, defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./types.js";
|
|
5
4
|
export type { SyncOptions, SharedOptions } from "./sync-command.js";
|
|
6
|
-
export type { SettingsOptions } from "./settings-command.js";
|
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// CLI command implementations
|
|
2
2
|
export { runSync } from "./sync-command.js";
|
|
3
|
-
export { runSettings } from "./settings-command.js";
|
|
4
3
|
export { program } from "./program.js";
|
|
5
4
|
// Export types - using 'export type' for type aliases, but interfaces need special handling
|
|
6
5
|
// For ESM compatibility, re-export everything from types.js
|
package/dist/cli/program.js
CHANGED
|
@@ -3,7 +3,6 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { runSync } from "./sync-command.js";
|
|
6
|
-
import { runSettings } from "./settings-command.js";
|
|
7
6
|
// Get version from package.json
|
|
8
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
8
|
const __dirname = dirname(__filename);
|
|
@@ -56,15 +55,4 @@ const syncCommand = new Command("sync")
|
|
|
56
55
|
});
|
|
57
56
|
addSharedOptions(syncCommand);
|
|
58
57
|
program.addCommand(syncCommand);
|
|
59
|
-
// Settings command (repository settings and rulesets)
|
|
60
|
-
const settingsCommand = new Command("settings")
|
|
61
|
-
.description("Manage GitHub repository settings and rulesets")
|
|
62
|
-
.action((opts) => {
|
|
63
|
-
runSettings(opts).catch((error) => {
|
|
64
|
-
console.error("Fatal error:", error);
|
|
65
|
-
process.exit(1);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
addSharedOptions(settingsCommand);
|
|
69
|
-
program.addCommand(settingsCommand);
|
|
70
58
|
export { program };
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { MergeMode, MergeStrategy } from "../config/index.js";
|
|
2
|
-
import {
|
|
3
|
-
import { type IRepoLifecycleManager } from "../lifecycle/index.js";
|
|
2
|
+
import { type SyncDependencies } from "./types.js";
|
|
4
3
|
/**
|
|
5
4
|
* Shared options common to all commands.
|
|
6
5
|
*/
|
|
@@ -23,4 +22,4 @@ export interface SyncOptions extends SharedOptions {
|
|
|
23
22
|
/**
|
|
24
23
|
* Run the sync command - synchronizes files across repositories.
|
|
25
24
|
*/
|
|
26
|
-
export declare function runSync(options: SyncOptions,
|
|
25
|
+
export declare function runSync(options: SyncOptions, deps?: SyncDependencies): Promise<void>;
|
package/dist/cli/sync-command.js
CHANGED
|
@@ -7,7 +7,10 @@ import { sanitizeBranchName, validateBranchName } from "../vcs/git-ops.js";
|
|
|
7
7
|
import { hasGitHubAppCredentials, GitHubAppTokenManager, } from "../vcs/index.js";
|
|
8
8
|
import { logger } from "../shared/logger.js";
|
|
9
9
|
import { generateWorkspaceName } from "../shared/workspace-utils.js";
|
|
10
|
-
import { defaultProcessorFactory } from "./types.js";
|
|
10
|
+
import { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./types.js";
|
|
11
|
+
import { ResultsCollector } from "./settings/results-collector.js";
|
|
12
|
+
import { buildSettingsReport } from "./settings-report-builder.js";
|
|
13
|
+
import { formatSettingsReportCLI } from "../output/settings-report.js";
|
|
11
14
|
import { buildSyncReport } from "./sync-report-builder.js";
|
|
12
15
|
import { formatSyncReportCLI } from "../output/sync-report.js";
|
|
13
16
|
import { buildLifecycleReport, formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecycle-report.js";
|
|
@@ -63,7 +66,8 @@ function determineMergeOutcome(result) {
|
|
|
63
66
|
/**
|
|
64
67
|
* Run the sync command - synchronizes files across repositories.
|
|
65
68
|
*/
|
|
66
|
-
export async function runSync(options,
|
|
69
|
+
export async function runSync(options, deps = {}) {
|
|
70
|
+
const { processorFactory = defaultProcessorFactory, lifecycleManager, rulesetProcessorFactory = defaultRulesetProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory, labelsProcessorFactory = defaultLabelsProcessorFactory, } = deps;
|
|
67
71
|
const configPath = resolve(options.config);
|
|
68
72
|
if (!existsSync(configPath)) {
|
|
69
73
|
console.error(`Config file not found: ${configPath}`);
|
|
@@ -102,6 +106,7 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
|
|
|
102
106
|
: null;
|
|
103
107
|
const reportResults = [];
|
|
104
108
|
const lifecycleReportInputs = [];
|
|
109
|
+
const settingsCollector = new ResultsCollector();
|
|
105
110
|
for (let i = 0; i < config.repos.length; i++) {
|
|
106
111
|
const repoConfig = config.repos[i];
|
|
107
112
|
if (options.merge || options.mergeStrategy || options.deleteBranch) {
|
|
@@ -230,6 +235,125 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
|
|
|
230
235
|
error: error instanceof Error ? error.message : String(error),
|
|
231
236
|
});
|
|
232
237
|
}
|
|
238
|
+
// After file sync, apply settings via API (GitHub-only — ADO and GitLab repos are skipped)
|
|
239
|
+
if (repoConfig.settings && isGitHubRepo(repoInfo)) {
|
|
240
|
+
const githubRepo = repoInfo;
|
|
241
|
+
let settingsToken;
|
|
242
|
+
try {
|
|
243
|
+
settingsToken =
|
|
244
|
+
(await tokenManager?.getTokenForRepo(githubRepo)) ??
|
|
245
|
+
process.env.GH_TOKEN;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
settingsToken = process.env.GH_TOKEN;
|
|
249
|
+
}
|
|
250
|
+
// Apply rulesets
|
|
251
|
+
if (repoConfig.settings.rulesets &&
|
|
252
|
+
Object.keys(repoConfig.settings.rulesets).length > 0) {
|
|
253
|
+
try {
|
|
254
|
+
const rulesetProcessor = rulesetProcessorFactory();
|
|
255
|
+
const rulesetResult = await rulesetProcessor.process(repoConfig, repoInfo, {
|
|
256
|
+
dryRun: options.dryRun,
|
|
257
|
+
noDelete: options.noDelete,
|
|
258
|
+
token: settingsToken,
|
|
259
|
+
});
|
|
260
|
+
if (rulesetResult.planOutput?.lines?.length) {
|
|
261
|
+
logger.info("");
|
|
262
|
+
logger.info(`${repoName} - Rulesets:`);
|
|
263
|
+
for (const line of rulesetResult.planOutput.lines) {
|
|
264
|
+
logger.info(line);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else if (!rulesetResult.skipped && rulesetResult.success) {
|
|
268
|
+
logger.success(current, repoName, `Rulesets: ${rulesetResult.message}`);
|
|
269
|
+
}
|
|
270
|
+
if (!rulesetResult.skipped) {
|
|
271
|
+
settingsCollector.getOrCreate(repoName).rulesetResult =
|
|
272
|
+
rulesetResult;
|
|
273
|
+
}
|
|
274
|
+
if (!rulesetResult.success && !rulesetResult.skipped) {
|
|
275
|
+
logger.error(current, repoName, `Rulesets: ${rulesetResult.message}`);
|
|
276
|
+
settingsCollector.appendError(repoName, rulesetResult.message);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
logger.error(current, repoName, `Rulesets: ${String(error)}`);
|
|
281
|
+
settingsCollector.appendError(repoName, error);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Apply labels
|
|
285
|
+
if (repoConfig.settings.labels &&
|
|
286
|
+
Object.keys(repoConfig.settings.labels).length > 0) {
|
|
287
|
+
try {
|
|
288
|
+
const labelsProcessor = labelsProcessorFactory();
|
|
289
|
+
const labelsResult = await labelsProcessor.process(repoConfig, repoInfo, {
|
|
290
|
+
dryRun: options.dryRun,
|
|
291
|
+
noDelete: options.noDelete,
|
|
292
|
+
token: settingsToken,
|
|
293
|
+
});
|
|
294
|
+
if (labelsResult.planOutput?.lines?.length) {
|
|
295
|
+
logger.info("");
|
|
296
|
+
logger.info(`${repoName} - Labels:`);
|
|
297
|
+
for (const line of labelsResult.planOutput.lines) {
|
|
298
|
+
logger.info(line);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else if (!labelsResult.skipped && labelsResult.success) {
|
|
302
|
+
logger.success(current, repoName, `Labels: ${labelsResult.message}`);
|
|
303
|
+
}
|
|
304
|
+
if (!labelsResult.skipped) {
|
|
305
|
+
settingsCollector.getOrCreate(repoName).labelsResult = labelsResult;
|
|
306
|
+
}
|
|
307
|
+
if (!labelsResult.success && !labelsResult.skipped) {
|
|
308
|
+
logger.error(current, repoName, `Labels: ${labelsResult.message}`);
|
|
309
|
+
settingsCollector.appendError(repoName, labelsResult.message);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
logger.error(current, repoName, `Labels: ${String(error)}`);
|
|
314
|
+
settingsCollector.appendError(repoName, error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Apply repo settings
|
|
318
|
+
if (repoConfig.settings.repo &&
|
|
319
|
+
Object.keys(repoConfig.settings.repo).length > 0) {
|
|
320
|
+
try {
|
|
321
|
+
const repoSettingsProcessor = repoSettingsProcessorFactory();
|
|
322
|
+
const repoSettingsResult = await repoSettingsProcessor.process(repoConfig, repoInfo, {
|
|
323
|
+
dryRun: options.dryRun,
|
|
324
|
+
token: settingsToken,
|
|
325
|
+
});
|
|
326
|
+
if (repoSettingsResult.planOutput?.lines?.length) {
|
|
327
|
+
logger.info("");
|
|
328
|
+
logger.info(`${repoName} - Repo Settings:`);
|
|
329
|
+
for (const line of repoSettingsResult.planOutput.lines) {
|
|
330
|
+
logger.info(line);
|
|
331
|
+
}
|
|
332
|
+
if (repoSettingsResult.warnings?.length) {
|
|
333
|
+
for (const warning of repoSettingsResult.warnings) {
|
|
334
|
+
logger.info(`Warning: ${warning}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else if (!repoSettingsResult.skipped &&
|
|
339
|
+
repoSettingsResult.success) {
|
|
340
|
+
logger.success(current, repoName, `Repo settings: ${repoSettingsResult.message}`);
|
|
341
|
+
}
|
|
342
|
+
if (!repoSettingsResult.skipped) {
|
|
343
|
+
settingsCollector.getOrCreate(repoName).settingsResult =
|
|
344
|
+
repoSettingsResult;
|
|
345
|
+
}
|
|
346
|
+
if (!repoSettingsResult.success && !repoSettingsResult.skipped) {
|
|
347
|
+
logger.error(current, repoName, `Repo settings: ${repoSettingsResult.message}`);
|
|
348
|
+
settingsCollector.appendError(repoName, repoSettingsResult.message);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
logger.error(current, repoName, `Repo settings: ${String(error)}`);
|
|
353
|
+
settingsCollector.appendError(repoName, error);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
233
357
|
}
|
|
234
358
|
// Build and display lifecycle report (before sync report)
|
|
235
359
|
const lifecycleReport = buildLifecycleReport(lifecycleReportInputs);
|
|
@@ -245,15 +369,30 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
|
|
|
245
369
|
for (const line of formatSyncReportCLI(report)) {
|
|
246
370
|
console.log(line);
|
|
247
371
|
}
|
|
372
|
+
// Build and display settings report (if any settings were processed)
|
|
373
|
+
const settingsResults = settingsCollector.getAll();
|
|
374
|
+
let settingsReport;
|
|
375
|
+
if (settingsResults.length > 0) {
|
|
376
|
+
settingsReport = buildSettingsReport(settingsResults);
|
|
377
|
+
const settingsLines = formatSettingsReportCLI(settingsReport);
|
|
378
|
+
if (settingsLines.length > 0) {
|
|
379
|
+
console.log("");
|
|
380
|
+
for (const line of settingsLines) {
|
|
381
|
+
console.log(line);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
248
385
|
// Write unified summary to GITHUB_STEP_SUMMARY
|
|
249
386
|
writeUnifiedSummary({
|
|
250
387
|
lifecycle: lifecycleReport,
|
|
251
388
|
sync: report,
|
|
389
|
+
settings: settingsReport,
|
|
252
390
|
dryRun: options.dryRun ?? false,
|
|
253
391
|
});
|
|
254
|
-
// Exit with error if any failures
|
|
392
|
+
// Exit with error if any failures (file sync or settings)
|
|
255
393
|
const hasErrors = reportResults.some((r) => r.error);
|
|
256
|
-
|
|
394
|
+
const hasSettingsErrors = settingsResults.some((r) => r.error);
|
|
395
|
+
if (hasErrors || hasSettingsErrors) {
|
|
257
396
|
process.exit(1);
|
|
258
397
|
}
|
|
259
398
|
}
|
package/dist/cli/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { RepoConfig } from "../config/index.js";
|
|
2
2
|
import { RepoInfo } from "../shared/repo-detector.js";
|
|
3
|
+
import type { IRepoLifecycleManager } from "../lifecycle/index.js";
|
|
3
4
|
import { type ProcessorResult, type ProcessorOptions } from "../sync/index.js";
|
|
4
5
|
import { RulesetProcessorOptions, RulesetProcessorResult } from "../settings/rulesets/processor.js";
|
|
5
6
|
import { type IRepoSettingsProcessor } from "../settings/repo-settings/processor.js";
|
|
@@ -9,10 +10,6 @@ import { type ILabelsProcessor } from "../settings/labels/processor.js";
|
|
|
9
10
|
*/
|
|
10
11
|
export interface IRepositoryProcessor {
|
|
11
12
|
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
|
|
12
|
-
updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
|
|
13
|
-
rulesets?: string[];
|
|
14
|
-
labels?: string[];
|
|
15
|
-
}): Promise<ProcessorResult>;
|
|
16
13
|
}
|
|
17
14
|
/**
|
|
18
15
|
* Factory function type for creating processors.
|
|
@@ -52,4 +49,14 @@ export type LabelsProcessorFactory = () => ILabelsProcessor;
|
|
|
52
49
|
* Default factory that creates a real LabelsProcessor.
|
|
53
50
|
*/
|
|
54
51
|
export declare const defaultLabelsProcessorFactory: LabelsProcessorFactory;
|
|
52
|
+
/**
|
|
53
|
+
* Dependencies for the sync command (dependency injection).
|
|
54
|
+
*/
|
|
55
|
+
export interface SyncDependencies {
|
|
56
|
+
processorFactory?: ProcessorFactory;
|
|
57
|
+
lifecycleManager?: IRepoLifecycleManager;
|
|
58
|
+
rulesetProcessorFactory?: RulesetProcessorFactory;
|
|
59
|
+
repoSettingsProcessorFactory?: RepoSettingsProcessorFactory;
|
|
60
|
+
labelsProcessorFactory?: LabelsProcessorFactory;
|
|
61
|
+
}
|
|
55
62
|
export type { IRepoSettingsProcessor, ILabelsProcessor };
|
package/dist/config/index.d.ts
CHANGED
|
@@ -4,4 +4,4 @@ export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
|
|
|
4
4
|
export { convertContentToString, detectOutputFormat, type OutputFormat, type ConvertOptions, } from "./formatter.js";
|
|
5
5
|
export { isFileReference, resolveFileReference, type FileReferenceOptions, } from "./file-reference-resolver.js";
|
|
6
6
|
export { arrayMergeStrategies, deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, type ArrayMergeStrategy, type ArrayMergeHandler, type MergeContext, } from "./merge.js";
|
|
7
|
-
export { validateRawConfig, validateSettings, validateForSync,
|
|
7
|
+
export { validateRawConfig, validateSettings, validateForSync, hasActionableSettings, } from "./validator.js";
|
package/dist/config/index.js
CHANGED
|
@@ -9,4 +9,4 @@ export { isFileReference, resolveFileReference, } from "./file-reference-resolve
|
|
|
9
9
|
// Deep merge utilities
|
|
10
10
|
export { arrayMergeStrategies, deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
|
|
11
11
|
// Validation
|
|
12
|
-
export { validateRawConfig, validateSettings, validateForSync,
|
|
12
|
+
export { validateRawConfig, validateSettings, validateForSync, hasActionableSettings, } from "./validator.js";
|
|
@@ -17,8 +17,3 @@ export declare function validateForSync(config: RawConfig): void;
|
|
|
17
17
|
* Checks if settings contain actionable configuration.
|
|
18
18
|
*/
|
|
19
19
|
export declare function hasActionableSettings(settings: RawRootSettings | RawRepoSettings | undefined): boolean;
|
|
20
|
-
/**
|
|
21
|
-
* Validates that config is suitable for the settings command.
|
|
22
|
-
* @throws Error if no settings are defined or no actionable settings exist
|
|
23
|
-
*/
|
|
24
|
-
export declare function validateForSettings(config: RawConfig): void;
|
package/dist/config/validator.js
CHANGED
|
@@ -537,9 +537,19 @@ export function validateForSync(config) {
|
|
|
537
537
|
const hasGroupFiles = config.groups &&
|
|
538
538
|
Object.values(config.groups).some((g) => g.files &&
|
|
539
539
|
Object.keys(g.files).filter((k) => k !== "inherit" && g.files[k] !== false).length > 0);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
540
|
+
const hasSettings = hasActionableSettings(config.settings);
|
|
541
|
+
const hasRepoSettings = config.repos.some((repo) => hasActionableSettings(repo.settings));
|
|
542
|
+
const hasGroupSettings = config.groups &&
|
|
543
|
+
typeof config.groups === "object" &&
|
|
544
|
+
!Array.isArray(config.groups) &&
|
|
545
|
+
Object.values(config.groups).some((g) => g.settings && hasActionableSettings(g.settings));
|
|
546
|
+
if (!hasRootFiles &&
|
|
547
|
+
!hasGroupFiles &&
|
|
548
|
+
!hasSettings &&
|
|
549
|
+
!hasRepoSettings &&
|
|
550
|
+
!hasGroupSettings) {
|
|
551
|
+
throw new Error("Config requires at least one of: 'files' or 'settings'. " +
|
|
552
|
+
"Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
|
|
543
553
|
}
|
|
544
554
|
}
|
|
545
555
|
/**
|
|
@@ -564,30 +574,3 @@ export function hasActionableSettings(settings) {
|
|
|
564
574
|
}
|
|
565
575
|
return false;
|
|
566
576
|
}
|
|
567
|
-
/**
|
|
568
|
-
* Validates that config is suitable for the settings command.
|
|
569
|
-
* @throws Error if no settings are defined or no actionable settings exist
|
|
570
|
-
*/
|
|
571
|
-
export function validateForSettings(config) {
|
|
572
|
-
// Check if settings exist at root, in any repo, or in any group
|
|
573
|
-
const hasRootSettings = config.settings !== undefined;
|
|
574
|
-
const hasRepoSettings = config.repos.some((repo) => repo.settings !== undefined);
|
|
575
|
-
const hasGroupSettings = config.groups &&
|
|
576
|
-
typeof config.groups === "object" &&
|
|
577
|
-
!Array.isArray(config.groups) &&
|
|
578
|
-
Object.values(config.groups).some((g) => g.settings && typeof g.settings === "object");
|
|
579
|
-
if (!hasRootSettings && !hasRepoSettings && !hasGroupSettings) {
|
|
580
|
-
throw new Error("The 'settings' command requires a 'settings' section at root level, " +
|
|
581
|
-
"in at least one repo, or in at least one group. To sync files instead, use 'xfg sync'.");
|
|
582
|
-
}
|
|
583
|
-
// Check if there's at least one actionable setting
|
|
584
|
-
const rootActionable = hasActionableSettings(config.settings);
|
|
585
|
-
const repoActionable = config.repos.some((repo) => hasActionableSettings(repo.settings));
|
|
586
|
-
const groupActionable = config.groups &&
|
|
587
|
-
Object.values(config.groups).some((g) => hasActionableSettings(g.settings));
|
|
588
|
-
if (!rootActionable && !repoActionable && !groupActionable) {
|
|
589
|
-
throw new Error("No actionable settings configured. Currently supported: rulesets, repo, labels. " +
|
|
590
|
-
"To sync files instead, use 'xfg sync'. " +
|
|
591
|
-
"See docs: https://anthony-spruyt.github.io/xfg/settings");
|
|
592
|
-
}
|
|
593
|
-
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { runSync
|
|
2
|
-
export type { SyncOptions,
|
|
1
|
+
export { runSync } from "./cli/index.js";
|
|
2
|
+
export type { SyncOptions, SharedOptions } from "./cli/index.js";
|
|
3
3
|
export { type IRepositoryProcessor, type ProcessorFactory, defaultProcessorFactory, type IRulesetProcessor, type RulesetProcessorFactory, defaultRulesetProcessorFactory, type RepoSettingsProcessorFactory, defaultRepoSettingsProcessorFactory, type ILabelsProcessor, type LabelsProcessorFactory, defaultLabelsProcessorFactory, } from "./cli/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Public API for library consumers
|
|
2
|
-
export { runSync
|
|
2
|
+
export { runSync } from "./cli/index.js";
|
|
3
3
|
export { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./cli/index.js";
|
|
@@ -52,6 +52,15 @@ export declare class GitHubLifecycleProvider implements IRepoLifecycleProvider {
|
|
|
52
52
|
* GitHub automatically updates the default branch pointer.
|
|
53
53
|
*/
|
|
54
54
|
private renameBranch;
|
|
55
|
+
/**
|
|
56
|
+
* Poll until the GitHub API reports the expected default branch.
|
|
57
|
+
* After a branch rename, the API may lag for a few seconds.
|
|
58
|
+
*
|
|
59
|
+
* Note: Uses the same executor.exec pattern as the rest of this class.
|
|
60
|
+
* The command arguments are constructed from trusted RepoInfo values
|
|
61
|
+
* (validated during config parsing), not user input.
|
|
62
|
+
*/
|
|
63
|
+
private waitForDefaultBranch;
|
|
55
64
|
/**
|
|
56
65
|
* Delete the README.md that --add-readme creates.
|
|
57
66
|
* This leaves the repo with a default branch established (from the initial
|
|
@@ -165,6 +165,9 @@ export class GitHubLifecycleProvider {
|
|
|
165
165
|
})).trim();
|
|
166
166
|
if (actualBranch !== settings.defaultBranch) {
|
|
167
167
|
await this.renameBranch(repoInfo, actualBranch, settings.defaultBranch, token);
|
|
168
|
+
// Wait for the rename to propagate — GitHub's API may still report
|
|
169
|
+
// the old default branch for a few seconds after the rename call.
|
|
170
|
+
await this.waitForDefaultBranch(repoInfo, settings.defaultBranch, token);
|
|
168
171
|
}
|
|
169
172
|
}
|
|
170
173
|
// Delete the README so xfg sync starts from a clean state.
|
|
@@ -340,6 +343,34 @@ export class GitHubLifecycleProvider {
|
|
|
340
343
|
retries: this.retries,
|
|
341
344
|
});
|
|
342
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Poll until the GitHub API reports the expected default branch.
|
|
348
|
+
* After a branch rename, the API may lag for a few seconds.
|
|
349
|
+
*
|
|
350
|
+
* Note: Uses the same executor.exec pattern as the rest of this class.
|
|
351
|
+
* The command arguments are constructed from trusted RepoInfo values
|
|
352
|
+
* (validated during config parsing), not user input.
|
|
353
|
+
*/
|
|
354
|
+
async waitForDefaultBranch(repoInfo, expectedBranch, token, timeoutMs = 15000, pollMs = 1000) {
|
|
355
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
356
|
+
const hostnameFlag = getHostnameFlag(repoInfo);
|
|
357
|
+
const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
|
|
358
|
+
const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
|
|
359
|
+
const startTime = Date.now();
|
|
360
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
361
|
+
try {
|
|
362
|
+
const branch = (await this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath} --jq '.default_branch'`, this.cwd)).trim();
|
|
363
|
+
if (branch === expectedBranch) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// API call failed, continue polling
|
|
369
|
+
}
|
|
370
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
371
|
+
}
|
|
372
|
+
// Don't throw — rename succeeded, this is just a best-effort wait
|
|
373
|
+
}
|
|
343
374
|
/**
|
|
344
375
|
* Delete the README.md that --add-readme creates.
|
|
345
376
|
* This leaves the repo with a default branch established (from the initial
|
|
@@ -25,9 +25,9 @@ export interface LabelChange {
|
|
|
25
25
|
*
|
|
26
26
|
* @param current - Current labels from GitHub API
|
|
27
27
|
* @param desired - Desired labels from config (name -> label)
|
|
28
|
-
* @param
|
|
28
|
+
* @param deleteOrphaned - If true, delete current labels not in desired config
|
|
29
29
|
* @param noDelete - If true, skip delete operations
|
|
30
30
|
* @returns Array of changes to apply
|
|
31
31
|
* @throws Error if rename collisions are detected
|
|
32
32
|
*/
|
|
33
|
-
export declare function diffLabels(current: GitHubLabel[], desired: Record<string, Label>,
|
|
33
|
+
export declare function diffLabels(current: GitHubLabel[], desired: Record<string, Label>, deleteOrphaned: boolean, noDelete: boolean): LabelChange[];
|
|
@@ -11,19 +11,18 @@ import { normalizeColor } from "./converter.js";
|
|
|
11
11
|
*
|
|
12
12
|
* @param current - Current labels from GitHub API
|
|
13
13
|
* @param desired - Desired labels from config (name -> label)
|
|
14
|
-
* @param
|
|
14
|
+
* @param deleteOrphaned - If true, delete current labels not in desired config
|
|
15
15
|
* @param noDelete - If true, skip delete operations
|
|
16
16
|
* @returns Array of changes to apply
|
|
17
17
|
* @throws Error if rename collisions are detected
|
|
18
18
|
*/
|
|
19
|
-
export function diffLabels(current, desired,
|
|
19
|
+
export function diffLabels(current, desired, deleteOrphaned, noDelete) {
|
|
20
20
|
const changes = [];
|
|
21
21
|
// Build case-insensitive lookup of current labels
|
|
22
22
|
const currentByName = new Map();
|
|
23
23
|
for (const label of current) {
|
|
24
24
|
currentByName.set(label.name.toLowerCase(), label);
|
|
25
25
|
}
|
|
26
|
-
const managedSet = new Set(managedLabels.map((n) => n.toLowerCase()));
|
|
27
26
|
// Collect rename targets for collision detection
|
|
28
27
|
const renameTargets = new Map(); // lowercase target -> source name
|
|
29
28
|
for (const [name, label] of Object.entries(desired)) {
|
|
@@ -38,10 +37,10 @@ export function diffLabels(current, desired, managedLabels, noDelete) {
|
|
|
38
37
|
// Determine which labels will be deleted (for collision checking)
|
|
39
38
|
const desiredLower = new Set(Object.keys(desired).map((n) => n.toLowerCase()));
|
|
40
39
|
const deletedNames = new Set();
|
|
41
|
-
if (!noDelete) {
|
|
42
|
-
for (const
|
|
43
|
-
if (!desiredLower.has(
|
|
44
|
-
deletedNames.add(
|
|
40
|
+
if (deleteOrphaned && !noDelete) {
|
|
41
|
+
for (const nameLower of currentByName.keys()) {
|
|
42
|
+
if (!desiredLower.has(nameLower)) {
|
|
43
|
+
deletedNames.add(nameLower);
|
|
45
44
|
}
|
|
46
45
|
}
|
|
47
46
|
}
|
|
@@ -130,18 +129,15 @@ export function diffLabels(current, desired, managedLabels, noDelete) {
|
|
|
130
129
|
}
|
|
131
130
|
}
|
|
132
131
|
}
|
|
133
|
-
//
|
|
134
|
-
if (!noDelete) {
|
|
135
|
-
for (const
|
|
136
|
-
if (!desiredLower.has(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
current: currentLabel,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
132
|
+
// Desired-state orphan detection: delete ALL current not in desired
|
|
133
|
+
if (deleteOrphaned && !noDelete) {
|
|
134
|
+
for (const [nameLower, currentLabel] of currentByName) {
|
|
135
|
+
if (!desiredLower.has(nameLower)) {
|
|
136
|
+
changes.push({
|
|
137
|
+
action: "delete",
|
|
138
|
+
name: currentLabel.name,
|
|
139
|
+
current: currentLabel,
|
|
140
|
+
});
|
|
145
141
|
}
|
|
146
142
|
}
|
|
147
143
|
}
|
|
@@ -6,9 +6,7 @@ export interface ILabelsProcessor {
|
|
|
6
6
|
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: LabelsProcessorOptions): Promise<LabelsProcessorResult>;
|
|
7
7
|
}
|
|
8
8
|
export interface LabelsProcessorOptions {
|
|
9
|
-
configId: string;
|
|
10
9
|
dryRun?: boolean;
|
|
11
|
-
managedLabels: string[];
|
|
12
10
|
noDelete?: boolean;
|
|
13
11
|
token?: string;
|
|
14
12
|
}
|
|
@@ -24,9 +22,6 @@ export interface LabelsProcessorResult {
|
|
|
24
22
|
delete: number;
|
|
25
23
|
unchanged: number;
|
|
26
24
|
};
|
|
27
|
-
manifestUpdate?: {
|
|
28
|
-
labels: string[];
|
|
29
|
-
};
|
|
30
25
|
planOutput?: LabelsPlanResult;
|
|
31
26
|
}
|
|
32
27
|
/**
|
|
@@ -45,11 +40,6 @@ export declare class LabelsProcessor implements ILabelsProcessor {
|
|
|
45
40
|
* Format change counts into a summary string.
|
|
46
41
|
*/
|
|
47
42
|
private formatChangeSummary;
|
|
48
|
-
/**
|
|
49
|
-
* Compute manifest update based on current config.
|
|
50
|
-
* Only labels with deleteOrphaned enabled should be tracked.
|
|
51
|
-
*/
|
|
52
|
-
private computeManifestUpdate;
|
|
53
43
|
/**
|
|
54
44
|
* Resolves a GitHub App installation token for the given repo.
|
|
55
45
|
*/
|
|
@@ -29,7 +29,7 @@ export class LabelsProcessor {
|
|
|
29
29
|
*/
|
|
30
30
|
async process(repoConfig, repoInfo, options) {
|
|
31
31
|
const repoName = getRepoDisplayName(repoInfo);
|
|
32
|
-
const { dryRun,
|
|
32
|
+
const { dryRun, noDelete, token } = options;
|
|
33
33
|
// Check if this is a GitHub repo
|
|
34
34
|
if (!isGitHubRepo(repoInfo)) {
|
|
35
35
|
return {
|
|
@@ -44,7 +44,7 @@ export class LabelsProcessor {
|
|
|
44
44
|
const desiredLabels = settings?.labels ?? {};
|
|
45
45
|
const deleteOrphaned = settings?.deleteOrphaned ?? false;
|
|
46
46
|
// If no labels configured, skip
|
|
47
|
-
if (Object.keys(desiredLabels).length === 0
|
|
47
|
+
if (Object.keys(desiredLabels).length === 0) {
|
|
48
48
|
return {
|
|
49
49
|
success: true,
|
|
50
50
|
repoName,
|
|
@@ -58,7 +58,7 @@ export class LabelsProcessor {
|
|
|
58
58
|
const strategyOptions = { token: effectiveToken, host: githubRepo.host };
|
|
59
59
|
const currentLabels = await this.strategy.list(githubRepo, strategyOptions);
|
|
60
60
|
// Compute diff
|
|
61
|
-
const changes = diffLabels(currentLabels, desiredLabels,
|
|
61
|
+
const changes = diffLabels(currentLabels, desiredLabels, deleteOrphaned, noDelete ?? false);
|
|
62
62
|
// Count changes by type
|
|
63
63
|
const changeCounts = {
|
|
64
64
|
create: changes.filter((c) => c.action === "create").length,
|
|
@@ -77,7 +77,6 @@ export class LabelsProcessor {
|
|
|
77
77
|
dryRun: true,
|
|
78
78
|
changes: changeCounts,
|
|
79
79
|
planOutput,
|
|
80
|
-
manifestUpdate: this.computeManifestUpdate(desiredLabels, deleteOrphaned),
|
|
81
80
|
};
|
|
82
81
|
}
|
|
83
82
|
// Apply changes (diff is already sorted: delete, update, create, unchanged)
|
|
@@ -133,7 +132,6 @@ export class LabelsProcessor {
|
|
|
133
132
|
message: appliedCount > 0 ? `Applied: ${summary}` : "No changes needed",
|
|
134
133
|
changes: changeCounts,
|
|
135
134
|
planOutput,
|
|
136
|
-
manifestUpdate: this.computeManifestUpdate(desiredLabels, deleteOrphaned),
|
|
137
135
|
};
|
|
138
136
|
}
|
|
139
137
|
catch (error) {
|
|
@@ -160,17 +158,6 @@ export class LabelsProcessor {
|
|
|
160
158
|
parts.push(`${counts.unchanged} unchanged`);
|
|
161
159
|
return parts.length > 0 ? parts.join(", ") : "no changes";
|
|
162
160
|
}
|
|
163
|
-
/**
|
|
164
|
-
* Compute manifest update based on current config.
|
|
165
|
-
* Only labels with deleteOrphaned enabled should be tracked.
|
|
166
|
-
*/
|
|
167
|
-
computeManifestUpdate(labels, deleteOrphaned) {
|
|
168
|
-
if (!deleteOrphaned) {
|
|
169
|
-
return undefined;
|
|
170
|
-
}
|
|
171
|
-
const labelNames = Object.keys(labels).sort();
|
|
172
|
-
return { labels: labelNames };
|
|
173
|
-
}
|
|
174
161
|
/**
|
|
175
162
|
* Resolves a GitHub App installation token for the given repo.
|
|
176
163
|
*/
|
|
@@ -25,10 +25,10 @@ export declare function projectToDesiredShape(current: unknown, desired: unknown
|
|
|
25
25
|
*
|
|
26
26
|
* @param current - Current rulesets from GitHub API
|
|
27
27
|
* @param desired - Desired rulesets from config (name → ruleset)
|
|
28
|
-
* @param
|
|
28
|
+
* @param deleteOrphaned - When true, delete ALL current rulesets not in desired (desired-state model)
|
|
29
29
|
* @returns Array of changes to apply
|
|
30
30
|
*/
|
|
31
|
-
export declare function diffRulesets(current: GitHubRuleset[], desired: Map<string, Ruleset>,
|
|
31
|
+
export declare function diffRulesets(current: GitHubRuleset[], desired: Map<string, Ruleset>, deleteOrphaned: boolean): RulesetChange[];
|
|
32
32
|
/**
|
|
33
33
|
* Formats diff output for display (dry-run mode).
|
|
34
34
|
*
|