@aspruyt/xfg 4.0.4 → 5.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.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/program.d.ts +3 -0
  3. package/dist/cli/program.js +18 -13
  4. package/dist/cli/sync-command.js +62 -39
  5. package/dist/cli/sync-report-builder.js +7 -4
  6. package/dist/config/formatter.js +14 -9
  7. package/dist/config/merge.d.ts +2 -4
  8. package/dist/config/merge.js +15 -67
  9. package/dist/config/validator.js +2 -9
  10. package/dist/lifecycle/repo-lifecycle-factory.js +0 -4
  11. package/dist/output/github-summary.d.ts +3 -2
  12. package/dist/output/github-summary.js +1 -7
  13. package/dist/output/lifecycle-report.js +7 -14
  14. package/dist/output/sync-report.d.ts +2 -19
  15. package/dist/output/sync-report.js +16 -28
  16. package/dist/output/types.d.ts +19 -0
  17. package/dist/output/types.js +1 -0
  18. package/dist/output/unified-summary.d.ts +2 -1
  19. package/dist/output/unified-summary.js +4 -1
  20. package/dist/settings/base-processor.d.ts +3 -1
  21. package/dist/settings/base-processor.js +9 -5
  22. package/dist/settings/index.d.ts +1 -1
  23. package/dist/settings/labels/diff.d.ts +2 -1
  24. package/dist/settings/labels/formatter.js +2 -4
  25. package/dist/settings/labels/github-labels-strategy.js +2 -1
  26. package/dist/settings/labels/processor.js +0 -1
  27. package/dist/settings/repo-settings/github-repo-settings-strategy.js +2 -1
  28. package/dist/settings/rulesets/diff-algorithm.js +0 -1
  29. package/dist/settings/rulesets/diff.d.ts +2 -1
  30. package/dist/settings/rulesets/diff.js +37 -21
  31. package/dist/settings/rulesets/formatter.js +44 -38
  32. package/dist/settings/rulesets/github-ruleset-strategy.js +2 -1
  33. package/dist/settings/rulesets/processor.js +0 -1
  34. package/dist/shared/gh-api-utils.d.ts +8 -7
  35. package/dist/shared/gh-api-utils.js +2 -16
  36. package/dist/shared/interpolation-engine.d.ts +3 -0
  37. package/dist/shared/interpolation-engine.js +0 -3
  38. package/dist/shared/json-utils.d.ts +6 -0
  39. package/dist/shared/json-utils.js +16 -0
  40. package/dist/shared/repo-detector.js +0 -4
  41. package/dist/shared/xfg-template.d.ts +3 -0
  42. package/dist/shared/xfg-template.js +0 -20
  43. package/dist/sync/auth-options-builder.js +7 -1
  44. package/dist/sync/branch-manager.d.ts +1 -1
  45. package/dist/sync/commit-message.d.ts +1 -1
  46. package/dist/sync/commit-push-manager.d.ts +1 -1
  47. package/dist/sync/commit-push-manager.js +2 -2
  48. package/dist/sync/diff-utils.d.ts +15 -2
  49. package/dist/sync/diff-utils.js +50 -14
  50. package/dist/sync/file-sync-orchestrator.js +2 -4
  51. package/dist/sync/file-sync-strategy.js +11 -4
  52. package/dist/sync/file-writer.js +9 -4
  53. package/dist/sync/index.d.ts +2 -1
  54. package/dist/sync/index.js +1 -0
  55. package/dist/sync/manifest-manager.d.ts +1 -1
  56. package/dist/sync/manifest-manager.js +20 -6
  57. package/dist/sync/pr-merge-handler.js +6 -1
  58. package/dist/sync/repository-processor.js +8 -1
  59. package/dist/sync/types.d.ts +5 -4
  60. package/dist/vcs/authenticated-git-ops.d.ts +9 -1
  61. package/dist/vcs/authenticated-git-ops.js +7 -14
  62. package/dist/vcs/git-ops.js +29 -12
  63. package/dist/vcs/github-pr-strategy.js +6 -1
  64. package/dist/vcs/gitlab-pr-strategy.js +7 -2
  65. package/dist/vcs/graphql-commit-strategy.js +2 -1
  66. package/dist/vcs/index.d.ts +1 -0
  67. package/dist/vcs/index.js +2 -0
  68. package/dist/vcs/pr-creator.d.ts +5 -1
  69. package/dist/vcs/pr-creator.js +4 -4
  70. package/package.json +1 -1
  71. package/dist/output/index.d.ts +0 -5
  72. package/dist/output/index.js +0 -10
  73. package/dist/shared/index.d.ts +0 -15
  74. package/dist/shared/index.js +0 -30
