@aspruyt/xfg 5.5.0 → 5.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/dist/config/normalizer.js +7 -5
- package/dist/config/types.d.ts +2 -0
- package/dist/config/validator.js +24 -3
- package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +1 -0
- package/dist/settings/repo-settings/github-repo-settings-strategy.js +14 -0
- package/dist/settings/repo-settings/processor.js +16 -0
- package/dist/settings/repo-settings/types.d.ts +4 -0
- package/dist/shared/gh-api-utils.d.ts +9 -0
- package/dist/shared/gh-api-utils.js +37 -0
- package/dist/shared/retry-utils.js +1 -0
- package/dist/sync/commit-push-manager.js +5 -1
- package/dist/sync/file-writer.js +3 -5
- package/dist/sync/types.d.ts +5 -2
- package/dist/vcs/commit-strategy-selector.d.ts +3 -2
- package/dist/vcs/commit-strategy-selector.js +6 -3
- package/dist/vcs/file-mode-fixup-commit-strategy.d.ts +34 -0
- package/dist/vcs/file-mode-fixup-commit-strategy.js +127 -0
- package/dist/vcs/index.d.ts +1 -0
- package/dist/vcs/index.js +1 -0
- package/dist/vcs/types.d.ts +3 -0
- package/package.json +1 -1
|
@@ -394,16 +394,18 @@ function mergeGroupSettings(rootSettings, groupNames, groupDefs) {
|
|
|
394
394
|
}
|
|
395
395
|
/**
|
|
396
396
|
* Evaluates a conditional group's `when` clause against a repo's effective groups.
|
|
397
|
-
*
|
|
398
|
-
*
|
|
397
|
+
* All specified operators must be satisfied: `allOf` (every listed group present),
|
|
398
|
+
* `anyOf` (at least one present), and `noneOf` (none of the listed groups present).
|
|
399
|
+
* Absent conditions are treated as satisfied.
|
|
399
400
|
*/
|
|
400
401
|
function evaluateWhenClause(when, effectiveGroups) {
|
|
401
|
-
// Defensive: if
|
|
402
|
-
if (!when.allOf && !when.anyOf)
|
|
402
|
+
// Defensive: if no condition is specified, don't match
|
|
403
|
+
if (!when.allOf && !when.anyOf && !when.noneOf)
|
|
403
404
|
return false;
|
|
404
405
|
const allOfSatisfied = !when.allOf || when.allOf.every((g) => effectiveGroups.has(g));
|
|
405
406
|
const anyOfSatisfied = !when.anyOf || when.anyOf.some((g) => effectiveGroups.has(g));
|
|
406
|
-
|
|
407
|
+
const noneOfSatisfied = !when.noneOf || when.noneOf.every((g) => !effectiveGroups.has(g));
|
|
408
|
+
return allOfSatisfied && anyOfSatisfied && noneOfSatisfied;
|
|
407
409
|
}
|
|
408
410
|
/**
|
|
409
411
|
* Merges matching conditional groups into the accumulated files/prOptions/settings.
|
package/dist/config/types.d.ts
CHANGED
|
@@ -315,6 +315,8 @@ export interface RawConditionalGroupWhen {
|
|
|
315
315
|
allOf?: string[];
|
|
316
316
|
/** At least one listed group must be present */
|
|
317
317
|
anyOf?: string[];
|
|
318
|
+
/** None of the listed groups may be present */
|
|
319
|
+
noneOf?: string[];
|
|
318
320
|
}
|
|
319
321
|
/** Conditional group: activates based on which groups a repo has */
|
|
320
322
|
export interface RawConditionalGroupConfig {
|
package/dist/config/validator.js
CHANGED
|
@@ -449,9 +449,9 @@ function validateConditionalGroups(config) {
|
|
|
449
449
|
if (!entry.when || !isPlainObject(entry.when)) {
|
|
450
450
|
throw new ValidationError(`${ctx}: 'when' is required and must be an object`);
|
|
451
451
|
}
|
|
452
|
-
const { allOf, anyOf } = entry.when;
|
|
453
|
-
if (!allOf && !anyOf) {
|
|
454
|
-
throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf' or '
|
|
452
|
+
const { allOf, anyOf, noneOf } = entry.when;
|
|
453
|
+
if (!allOf && !anyOf && !noneOf) {
|
|
454
|
+
throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf', 'anyOf', or 'noneOf'`);
|
|
455
455
|
}
|
|
456
456
|
if (allOf !== undefined) {
|
|
457
457
|
validateGroupRefArray(allOf, "allOf", ctx, groupNames);
|
|
@@ -459,6 +459,27 @@ function validateConditionalGroups(config) {
|
|
|
459
459
|
if (anyOf !== undefined) {
|
|
460
460
|
validateGroupRefArray(anyOf, "anyOf", ctx, groupNames);
|
|
461
461
|
}
|
|
462
|
+
if (noneOf !== undefined) {
|
|
463
|
+
validateGroupRefArray(noneOf, "noneOf", ctx, groupNames);
|
|
464
|
+
}
|
|
465
|
+
// Cross-operator overlap: noneOf must not share groups with allOf or anyOf
|
|
466
|
+
if (noneOf) {
|
|
467
|
+
const noneOfSet = new Set(noneOf);
|
|
468
|
+
if (allOf) {
|
|
469
|
+
for (const g of allOf) {
|
|
470
|
+
if (noneOfSet.has(g)) {
|
|
471
|
+
throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with allOf (contradictory condition)`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (anyOf) {
|
|
476
|
+
for (const g of anyOf) {
|
|
477
|
+
if (noneOfSet.has(g)) {
|
|
478
|
+
throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with anyOf (contradictory condition)`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
462
483
|
// Validate files
|
|
463
484
|
if (entry.files) {
|
|
464
485
|
for (const [fileName, fileConfig] of Object.entries(entry.files)) {
|
|
@@ -15,6 +15,7 @@ export declare class GitHubRepoSettingsStrategy implements IRepoSettingsStrategy
|
|
|
15
15
|
setVulnerabilityAlerts(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
|
|
16
16
|
setAutomatedSecurityFixes(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
|
|
17
17
|
setPrivateVulnerabilityReporting(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
|
|
18
|
+
branchExists(repoInfo: RepoInfo, branch: string, options?: GhApiOptions): Promise<boolean>;
|
|
18
19
|
private getVulnerabilityAlerts;
|
|
19
20
|
private getAutomatedSecurityFixes;
|
|
20
21
|
private getPrivateVulnerabilityReporting;
|
|
@@ -104,6 +104,20 @@ export class GitHubRepoSettingsStrategy {
|
|
|
104
104
|
const method = enable ? "PUT" : "DELETE";
|
|
105
105
|
await this.api.call(method, endpoint, { options });
|
|
106
106
|
}
|
|
107
|
+
async branchExists(repoInfo, branch, options) {
|
|
108
|
+
assertGitHubRepo(repoInfo, "GitHub Repo Settings strategy");
|
|
109
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/branches/${encodeURIComponent(branch)}`;
|
|
110
|
+
try {
|
|
111
|
+
await this.api.call("GET", endpoint, { options });
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (isHttp404Error(error)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
107
121
|
async getVulnerabilityAlerts(github, options) {
|
|
108
122
|
const endpoint = `/repos/${github.owner}/${github.repo}/vulnerability-alerts`;
|
|
109
123
|
try {
|
|
@@ -47,6 +47,22 @@ export class RepoSettingsProcessor {
|
|
|
47
47
|
changes: { create: 0, update: 0, delete: 0, unchanged: unchangedCount },
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
// Validate defaultBranch target exists before attempting to apply
|
|
51
|
+
const defaultBranchChange = changes.find((c) => c.property === "defaultBranch" && c.action !== "unchanged");
|
|
52
|
+
if (defaultBranchChange) {
|
|
53
|
+
const targetBranch = String(defaultBranchChange.newValue);
|
|
54
|
+
const exists = await this.strategy.branchExists(githubRepo, targetBranch, strategyOptions);
|
|
55
|
+
if (!exists) {
|
|
56
|
+
const currentBranch = defaultBranchChange.oldValue
|
|
57
|
+
? String(defaultBranchChange.oldValue)
|
|
58
|
+
: "unknown";
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
repoName,
|
|
62
|
+
message: `Failed: Cannot set default branch to '${targetBranch}': branch '${targetBranch}' does not exist (current: '${currentBranch}'). Create or rename the branch first.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
50
66
|
// Format plan output
|
|
51
67
|
const planOutput = formatRepoSettingsPlan(changes);
|
|
52
68
|
const changeCounts = {
|
|
@@ -63,4 +63,8 @@ export interface IRepoSettingsStrategy {
|
|
|
63
63
|
* Enables or disables private vulnerability reporting.
|
|
64
64
|
*/
|
|
65
65
|
setPrivateVulnerabilityReporting(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Checks whether a branch exists in the repository.
|
|
68
|
+
*/
|
|
69
|
+
branchExists(repoInfo: RepoInfo, branch: string, options?: GhApiOptions): Promise<boolean>;
|
|
66
70
|
}
|
|
@@ -36,6 +36,15 @@ export declare function parseResponseBody(raw: string): string;
|
|
|
36
36
|
* No-op if stdout is absent or does not contain a numeric Retry-After header.
|
|
37
37
|
*/
|
|
38
38
|
export declare function attachRetryAfter(error: unknown): void;
|
|
39
|
+
/**
|
|
40
|
+
* Extracts GitHub API validation error details from `gh api --include` stdout
|
|
41
|
+
* and appends them to the error message. This surfaces the descriptive error
|
|
42
|
+
* messages that GitHub returns in 422 responses (e.g., "The branch main was
|
|
43
|
+
* not found") which are otherwise lost when only stderr is shown.
|
|
44
|
+
*
|
|
45
|
+
* No-op if stdout is absent or does not contain parseable error JSON.
|
|
46
|
+
*/
|
|
47
|
+
export declare function attachValidationDetails(error: unknown): void;
|
|
39
48
|
/**
|
|
40
49
|
* Encapsulates executor + retries for GitHub API calls.
|
|
41
50
|
* Strategies compose with this instead of duplicating ghApi wrappers.
|
|
@@ -48,6 +48,42 @@ export function attachRetryAfter(error) {
|
|
|
48
48
|
error.retryAfter = parseInt(match[1], 10);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Extracts GitHub API validation error details from `gh api --include` stdout
|
|
53
|
+
* and appends them to the error message. This surfaces the descriptive error
|
|
54
|
+
* messages that GitHub returns in 422 responses (e.g., "The branch main was
|
|
55
|
+
* not found") which are otherwise lost when only stderr is shown.
|
|
56
|
+
*
|
|
57
|
+
* No-op if stdout is absent or does not contain parseable error JSON.
|
|
58
|
+
*/
|
|
59
|
+
export function attachValidationDetails(error) {
|
|
60
|
+
const stdout = error.stdout;
|
|
61
|
+
if (!stdout)
|
|
62
|
+
return;
|
|
63
|
+
const stdoutStr = typeof stdout === "string" ? stdout : stdout.toString();
|
|
64
|
+
const body = parseResponseBody(stdoutStr);
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(body);
|
|
67
|
+
const details = [];
|
|
68
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
69
|
+
for (const err of parsed.errors) {
|
|
70
|
+
if (err.message) {
|
|
71
|
+
const fieldPrefix = err.field ? `${err.field}: ` : "";
|
|
72
|
+
details.push(`${fieldPrefix}${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (parsed.message) {
|
|
77
|
+
details.push(parsed.message);
|
|
78
|
+
}
|
|
79
|
+
if (details.length > 0 && error instanceof Error) {
|
|
80
|
+
error.message += ` [${details.join("; ")}]`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// JSON parse failed — stdout is not a JSON error response
|
|
85
|
+
}
|
|
86
|
+
}
|
|
51
87
|
/**
|
|
52
88
|
* Executes a GitHub API call using the gh CLI.
|
|
53
89
|
* Shared by labels, rulesets, and repo-settings strategies.
|
|
@@ -81,6 +117,7 @@ async function ghApiCall(method, endpoint, opts) {
|
|
|
81
117
|
catch (error) {
|
|
82
118
|
if (!paginate) {
|
|
83
119
|
attachRetryAfter(error);
|
|
120
|
+
attachValidationDetails(error);
|
|
84
121
|
}
|
|
85
122
|
throw error;
|
|
86
123
|
}
|
|
@@ -18,7 +18,11 @@ export class CommitPushManager {
|
|
|
18
18
|
}
|
|
19
19
|
const changes = Array.from(fileChanges.entries())
|
|
20
20
|
.filter(([, info]) => info.action !== "skip")
|
|
21
|
-
.map(([path, info]) => ({
|
|
21
|
+
.map(([path, info]) => ({
|
|
22
|
+
path,
|
|
23
|
+
content: info.content,
|
|
24
|
+
...(info.mode ? { mode: info.mode } : {}),
|
|
25
|
+
}));
|
|
22
26
|
this.log.info("Staging changes...");
|
|
23
27
|
await gitOps.stageAll();
|
|
24
28
|
if (!(await gitOps.hasStagedChanges())) {
|
package/dist/sync/file-writer.js
CHANGED
|
@@ -69,6 +69,9 @@ export class FileWriter {
|
|
|
69
69
|
fileName: file.fileName,
|
|
70
70
|
content: fileContent,
|
|
71
71
|
action,
|
|
72
|
+
// mode is only set on changed files — unchanged files won't trigger a
|
|
73
|
+
// fixup commit, which is correct since their mode was set on a prior sync
|
|
74
|
+
...(shouldBeExecutable(file) ? { mode: "100755" } : {}),
|
|
72
75
|
};
|
|
73
76
|
// Compute raw diff lines for text files (all modes)
|
|
74
77
|
if (!isBinaryFile(file.fileName)) {
|
|
@@ -95,11 +98,6 @@ export class FileWriter {
|
|
|
95
98
|
continue;
|
|
96
99
|
}
|
|
97
100
|
if (shouldBeExecutable(file)) {
|
|
98
|
-
if (tracked?.action === "create" && ctx.hasAppCredentials) {
|
|
99
|
-
log.warn(`${file.fileName}: GitHub App commits cannot set executable mode on new files. ` +
|
|
100
|
-
`The file will be created as non-executable (100644). ` +
|
|
101
|
-
`See: https://anthony-spruyt.github.io/xfg/examples/executable-files/`);
|
|
102
|
-
}
|
|
103
101
|
log.info(`Setting executable: ${file.fileName}`);
|
|
104
102
|
await gitOps.setExecutable(file.fileName);
|
|
105
103
|
}
|
package/dist/sync/types.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export interface FileWriteResult {
|
|
|
11
11
|
content: string | null;
|
|
12
12
|
action: "create" | "update" | "delete" | "skip";
|
|
13
13
|
diffLines?: string[];
|
|
14
|
+
/** Git file mode. Only set for executable files ("100755"). "100644" is included
|
|
15
|
+
* in the union for type completeness — non-executable files omit this field. */
|
|
16
|
+
mode?: "100755" | "100644";
|
|
14
17
|
}
|
|
15
18
|
export interface FileWriteContext {
|
|
16
19
|
repoInfo: RepoInfo;
|
|
@@ -19,7 +22,7 @@ export interface FileWriteContext {
|
|
|
19
22
|
dryRun: boolean;
|
|
20
23
|
noDelete: boolean;
|
|
21
24
|
configId: string;
|
|
22
|
-
/** True when using GraphQL commit strategy (GitHub App)
|
|
25
|
+
/** True when using GraphQL commit strategy (GitHub App) */
|
|
23
26
|
hasAppCredentials?: boolean;
|
|
24
27
|
}
|
|
25
28
|
export interface FileWriterDeps {
|
|
@@ -129,7 +132,7 @@ export interface ProcessorOptions {
|
|
|
129
132
|
noDelete?: boolean;
|
|
130
133
|
/** GitHub token for authentication (resolved by caller) */
|
|
131
134
|
token?: string;
|
|
132
|
-
/** True when using GraphQL commit strategy (GitHub App)
|
|
135
|
+
/** True when using GraphQL commit strategy (GitHub App) */
|
|
133
136
|
hasAppCredentials?: boolean;
|
|
134
137
|
}
|
|
135
138
|
export interface FileChangeDetail {
|
|
@@ -11,8 +11,9 @@ interface GitHubAppCredentials {
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function createTokenManager(credentials?: GitHubAppCredentials): GitHubAppTokenManager | null;
|
|
13
13
|
/**
|
|
14
|
-
* Returns
|
|
15
|
-
*
|
|
14
|
+
* Returns FileModeFixupCommitStrategy (decorating GraphQLCommitStrategy) for
|
|
15
|
+
* GitHub repos with App credentials (verified commits + executable file mode
|
|
16
|
+
* support), or GitCommitStrategy for all other cases.
|
|
16
17
|
*/
|
|
17
18
|
export declare function createCommitStrategy(repoInfo: RepoInfo, executor: ICommandExecutor, hasAppCredentials?: boolean): ICommitStrategy;
|
|
18
19
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isGitHubRepo } from "../shared/repo-detector.js";
|
|
2
2
|
import { GitCommitStrategy } from "./git-commit-strategy.js";
|
|
3
3
|
import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
|
|
4
|
+
import { FileModeFixupCommitStrategy } from "./file-mode-fixup-commit-strategy.js";
|
|
4
5
|
import { GitHubAppTokenManager } from "./github-app-token-manager.js";
|
|
5
6
|
/**
|
|
6
7
|
* Creates a GitHubAppTokenManager from credentials, or null if not provided.
|
|
@@ -12,12 +13,14 @@ export function createTokenManager(credentials) {
|
|
|
12
13
|
return new GitHubAppTokenManager(credentials.appId, credentials.privateKey);
|
|
13
14
|
}
|
|
14
15
|
/**
|
|
15
|
-
* Returns
|
|
16
|
-
*
|
|
16
|
+
* Returns FileModeFixupCommitStrategy (decorating GraphQLCommitStrategy) for
|
|
17
|
+
* GitHub repos with App credentials (verified commits + executable file mode
|
|
18
|
+
* support), or GitCommitStrategy for all other cases.
|
|
17
19
|
*/
|
|
18
20
|
export function createCommitStrategy(repoInfo, executor, hasAppCredentials) {
|
|
19
21
|
if (isGitHubRepo(repoInfo) && hasAppCredentials) {
|
|
20
|
-
|
|
22
|
+
const inner = new GraphQLCommitStrategy(executor);
|
|
23
|
+
return new FileModeFixupCommitStrategy(inner, executor);
|
|
21
24
|
}
|
|
22
25
|
return new GitCommitStrategy(executor);
|
|
23
26
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ICommitStrategy, CommitOptions, CommitResult } from "./types.js";
|
|
2
|
+
import type { ICommandExecutor } from "../shared/command-executor.js";
|
|
3
|
+
import { GhApiClient } from "../shared/gh-api-utils.js";
|
|
4
|
+
/** Factory type for GhApiClient — enables test injection. */
|
|
5
|
+
export type GhApiClientFactory = (executor: ICommandExecutor, retries: number, cwd: string) => GhApiClient;
|
|
6
|
+
/**
|
|
7
|
+
* Decorator that adds a follow-up commit to fix executable file modes.
|
|
8
|
+
*
|
|
9
|
+
* The GitHub GraphQL createCommitOnBranch mutation cannot set file modes.
|
|
10
|
+
* After the inner strategy (GraphQLCommitStrategy) creates the content commit,
|
|
11
|
+
* this decorator creates a second commit via the REST Git Data API that
|
|
12
|
+
* patches tree modes from 100644 to 100755 for executable files.
|
|
13
|
+
*
|
|
14
|
+
* Only activates when fileChanges contain entries with mode "100755".
|
|
15
|
+
* When no executable files are present, delegates directly to the inner strategy.
|
|
16
|
+
*/
|
|
17
|
+
export declare class FileModeFixupCommitStrategy implements ICommitStrategy {
|
|
18
|
+
private readonly inner;
|
|
19
|
+
private readonly executor;
|
|
20
|
+
private readonly clientFactory;
|
|
21
|
+
constructor(inner: ICommitStrategy, executor: ICommandExecutor, clientFactory?: GhApiClientFactory);
|
|
22
|
+
commit(options: CommitOptions): Promise<CommitResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Create a fixup commit that changes file modes from 100644 to 100755.
|
|
25
|
+
*
|
|
26
|
+
* Flow:
|
|
27
|
+
* 1. GET the content commit to find its tree SHA
|
|
28
|
+
* 2. GET the tree (recursive) to find blob SHAs for executable files
|
|
29
|
+
* 3. POST a new tree with updated modes (base_tree carries forward unchanged)
|
|
30
|
+
* 4. POST a new commit with the new tree
|
|
31
|
+
* 5. PATCH the branch ref to point to the new commit
|
|
32
|
+
*/
|
|
33
|
+
private createFixupCommit;
|
|
34
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { isGitHubRepo } from "../shared/repo-detector.js";
|
|
2
|
+
import { GhApiClient } from "../shared/gh-api-utils.js";
|
|
3
|
+
import { parseApiJson } from "../shared/json-utils.js";
|
|
4
|
+
import { SyncError } from "../shared/errors.js";
|
|
5
|
+
const defaultClientFactory = (executor, retries, cwd) => new GhApiClient(executor, retries, cwd);
|
|
6
|
+
/**
|
|
7
|
+
* Decorator that adds a follow-up commit to fix executable file modes.
|
|
8
|
+
*
|
|
9
|
+
* The GitHub GraphQL createCommitOnBranch mutation cannot set file modes.
|
|
10
|
+
* After the inner strategy (GraphQLCommitStrategy) creates the content commit,
|
|
11
|
+
* this decorator creates a second commit via the REST Git Data API that
|
|
12
|
+
* patches tree modes from 100644 to 100755 for executable files.
|
|
13
|
+
*
|
|
14
|
+
* Only activates when fileChanges contain entries with mode "100755".
|
|
15
|
+
* When no executable files are present, delegates directly to the inner strategy.
|
|
16
|
+
*/
|
|
17
|
+
export class FileModeFixupCommitStrategy {
|
|
18
|
+
inner;
|
|
19
|
+
executor;
|
|
20
|
+
clientFactory;
|
|
21
|
+
constructor(inner, executor, clientFactory = defaultClientFactory) {
|
|
22
|
+
this.inner = inner;
|
|
23
|
+
this.executor = executor;
|
|
24
|
+
this.clientFactory = clientFactory;
|
|
25
|
+
}
|
|
26
|
+
async commit(options) {
|
|
27
|
+
const innerResult = await this.inner.commit(options);
|
|
28
|
+
// Only non-deleted files can have their mode fixed (deletions have content === null)
|
|
29
|
+
const executableFiles = options.fileChanges.filter((fc) => fc.mode === "100755" && fc.content !== null);
|
|
30
|
+
if (executableFiles.length === 0) {
|
|
31
|
+
return innerResult;
|
|
32
|
+
}
|
|
33
|
+
// Safety net: only GitHub repos use the REST Git Data API for fixup.
|
|
34
|
+
// Currently only composed for GitHub repos in createCommitStrategy(),
|
|
35
|
+
// but guard defensively in case the decorator is reused elsewhere.
|
|
36
|
+
if (!isGitHubRepo(options.repoInfo)) {
|
|
37
|
+
return innerResult;
|
|
38
|
+
}
|
|
39
|
+
return await this.createFixupCommit(options.repoInfo, options.branchName, innerResult, executableFiles, options.workDir, options.retries ?? 3, options.token);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create a fixup commit that changes file modes from 100644 to 100755.
|
|
43
|
+
*
|
|
44
|
+
* Flow:
|
|
45
|
+
* 1. GET the content commit to find its tree SHA
|
|
46
|
+
* 2. GET the tree (recursive) to find blob SHAs for executable files
|
|
47
|
+
* 3. POST a new tree with updated modes (base_tree carries forward unchanged)
|
|
48
|
+
* 4. POST a new commit with the new tree
|
|
49
|
+
* 5. PATCH the branch ref to point to the new commit
|
|
50
|
+
*/
|
|
51
|
+
async createFixupCommit(repoInfo, branchName, innerResult, executableFiles, workDir, retries, token) {
|
|
52
|
+
const parentSha = innerResult.sha;
|
|
53
|
+
const client = this.clientFactory(this.executor, retries, workDir);
|
|
54
|
+
const apiOpts = {
|
|
55
|
+
token,
|
|
56
|
+
host: repoInfo.host,
|
|
57
|
+
};
|
|
58
|
+
const repoPath = `repos/${repoInfo.owner}/${repoInfo.repo}`;
|
|
59
|
+
// 1. Get the commit to find tree SHA
|
|
60
|
+
const commitRaw = await client.call("GET", `${repoPath}/git/commits/${parentSha}`, { options: apiOpts });
|
|
61
|
+
const commitData = parseApiJson(commitRaw, "GET git commit");
|
|
62
|
+
const treeSha = commitData.tree.sha;
|
|
63
|
+
// 2. Get tree entries to find blob SHAs
|
|
64
|
+
const treeRaw = await client.call("GET", `${repoPath}/git/trees/${treeSha}?recursive=1`, { options: apiOpts });
|
|
65
|
+
const treeData = parseApiJson(treeRaw, "GET git tree");
|
|
66
|
+
const executablePaths = new Set(executableFiles.map((f) => f.path));
|
|
67
|
+
const treeEntries = [];
|
|
68
|
+
for (const entry of treeData.tree) {
|
|
69
|
+
if (executablePaths.has(entry.path) &&
|
|
70
|
+
entry.type === "blob" &&
|
|
71
|
+
entry.mode !== "100755") {
|
|
72
|
+
treeEntries.push({
|
|
73
|
+
path: entry.path,
|
|
74
|
+
mode: "100755",
|
|
75
|
+
type: "blob",
|
|
76
|
+
sha: entry.sha,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// If tree was truncated (>100k entries), check that all executable files were found
|
|
81
|
+
if (treeData.truncated) {
|
|
82
|
+
const foundPaths = new Set(treeData.tree.filter((e) => e.type === "blob").map((e) => e.path));
|
|
83
|
+
const missing = [...executablePaths].filter((p) => !foundPaths.has(p));
|
|
84
|
+
if (missing.length > 0) {
|
|
85
|
+
throw new SyncError(`File mode fixup incomplete: tree response was truncated (>100k entries) ` +
|
|
86
|
+
`and ${missing.length} executable file(s) were not found: ${missing.join(", ")}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (treeEntries.length === 0) {
|
|
90
|
+
// All requested files are either already 100755 or absent from the tree.
|
|
91
|
+
// Absent files in a non-truncated tree means createCommitOnBranch did not
|
|
92
|
+
// include them (e.g., concurrent deletion) — safe to skip since there is
|
|
93
|
+
// no blob to patch.
|
|
94
|
+
return innerResult;
|
|
95
|
+
}
|
|
96
|
+
// 3. Create new tree with updated modes
|
|
97
|
+
const newTreeRaw = await client.call("POST", `${repoPath}/git/trees`, {
|
|
98
|
+
payload: { base_tree: treeSha, tree: treeEntries },
|
|
99
|
+
options: apiOpts,
|
|
100
|
+
});
|
|
101
|
+
const newTree = parseApiJson(newTreeRaw, "POST git tree");
|
|
102
|
+
// 4. Create fixup commit (message is not user-customizable; this is an
|
|
103
|
+
// internal implementation detail of the mode-patching decorator)
|
|
104
|
+
const newCommitRaw = await client.call("POST", `${repoPath}/git/commits`, {
|
|
105
|
+
payload: {
|
|
106
|
+
message: "chore: set executable file modes",
|
|
107
|
+
tree: newTree.sha,
|
|
108
|
+
parents: [parentSha],
|
|
109
|
+
},
|
|
110
|
+
options: apiOpts,
|
|
111
|
+
});
|
|
112
|
+
const newCommit = parseApiJson(newCommitRaw, "POST git commit");
|
|
113
|
+
// 5. Update branch ref (fast-forward only; force is not needed since the
|
|
114
|
+
// fixup commit's parent is always the content commit we just created).
|
|
115
|
+
// Branch names with slashes (e.g. "chore/sync-config") are passed verbatim —
|
|
116
|
+
// the GitHub REST API accepts literal slashes in ref paths.
|
|
117
|
+
await client.call("PATCH", `${repoPath}/git/refs/heads/${branchName}`, {
|
|
118
|
+
payload: { sha: newCommit.sha },
|
|
119
|
+
options: apiOpts,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
sha: newCommit.sha,
|
|
123
|
+
verified: true,
|
|
124
|
+
pushed: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/vcs/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { PRMergeConfig, FileChange, FileAction, IGitOps, ILocalGitOps, IPRStrategy, GitAuthOptions, PRResult, ICommitStrategy, } from "./types.js";
|
|
2
2
|
export type { GitOpsOptions } from "./git-ops.js";
|
|
3
3
|
export { createCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
|
|
4
|
+
export { FileModeFixupCommitStrategy } from "./file-mode-fixup-commit-strategy.js";
|
|
4
5
|
export type { GitHubAppTokenManager } from "./github-app-token-manager.js";
|
|
5
6
|
export { GitOps } from "./git-ops.js";
|
|
6
7
|
export { AuthenticatedGitOps } from "./authenticated-git-ops.js";
|
package/dist/vcs/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Commit strategies
|
|
2
2
|
export { createCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
|
|
3
|
+
export { FileModeFixupCommitStrategy } from "./file-mode-fixup-commit-strategy.js";
|
|
3
4
|
// Git operations
|
|
4
5
|
export { GitOps } from "./git-ops.js";
|
|
5
6
|
export { AuthenticatedGitOps } from "./authenticated-git-ops.js";
|
package/dist/vcs/types.d.ts
CHANGED
|
@@ -146,6 +146,9 @@ export interface FileAction {
|
|
|
146
146
|
export interface FileChange {
|
|
147
147
|
path: string;
|
|
148
148
|
content: string | null;
|
|
149
|
+
/** Git file mode. Only set for executable files ("100755"). "100644" is included
|
|
150
|
+
* in the union for type completeness — non-executable files omit this field. */
|
|
151
|
+
mode?: "100755" | "100644";
|
|
149
152
|
}
|
|
150
153
|
export interface CommitOptions {
|
|
151
154
|
repoInfo: RepoInfo;
|
package/package.json
CHANGED