@@ -1,22 +1,5 @@
1
- import type { FileChangeDetail } from "../sync/types.js";
2
- export type ReportFileChange = FileChangeDetail;
3
- export interface SyncReport {
4
- repos: RepoFileChanges[];
5
- totals: {
6
- files: {
7
- create: number;
8
- update: number;
9
- delete: number;
10
- };
11
- };
12
- }
13
- export interface RepoFileChanges {
14
- repoName: string;
15
- files: ReportFileChange[];
16
- prUrl?: string;
17
- mergeOutcome?: "manual" | "auto" | "force" | "direct";
18
- error?: string;
19
- }
1
+ import type { SyncReport, RepoFileChanges, ReportFileChange } from "./types.js";
2
+ export type { SyncReport, RepoFileChanges, ReportFileChange };
20
3
  export declare function formatSyncReportCLI(report: SyncReport): string[];
21
4
  export declare function formatSyncReportMarkdown(report: SyncReport, dryRun: boolean): string;
22
5
  export declare function writeSyncReportSummary(report: SyncReport, dryRun: boolean, summaryPath: string | undefined): void;
@@ -1,20 +1,15 @@
1
- // src/output/sync-report.ts
2
1
  import chalk from "chalk";
3
2
  import { writeGitHubStepSummary } from "./github-summary.js";
3
+ import { formatCountEntry } from "./settings-report.js";
4
+ import { renderSyncLines } from "./unified-summary.js";
5
+ import { formatDiffLine } from "../sync/index.js";
4
6
  function formatSyncSummary(totals) {
5
- const total = totals.files.create + totals.files.update + totals.files.delete;
6
- if (total === 0) {
7
- return "No changes";
8
- }
9
- const parts = [];
10
- if (totals.files.create > 0)
11
- parts.push(`${totals.files.create} to create`);
12
- if (totals.files.update > 0)
13
- parts.push(`${totals.files.update} to update`);
14
- if (totals.files.delete > 0)
15
- parts.push(`${totals.files.delete} to delete`);
16
- const fileWord = total === 1 ? "file" : "files";
17
- return `Plan: ${total} ${fileWord} (${parts.join(", ")})`;
7
+ const entry = formatCountEntry("file", "files", [
8
+ { label: "to create", value: totals.files.create },
9
+ { label: "to update", value: totals.files.update },
10
+ { label: "to delete", value: totals.files.delete },
11
+ ]);
12
+ return entry ? `Plan: ${entry}` : "No changes";
18
13
  }
19
14
  export function formatSyncReportCLI(report) {
20
15
  const lines = [];
@@ -35,6 +30,12 @@ export function formatSyncReportCLI(report) {
35
30
  else if (file.action === "delete") {
36
31
  lines.push(chalk.red(` - ${file.path}`));
37
32
  }
33
+ // Content diff for structured data files
34
+ if (file.diffLines) {
35
+ for (const diffLine of file.diffLines) {
36
+ lines.push(` ${formatDiffLine(diffLine)}`);
37
+ }
38
+ }
38
39
  }
39
40
  // Error
40
41
  if (repo.error) {
@@ -65,20 +66,7 @@ export function formatSyncReportMarkdown(report, dryRun) {
65
66
  continue;
66
67
  }
67
68
  diffLines.push(`@@ ${repo.repoName} @@`);
68
- for (const file of repo.files) {
69
- if (file.action === "create") {
70
- diffLines.push(`+ ${file.path}`);
71
- }
72
- else if (file.action === "update") {
73
- diffLines.push(`! ${file.path}`);
74
- }
75
- else if (file.action === "delete") {
76
- diffLines.push(`- ${file.path}`);
77
- }
78
- }
79
- if (repo.error) {
80
- diffLines.push(`- Error: ${repo.error}`);
81
- }
69
+ renderSyncLines(repo, diffLines);
82
70
  }
83
71
  if (diffLines.length > 0) {
84
72
  lines.push("```diff");
@@ -0,0 +1,19 @@
1
+ import type { FileChangeDetail } from "../sync/index.js";
2
+ export type ReportFileChange = FileChangeDetail;
3
+ export interface SyncReport {
4
+ repos: RepoFileChanges[];
5
+ totals: {
6
+ files: {
7
+ create: number;
8
+ update: number;
9
+ delete: number;
10
+ };
11
+ };
12
+ }
13
+ export interface RepoFileChanges {
14
+ repoName: string;
15
+ files: ReportFileChange[];
16
+ prUrl?: string;
17
+ mergeOutcome?: "manual" | "auto" | "force" | "direct";
18
+ error?: string;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
1
  import type { LifecycleReport } from "./lifecycle-report.js";
2
- import type { SyncReport } from "./sync-report.js";
2
+ import type { SyncReport, RepoFileChanges } from "./types.js";
3
3
  import type { SettingsReport } from "./settings-report.js";
4
4
  interface UnifiedSummaryInput {
5
5
  lifecycle?: LifecycleReport;
@@ -8,6 +8,7 @@ interface UnifiedSummaryInput {
8
8
  dryRun: boolean;
9
9
  summaryPath?: string | undefined;
10
10
  }
11
+ export declare function renderSyncLines(syncRepo: RepoFileChanges, diffLines: string[]): void;
11
12
  export declare function formatUnifiedSummaryMarkdown(input: UnifiedSummaryInput): string;
12
13
  export declare function writeUnifiedSummary(input: UnifiedSummaryInput): void;
13
14
  export {};
@@ -130,7 +130,7 @@ function renderLifecycleLines(lcAction, diffLines) {
130
130
  }
131
131
  }
132
132
  }
133
- function renderSyncLines(syncRepo, diffLines) {
133
+ export function renderSyncLines(syncRepo, diffLines) {
134
134
  for (const file of syncRepo.files) {
135
135
  if (file.action === "create") {
136
136
  diffLines.push(`+ ${file.path}`);
@@ -141,6 +141,9 @@ function renderSyncLines(syncRepo, diffLines) {
141
141
  else if (file.action === "delete") {
142
142
  diffLines.push(`- ${file.path}`);
143
143
  }
144
+ if (file.diffLines) {
145
+ diffLines.push(...file.diffLines);
146
+ }
144
147
  }
145
148
  if (syncRepo.error) {
146
149
  diffLines.push(`- Error: ${syncRepo.error}`);
@@ -33,6 +33,8 @@ interface SettingsGuards<TOptions extends BaseProcessorOptions, TResult extends
33
33
  * empty settings check, token resolution, and error wrapping.
34
34
  */
35
35
  export declare function withGitHubGuards<TOptions extends BaseProcessorOptions, TResult extends BaseProcessorResult>(repoConfig: RepoConfig, repoInfo: RepoInfo, options: TOptions, guards: SettingsGuards<TOptions, TResult>): Promise<TResult>;
36
+ /** Common action literals shared by all settings processors. */
37
+ export type SettingsAction = "create" | "update" | "delete" | "unchanged";
36
38
  export interface ChangeCounts {
37
39
  create: number;
38
40
  update: number;
@@ -44,7 +46,7 @@ export interface ChangeCounts {
44
46
  * Works with any change type that has an `action` field.
45
47
  */
46
48
  export declare function countActions(changes: ReadonlyArray<{
47
- action: string;
49
+ action: SettingsAction;
48
50
  }>): ChangeCounts;
49
51
  export declare function formatChangeSummary(counts: ChangeCounts): string;
50
52
  /**
@@ -39,12 +39,16 @@ export async function withGitHubGuards(repoConfig, repoInfo, options, guards) {
39
39
  * Works with any change type that has an `action` field.
40
40
  */
41
41
  export function countActions(changes) {
42
- return {
43
- create: changes.filter((c) => c.action === "create").length,
44
- update: changes.filter((c) => c.action === "update").length,
45
- delete: changes.filter((c) => c.action === "delete").length,
46
- unchanged: changes.filter((c) => c.action === "unchanged").length,
42
+ const counts = {
43
+ create: 0,
44
+ update: 0,
45
+ delete: 0,
46
+ unchanged: 0,
47
47
  };
48
+ for (const c of changes) {
49
+ counts[c.action]++;
50
+ }
51
+ return counts;
48
52
  }
49
53
  export function formatChangeSummary(counts) {
50
54
  const parts = [];
@@ -1,4 +1,4 @@
1
- export { type ISettingsProcessor } from "./base-processor.js";
1
+ export { type ISettingsProcessor, type SettingsAction, } from "./base-processor.js";
2
2
  export { type PropertyDiff, formatPropertyTree, type RulesetPlanEntry, RulesetProcessor, type IRulesetProcessor, GitHubRulesetStrategy, } from "./rulesets/index.js";
3
3
  export { RepoSettingsProcessor, type IRepoSettingsProcessor, type RepoSettingsPlanEntry, GitHubRepoSettingsStrategy, } from "./repo-settings/index.js";
4
4
  export { type LabelsPlanEntry, LabelsProcessor, type ILabelsProcessor, GitHubLabelsStrategy, } from "./labels/index.js";
@@ -1,6 +1,7 @@
1
1
  import type { Label } from "../../config/types.js";
2
2
  import type { GitHubLabel } from "./types.js";
3
- export type LabelAction = "create" | "update" | "delete" | "unchanged";
3
+ import type { SettingsAction } from "../base-processor.js";
4
+ export type LabelAction = SettingsAction;
4
5
  export interface LabelChange {
5
6
  action: LabelAction;
6
7
  name: string;
@@ -1,18 +1,16 @@
1
1
  import chalk from "chalk";
2
+ import { countActions } from "../base-processor.js";
2
3
  /**
3
4
  * Format label changes as a Terraform-style plan.
4
5
  */
5
6
  export function formatLabelsPlan(changes) {
6
7
  const lines = [];
7
8
  const entries = [];
9
+ const { create: creates, update: updates, delete: deletes, unchanged, } = countActions(changes);
8
10
  const createChanges = changes.filter((c) => c.action === "create");
9
11
  const updateChanges = changes.filter((c) => c.action === "update");
10
12
  const deleteChanges = changes.filter((c) => c.action === "delete");
11
13
  const unchangedItems = changes.filter((c) => c.action === "unchanged");
12
- const creates = createChanges.length;
13
- const updates = updateChanges.length;
14
- const deletes = deleteChanges.length;
15
- const unchanged = unchangedItems.length;
16
14
  // Format creates
17
15
  if (createChanges.length > 0) {
18
16
  lines.push(chalk.bold(" Create:"));
@@ -1,5 +1,6 @@
1
1
  import { assertGitHubRepo } from "../../shared/repo-detector.js";
2
- import { GhApiClient, parseApiJson, } from "../../shared/gh-api-utils.js";
2
+ import { GhApiClient } from "../../shared/gh-api-utils.js";
3
+ import { parseApiJson } from "../../shared/json-utils.js";
3
4
  export class GitHubLabelsStrategy {
4
5
  api;
5
6
  constructor(executor, options) {
@@ -74,7 +74,6 @@ export class LabelsProcessor {
74
74
  }
75
75
  break;
76
76
  case "unchanged":
77
- // No action needed
78
77
  break;
79
78
  }
80
79
  }
@@ -1,5 +1,6 @@
1
1
  import { assertGitHubRepo, } from "../../shared/repo-detector.js";
2
- import { GhApiClient, parseApiJson, isHttp404Error, } from "../../shared/gh-api-utils.js";
2
+ import { GhApiClient, isHttp404Error, } from "../../shared/gh-api-utils.js";
3
+ import { parseApiJson } from "../../shared/json-utils.js";
3
4
  import { camelToSnake } from "../../shared/string-utils.js";
4
5
  /**
5
6
  * Converts GitHubRepoSettings (camelCase) to GitHub API format (snake_case).
@@ -1,4 +1,3 @@
1
- // src/settings/rulesets/diff-algorithm.ts
2
1
  import { isPlainObject } from "../../shared/type-guards.js";
3
2
  export function deepEqual(a, b) {
4
3
  if (a === b)
@@ -1,6 +1,7 @@
1
1
  import { type Ruleset } from "../../config/index.js";
2
+ import { type SettingsAction } from "../base-processor.js";
2
3
  import type { GitHubRuleset } from "./types.js";
3
- export type RulesetAction = "create" | "update" | "delete" | "unchanged";
4
+ export type RulesetAction = SettingsAction;
4
5
  export interface RulesetChange {
5
6
  action: RulesetAction;
6
7
  name: string;
@@ -1,6 +1,7 @@
1
1
  import { RULESET_COMPARABLE_FIELDS } from "../../config/index.js";
2
2
  import { isPlainObject } from "../../shared/type-guards.js";
3
3
  import { camelToSnake } from "../../shared/string-utils.js";
4
+ import { countActions } from "../base-processor.js";
4
5
  /**
5
6
  * Normalizes a value recursively, converting keys to a consistent format (snake_case).
6
7
  * This allows comparing GitHub API responses (snake_case) with config (camelCase).
@@ -128,34 +129,54 @@ function projectObjects(current, desired) {
128
129
  }
129
130
  return result;
130
131
  }
132
+ /**
133
+ * Candidate keys for matching array items by identity rather than index.
134
+ * Order matters — first key found across all items wins.
135
+ */
136
+ const MATCH_KEY_CANDIDATES = ["type", "actor_id"];
137
+ /**
138
+ * Finds a key that uniquely identifies items in both arrays.
139
+ * Returns the first candidate key present in every item of both arrays, or undefined.
140
+ */
141
+ function findMatchKey(current, desired) {
142
+ const allItems = [...current, ...desired];
143
+ if (allItems.length === 0)
144
+ return undefined;
145
+ for (const candidate of MATCH_KEY_CANDIDATES) {
146
+ const everyItemHasKey = allItems.every((item) => isPlainObject(item) && candidate in item);
147
+ if (everyItemHasKey)
148
+ return candidate;
149
+ }
150
+ return undefined;
151
+ }
131
152
  function projectArrays(current, desired) {
132
153
  // Primitive arrays — return current as-is
133
154
  if (desired.length === 0 || !isPlainObject(desired[0])) {
134
155
  return current;
135
156
  }
136
- // Arrays of objects — match by `type` field if available
137
- const hasType = desired.every((item) => isPlainObject(item) && "type" in item);
138
- if (hasType) {
139
- return matchByType(current, desired);
157
+ // Arrays of objects — match by identifying key if available
158
+ const matchKey = findMatchKey(current, desired);
159
+ if (matchKey) {
160
+ return matchByKey(current, desired, matchKey);
140
161
  }
141
162
  // Fallback: match by index
142
163
  return matchByIndex(current, desired);
143
164
  }
144
- function matchByType(current, desired) {
145
- const currentByType = new Map();
165
+ function matchByKey(current, desired, key) {
166
+ const currentByKey = new Map();
146
167
  for (const item of current) {
147
168
  if (isPlainObject(item)) {
148
- const type = item.type;
149
- if (type)
150
- currentByType.set(type, item);
169
+ const keyValue = item[key];
170
+ if (keyValue !== undefined)
171
+ currentByKey.set(keyValue, item);
151
172
  }
152
173
  }
153
- const desiredTypes = new Set();
174
+ const desiredKeys = new Set();
154
175
  const result = [];
155
176
  for (const desiredItem of desired) {
156
- const type = desiredItem.type;
157
- desiredTypes.add(type);
158
- const currentItem = currentByType.get(type);
177
+ const keyValue = desiredItem[key];
178
+ desiredKeys.add(keyValue);
179
+ const currentItem = currentByKey.get(keyValue);
159
180
  if (currentItem) {
160
181
  result.push(projectToDesiredShape(currentItem, desiredItem));
161
182
  }
@@ -165,8 +186,8 @@ function matchByType(current, desired) {
165
186
  // deepEqual must detect (length mismatch). Fixes #549.
166
187
  for (const item of current) {
167
188
  if (isPlainObject(item)) {
168
- const type = item.type;
169
- if (type && !desiredTypes.has(type)) {
189
+ const keyValue = item[key];
190
+ if (keyValue !== undefined && !desiredKeys.has(keyValue)) {
170
191
  result.push(item);
171
192
  }
172
193
  }
@@ -286,12 +307,7 @@ export function formatDiff(changes) {
286
307
  lines.push(formatChange(change));
287
308
  }
288
309
  // Summary
289
- const counts = {
290
- create: changes.filter((c) => c.action === "create").length,
291
- update: changes.filter((c) => c.action === "update").length,
292
- delete: changes.filter((c) => c.action === "delete").length,
293
- unchanged: changes.filter((c) => c.action === "unchanged").length,
294
- };
310
+ const counts = countActions(changes);
295
311
  lines.push("");
296
312
  lines.push("Summary:");
297
313
  const parts = [];
@@ -118,6 +118,49 @@ function getActionStyle(action) {
118
118
  return { symbol: "~", color: chalk.yellow };
119
119
  }
120
120
  }
121
+ /**
122
+ * Render a leaf tree node (no children) with its value.
123
+ */
124
+ function renderLeafNode(child, style, indentStr, indent) {
125
+ const lines = [];
126
+ const hasComplexNew = isPlainObject(child.newValue) ||
127
+ (Array.isArray(child.newValue) &&
128
+ child.newValue.some((v) => isPlainObject(v)));
129
+ const hasComplexOld = isPlainObject(child.oldValue) ||
130
+ (Array.isArray(child.oldValue) &&
131
+ child.oldValue.some((v) => isPlainObject(v)));
132
+ if (child.action === "add" && hasComplexNew) {
133
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
134
+ lines.push(...renderNestedValue(child.newValue, child.action, indent + 1));
135
+ }
136
+ else if (child.action === "remove" && hasComplexOld) {
137
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name} (removed):`));
138
+ lines.push(...renderNestedValue(child.oldValue, child.action, indent + 1));
139
+ }
140
+ else if (child.action === "change" && (hasComplexNew || hasComplexOld)) {
141
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
142
+ if (hasComplexOld) {
143
+ lines.push(...renderNestedValue(child.oldValue, "remove", indent + 1));
144
+ }
145
+ if (hasComplexNew) {
146
+ lines.push(...renderNestedValue(child.newValue, "add", indent + 1));
147
+ }
148
+ }
149
+ else {
150
+ let valuePart = "";
151
+ if (child.action === "change") {
152
+ valuePart = `: ${formatValue(child.oldValue)} → ${formatValue(child.newValue)}`;
153
+ }
154
+ else if (child.action === "add") {
155
+ valuePart = `: ${formatValue(child.newValue)}`;
156
+ }
157
+ else if (child.action === "remove") {
158
+ valuePart = ` (was: ${formatValue(child.oldValue)})`;
159
+ }
160
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name}${valuePart}`));
161
+ }
162
+ return lines;
163
+ }
121
164
  /**
122
165
  * Recursively render tree nodes to formatted lines.
123
166
  */
@@ -135,44 +178,7 @@ function renderTree(node, indent = 0) {
135
178
  lines.push(...renderTree(child, indent + 1));
136
179
  }
137
180
  else {
138
- // Leaf node with value
139
- const hasComplexNew = isPlainObject(child.newValue) ||
140
- (Array.isArray(child.newValue) &&
141
- child.newValue.some((v) => isPlainObject(v)));
142
- const hasComplexOld = isPlainObject(child.oldValue) ||
143
- (Array.isArray(child.oldValue) &&
144
- child.oldValue.some((v) => isPlainObject(v)));
145
- if (child.action === "add" && hasComplexNew) {
146
- lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
147
- lines.push(...renderNestedValue(child.newValue, child.action, indent + 1));
148
- }
149
- else if (child.action === "remove" && hasComplexOld) {
150
- lines.push(style.color(`${indentStr}${style.symbol} ${child.name} (removed):`));
151
- lines.push(...renderNestedValue(child.oldValue, child.action, indent + 1));
152
- }
153
- else if (child.action === "change" &&
154
- (hasComplexNew || hasComplexOld)) {
155
- lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
156
- if (hasComplexOld) {
157
- lines.push(...renderNestedValue(child.oldValue, "remove", indent + 1));
158
- }
159
- if (hasComplexNew) {
160
- lines.push(...renderNestedValue(child.newValue, "add", indent + 1));
161
- }
162
- }
163
- else {
164
- let valuePart = "";
165
- if (child.action === "change") {
166
- valuePart = `: ${formatValue(child.oldValue)} → ${formatValue(child.newValue)}`;
167
- }
168
- else if (child.action === "add") {
169
- valuePart = `: ${formatValue(child.newValue)}`;
170
- }
171
- else if (child.action === "remove") {
172
- valuePart = ` (was: ${formatValue(child.oldValue)})`;
173
- }
174
- lines.push(style.color(`${indentStr}${style.symbol} ${child.name}${valuePart}`));
175
- }
181
+ lines.push(...renderLeafNode(child, style, indentStr, indent));
176
182
  }
177
183
  }
178
184
  return lines;
@@ -1,6 +1,7 @@
1
1
  import { assertGitHubRepo } from "../../shared/repo-detector.js";
2
2
  import { camelToSnake } from "../../shared/string-utils.js";
3
- import { GhApiClient, parseApiJson, } from "../../shared/gh-api-utils.js";
3
+ import { GhApiClient } from "../../shared/gh-api-utils.js";
4
+ import { parseApiJson } from "../../shared/json-utils.js";
4
5
  /**
5
6
  * Converts camelCase config ruleset to snake_case GitHub API format.
6
7
  */
@@ -68,7 +68,6 @@ export class RulesetProcessor {
68
68
  }
69
69
  break;
70
70
  case "unchanged":
71
- // No action needed
72
71
  break;
73
72
  }
74
73
  }
@@ -31,12 +31,19 @@ export declare class GhApiClient {
31
31
  constructor(executor: ICommandExecutor, retries: number, cwd: string);
32
32
  call(method: HttpMethod, endpoint: string, params?: GhApiCallParams): Promise<string>;
33
33
  }
34
+ export interface ResolveGitHubTokenOptions {
35
+ repoInfo: GitHubRepoInfo;
36
+ tokenManager: ITokenManager | null;
37
+ context: string;
38
+ log?: DebugLog;
39
+ envToken?: string;
40
+ }
34
41
  /**
35
42
  * Resolve a GitHub token for a repo: GitHub App token → envToken fallback.
36
43
  * Returns { token, skipped } where skipped=true means no App installation found
37
44
  * for this owner (token will be undefined). Both sync and settings paths use this.
38
45
  */
39
- export declare function resolveGitHubToken(repoInfo: GitHubRepoInfo, tokenManager: ITokenManager | null, context: string, log?: DebugLog, envToken?: string): Promise<{
46
+ export declare function resolveGitHubToken(options: ResolveGitHubTokenOptions): Promise<{
40
47
  token: string | undefined;
41
48
  skipped: boolean;
42
49
  }>;
@@ -44,10 +51,4 @@ export declare function resolveGitHubToken(repoInfo: GitHubRepoInfo, tokenManage
44
51
  * Check if an error message indicates an HTTP 404 response from the GitHub API.
45
52
  */
46
53
  export declare function isHttp404Error(error: unknown): boolean;
47
- /**
48
- * Parse a JSON API response with a contextual error message.
49
- * Wraps JSON.parse so callers get "Failed to parse <context>: ..." instead of
50
- * a bare "Unexpected token" SyntaxError.
51
- */
52
- export declare function parseApiJson<T>(response: string, context: string): T;
53
54
  export {};
@@ -1,7 +1,6 @@
1
1
  import { escapeShellArg } from "./shell-utils.js";
2
2
  import { withRetry } from "./retry-utils.js";
3
3
  import { toErrorMessage } from "./type-guards.js";
4
- import { SyncError } from "./errors.js";
5
4
  /**
6
5
  * Get the hostname flag for gh commands.
7
6
  * Returns "--hostname HOST" for GHE, empty string for github.com.
@@ -78,7 +77,8 @@ export class GhApiClient {
78
77
  * Returns { token, skipped } where skipped=true means no App installation found
79
78
  * for this owner (token will be undefined). Both sync and settings paths use this.
80
79
  */
81
- export async function resolveGitHubToken(repoInfo, tokenManager, context, log, envToken) {
80
+ export async function resolveGitHubToken(options) {
81
+ const { repoInfo, tokenManager, context, log, envToken } = options;
82
82
  try {
83
83
  const appToken = await tokenManager?.getTokenForRepo(repoInfo);
84
84
  if (appToken === null) {
@@ -102,17 +102,3 @@ export async function resolveGitHubToken(repoInfo, tokenManager, context, log, e
102
102
  export function isHttp404Error(error) {
103
103
  return toErrorMessage(error).includes("HTTP 404");
104
104
  }
105
- /**
106
- * Parse a JSON API response with a contextual error message.
107
- * Wraps JSON.parse so callers get "Failed to parse <context>: ..." instead of
108
- * a bare "Unexpected token" SyntaxError.
109
- */
110
- export function parseApiJson(response, context) {
111
- try {
112
- return JSON.parse(response);
113
- }
114
- catch (error) {
115
- const preview = response.slice(0, 200);
116
- throw new SyncError(`Failed to parse ${context}: ${toErrorMessage(error)} — ${preview}`);
117
- }
118
- }
@@ -28,4 +28,7 @@ export declare function interpolateString(value: string, config: InterpolationCo
28
28
  /**
29
29
  * Recursively process a value, interpolating strings within objects and arrays.
30
30
  */
31
+ export declare function interpolateValue(value: string, config: InterpolationConfig): string;
32
+ export declare function interpolateValue(value: unknown[], config: InterpolationConfig): unknown[];
33
+ export declare function interpolateValue(value: Record<string, unknown>, config: InterpolationConfig): Record<string, unknown>;
31
34
  export declare function interpolateValue(value: unknown, config: InterpolationConfig): unknown;
@@ -28,9 +28,6 @@ export function interpolateString(value, config) {
28
28
  });
29
29
  return processed;
30
30
  }
31
- /**
32
- * Recursively process a value, interpolating strings within objects and arrays.
33
- */
34
31
  export function interpolateValue(value, config) {
35
32
  if (typeof value === "string") {
36
33
  return interpolateString(value, config);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Parse a JSON API response with a contextual error message.
3
+ * Wraps JSON.parse so callers get "Failed to parse <context>: ..." instead of
4
+ * a bare "Unexpected token" SyntaxError.
5
+ */
6
+ export declare function parseApiJson<T>(response: string, context: string): T;
@@ -0,0 +1,16 @@
1
+ import { toErrorMessage } from "./type-guards.js";
2
+ import { SyncError } from "./errors.js";
3
+ /**
4
+ * Parse a JSON API response with a contextual error message.
5
+ * Wraps JSON.parse so callers get "Failed to parse <context>: ..." instead of
6
+ * a bare "Unexpected token" SyntaxError.
7
+ */
8
+ export function parseApiJson(response, context) {
9
+ try {
10
+ return JSON.parse(response);
11
+ }
12
+ catch (error) {
13
+ const preview = response.slice(0, 200);
14
+ throw new SyncError(`Failed to parse ${context}: ${toErrorMessage(error)} — ${preview}`);
15
+ }
16
+ }
@@ -83,7 +83,6 @@ function isGitLabStyleUrl(gitUrl) {
83
83
  return false;
84
84
  }
85
85
  export function detectRepoType(gitUrl, context) {
86
- // Check for GitHub Enterprise hosts first (if configured)
87
86
  if (context?.githubHosts?.length) {
88
87
  const host = extractHostFromUrl(gitUrl)?.toLowerCase();
89
88
  const normalizedHosts = context.githubHosts.map((h) => h.toLowerCase());
@@ -91,19 +90,16 @@ export function detectRepoType(gitUrl, context) {
91
90
  return "github";
92
91
  }
93
92
  }
94
- // Check for Azure DevOps formats (most specific patterns)
95
93
  for (const pattern of AZURE_DEVOPS_URL_PATTERNS) {
96
94
  if (pattern.test(gitUrl)) {
97
95
  return "azure-devops";
98
96
  }
99
97
  }
100
- // Check for GitHub formats
101
98
  for (const pattern of GITHUB_URL_PATTERNS) {
102
99
  if (pattern.test(gitUrl)) {
103
100
  return "github";
104
101
  }
105
102
  }
106
- // Check for GitLab SaaS formats
107
103
  for (const pattern of GITLAB_SAAS_URL_PATTERNS) {
108
104
  if (pattern.test(gitUrl)) {
109
105
  return "gitlab";
@@ -40,5 +40,8 @@ interface XfgInterpolationOptions {
40
40
  * @param options - Interpolation options (default: strict mode)
41
41
  * @returns Content with interpolated values
42
42
  */
43
+ export declare function interpolateXfgContent(content: string, ctx: XfgTemplateContext, options?: XfgInterpolationOptions): string;
44
+ export declare function interpolateXfgContent(content: string[], ctx: XfgTemplateContext, options?: XfgInterpolationOptions): string[];
45
+ export declare function interpolateXfgContent(content: Record<string, unknown>, ctx: XfgTemplateContext, options?: XfgInterpolationOptions): Record<string, unknown>;
43
46
  export declare function interpolateXfgContent(content: TemplateContent, ctx: XfgTemplateContext, options?: XfgInterpolationOptions): TemplateContent;
44
47
  export {};