@aspruyt/xfg 3.9.14 → 3.10.1
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/index.d.ts +1 -1
- package/dist/config/normalizer.d.ts +2 -2
- package/dist/config/normalizer.js +3 -0
- package/dist/config/types.d.ts +8 -1
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +11 -0
- package/dist/lifecycle/github-lifecycle-provider.js +12 -3
- package/dist/shared/logger.d.ts +2 -0
- package/dist/shared/logger.js +3 -0
- package/dist/shared/retry-utils.js +1 -0
- package/dist/sync/file-writer.js +6 -0
- package/dist/sync/pr-merge-handler.js +1 -0
- package/dist/vcs/github-pr-strategy.js +8 -2
- package/dist/vcs/graphql-commit-strategy.d.ts +29 -0
- package/dist/vcs/graphql-commit-strategy.js +92 -35
- package/dist/vcs/pr-creator.d.ts +2 -0
- package/dist/vcs/pr-creator.js +2 -1
- package/dist/vcs/types.d.ts +3 -1
- package/package.json +1 -1
package/dist/config/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { MergeMode, MergeStrategy, PRMergeOptions, RulesetTarget, RulesetEnforcement, BypassActorType, BypassMode, PatternOperator, MergeMethod, AlertsThreshold, SecurityAlertsThreshold, BypassActor, RefNameCondition, RulesetConditions, StatusCheckConfig, RequiredReviewer, CodeScanningTool, WorkflowConfig, PullRequestRuleParameters, RequiredStatusChecksParameters, UpdateRuleParameters, RequiredDeploymentsParameters, CodeScanningParameters, CodeQualityParameters, WorkflowsParameters, PatternRuleParameters, FilePathRestrictionParameters, FileExtensionRestrictionParameters, MaxFilePathLengthParameters, MaxFileSizeParameters, PullRequestRule, RequiredStatusChecksRule, RequiredSignaturesRule, RequiredLinearHistoryRule, NonFastForwardRule, CreationRule, UpdateRule, DeletionRule, RequiredDeploymentsRule, CodeScanningRule, CodeQualityRule, WorkflowsRule, CommitAuthorEmailPatternRule, CommitMessagePatternRule, CommitterEmailPatternRule, BranchNamePatternRule, TagNamePatternRule, FilePathRestrictionRule, FileExtensionRestrictionRule, MaxFilePathLengthRule, MaxFileSizeRule, RulesetRule, Ruleset, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, RepoVisibility, GitHubRepoSettings, Label, RepoSettings, ContentValue, RawFileConfig, RawRepoFileOverride, RawRepoSettings, RawRepoConfig, RawConfig, FileContent, RepoConfig, Config, } from "./types.js";
|
|
1
|
+
export type { MergeMode, MergeStrategy, PRMergeOptions, RulesetTarget, RulesetEnforcement, BypassActorType, BypassMode, PatternOperator, MergeMethod, AlertsThreshold, SecurityAlertsThreshold, BypassActor, RefNameCondition, RulesetConditions, StatusCheckConfig, RequiredReviewer, CodeScanningTool, WorkflowConfig, PullRequestRuleParameters, RequiredStatusChecksParameters, UpdateRuleParameters, RequiredDeploymentsParameters, CodeScanningParameters, CodeQualityParameters, WorkflowsParameters, PatternRuleParameters, FilePathRestrictionParameters, FileExtensionRestrictionParameters, MaxFilePathLengthParameters, MaxFileSizeParameters, PullRequestRule, RequiredStatusChecksRule, RequiredSignaturesRule, RequiredLinearHistoryRule, NonFastForwardRule, CreationRule, UpdateRule, DeletionRule, RequiredDeploymentsRule, CodeScanningRule, CodeQualityRule, WorkflowsRule, CommitAuthorEmailPatternRule, CommitMessagePatternRule, CommitterEmailPatternRule, BranchNamePatternRule, TagNamePatternRule, FilePathRestrictionRule, FileExtensionRestrictionRule, MaxFilePathLengthRule, MaxFileSizeRule, RulesetRule, Ruleset, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, RepoVisibility, GitHubRepoSettings, Label, RepoSettings, ContentValue, RawFileConfig, RawRepoFileOverride, RawRootSettings, RawRepoSettings, RawRepoConfig, RawConfig, FileContent, RepoConfig, Config, } from "./types.js";
|
|
2
2
|
export { RULESET_FIELD_MAP, RULESET_COMPARABLE_FIELDS } from "./types.js";
|
|
3
3
|
export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
|
|
4
4
|
export { convertContentToString, detectOutputFormat, type OutputFormat, type ConvertOptions, } from "./formatter.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { RawConfig, Config, RepoSettings, RawRepoSettings } from "./types.js";
|
|
2
|
-
export declare function mergeSettings(root:
|
|
1
|
+
import type { RawConfig, Config, RepoSettings, RawRootSettings, RawRepoSettings } from "./types.js";
|
|
2
|
+
export declare function mergeSettings(root: RawRootSettings | undefined, perRepo: RawRepoSettings | undefined): RepoSettings | undefined;
|
|
3
3
|
/**
|
|
4
4
|
* Normalizes raw config into expanded, merged config.
|
|
5
5
|
* Pipeline: expand git arrays -> merge content -> interpolate env vars
|
|
@@ -26,6 +26,7 @@ function mergePROptions(global, perRepo) {
|
|
|
26
26
|
const mergeStrategy = perRepo.mergeStrategy ?? global.mergeStrategy;
|
|
27
27
|
const deleteBranch = perRepo.deleteBranch ?? global.deleteBranch;
|
|
28
28
|
const bypassReason = perRepo.bypassReason ?? global.bypassReason;
|
|
29
|
+
const labels = perRepo.labels ?? global.labels;
|
|
29
30
|
if (merge !== undefined)
|
|
30
31
|
result.merge = merge;
|
|
31
32
|
if (mergeStrategy !== undefined)
|
|
@@ -34,6 +35,8 @@ function mergePROptions(global, perRepo) {
|
|
|
34
35
|
result.deleteBranch = deleteBranch;
|
|
35
36
|
if (bypassReason !== undefined)
|
|
36
37
|
result.bypassReason = bypassReason;
|
|
38
|
+
if (labels !== undefined)
|
|
39
|
+
result.labels = labels;
|
|
37
40
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
38
41
|
}
|
|
39
42
|
/**
|
package/dist/config/types.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface PRMergeOptions {
|
|
|
6
6
|
mergeStrategy?: MergeStrategy;
|
|
7
7
|
deleteBranch?: boolean;
|
|
8
8
|
bypassReason?: string;
|
|
9
|
+
labels?: string[];
|
|
9
10
|
}
|
|
10
11
|
/** Ruleset target type */
|
|
11
12
|
export type RulesetTarget = "branch" | "tag";
|
|
@@ -304,6 +305,12 @@ export interface RawRepoFileOverride {
|
|
|
304
305
|
vars?: Record<string, string>;
|
|
305
306
|
deleteOrphaned?: boolean;
|
|
306
307
|
}
|
|
308
|
+
export interface RawRootSettings {
|
|
309
|
+
rulesets?: Record<string, Ruleset | false>;
|
|
310
|
+
repo?: GitHubRepoSettings | false;
|
|
311
|
+
labels?: Record<string, Label | false>;
|
|
312
|
+
deleteOrphaned?: boolean;
|
|
313
|
+
}
|
|
307
314
|
export interface RawRepoSettings {
|
|
308
315
|
rulesets?: Record<string, Ruleset | false> & {
|
|
309
316
|
inherit?: boolean;
|
|
@@ -334,7 +341,7 @@ export interface RawConfig {
|
|
|
334
341
|
prTemplate?: string;
|
|
335
342
|
githubHosts?: string[];
|
|
336
343
|
deleteOrphaned?: boolean;
|
|
337
|
-
settings?:
|
|
344
|
+
settings?: RawRootSettings;
|
|
338
345
|
}
|
|
339
346
|
export interface FileContent {
|
|
340
347
|
fileName: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RawConfig, RawRepoSettings } from "./types.js";
|
|
1
|
+
import type { RawConfig, RawRepoSettings, RawRootSettings } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Validates settings object containing rulesets, labels, and repo settings.
|
|
4
4
|
*/
|
|
@@ -16,7 +16,7 @@ export declare function validateForSync(config: RawConfig): void;
|
|
|
16
16
|
/**
|
|
17
17
|
* Checks if settings contain actionable configuration.
|
|
18
18
|
*/
|
|
19
|
-
export declare function hasActionableSettings(settings: RawRepoSettings | undefined): boolean;
|
|
19
|
+
export declare function hasActionableSettings(settings: RawRootSettings | RawRepoSettings | undefined): boolean;
|
|
20
20
|
/**
|
|
21
21
|
* Validates that config is suitable for the settings command.
|
|
22
22
|
* @throws Error if no settings are defined or no actionable settings exist
|
package/dist/config/validator.js
CHANGED
|
@@ -274,6 +274,17 @@ export function validateRawConfig(config) {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
|
+
// Validate prOptions.labels if present
|
|
278
|
+
if (config.prOptions?.labels !== undefined) {
|
|
279
|
+
if (!Array.isArray(config.prOptions.labels)) {
|
|
280
|
+
throw new Error("prOptions.labels must be an array of strings");
|
|
281
|
+
}
|
|
282
|
+
for (const label of config.prOptions.labels) {
|
|
283
|
+
if (typeof label !== "string" || label.length === 0) {
|
|
284
|
+
throw new Error("prOptions.labels entries must be non-empty strings");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
277
288
|
// Validate each repo
|
|
278
289
|
for (let i = 0; i < config.repos.length; i++) {
|
|
279
290
|
const repo = config.repos[i];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { escapeShellArg } from "../shared/shell-utils.js";
|
|
2
2
|
import { defaultExecutor, } from "../shared/command-executor.js";
|
|
3
|
-
import { withRetry } from "../shared/retry-utils.js";
|
|
3
|
+
import { withRetry, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
|
|
4
4
|
import { isGitHubRepo, } from "../shared/repo-detector.js";
|
|
5
5
|
import { logger } from "../shared/logger.js";
|
|
6
6
|
/**
|
|
@@ -304,11 +304,20 @@ export class GitHubLifecycleProvider {
|
|
|
304
304
|
const hostnameFlag = getHostnameFlag(repoInfo);
|
|
305
305
|
const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
|
|
306
306
|
const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
|
|
307
|
+
// After repo creation, GitHub may return 404 due to eventual consistency.
|
|
308
|
+
// Exclude 404/not-found from permanent errors so withRetry retries them.
|
|
309
|
+
const postCreatePermanentPatterns = DEFAULT_PERMANENT_ERROR_PATTERNS.filter((p) => !p.test("404 Not Found"));
|
|
307
310
|
// Get the SHA of the README.md created by --add-readme
|
|
308
|
-
const fileInfo = await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath}/contents/README.md --jq '.sha'`, this.cwd), {
|
|
311
|
+
const fileInfo = await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath}/contents/README.md --jq '.sha'`, this.cwd), {
|
|
312
|
+
retries: this.retries,
|
|
313
|
+
permanentErrorPatterns: postCreatePermanentPatterns,
|
|
314
|
+
});
|
|
309
315
|
const sha = fileInfo.trim();
|
|
310
316
|
// Delete the README.md to leave the repo clean
|
|
311
317
|
await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath}/contents/README.md ` +
|
|
312
|
-
`--method DELETE -f message='Remove initialization file' -f sha=${escapeShellArg(sha)}`, this.cwd), {
|
|
318
|
+
`--method DELETE -f message='Remove initialization file' -f sha=${escapeShellArg(sha)}`, this.cwd), {
|
|
319
|
+
retries: this.retries,
|
|
320
|
+
permanentErrorPatterns: postCreatePermanentPatterns,
|
|
321
|
+
});
|
|
313
322
|
}
|
|
314
323
|
}
|
package/dist/shared/logger.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FileStatus } from "../sync/diff-utils.js";
|
|
2
2
|
export interface ILogger {
|
|
3
3
|
info(message: string): void;
|
|
4
|
+
warn(message: string): void;
|
|
4
5
|
debug(message: string): void;
|
|
5
6
|
fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
|
|
6
7
|
diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
|
|
@@ -21,6 +22,7 @@ export declare class Logger implements ILogger {
|
|
|
21
22
|
setTotal(total: number): void;
|
|
22
23
|
progress(current: number, repoName: string, message: string): void;
|
|
23
24
|
info(message: string): void;
|
|
25
|
+
warn(message: string): void;
|
|
24
26
|
debug(message: string): void;
|
|
25
27
|
success(current: number, repoName: string, message: string): void;
|
|
26
28
|
skip(current: number, repoName: string, reason: string): void;
|
package/dist/shared/logger.js
CHANGED
|
@@ -17,6 +17,9 @@ export class Logger {
|
|
|
17
17
|
info(message) {
|
|
18
18
|
console.log(chalk.gray(` ${message}`));
|
|
19
19
|
}
|
|
20
|
+
warn(message) {
|
|
21
|
+
console.log(chalk.yellow(` ⚠ ${message}`));
|
|
22
|
+
}
|
|
20
23
|
debug(message) {
|
|
21
24
|
if (process.env.DEBUG || process.env.XFG_DEBUG) {
|
|
22
25
|
console.log(chalk.dim(` [debug] ${message}`));
|
|
@@ -8,6 +8,7 @@ import { sanitizeCredentials } from "./sanitize-utils.js";
|
|
|
8
8
|
*/
|
|
9
9
|
export const DEFAULT_PERMANENT_ERROR_PATTERNS = [
|
|
10
10
|
/permission\s*denied/i,
|
|
11
|
+
/not\s*accessible\s*by\s*integration/i,
|
|
11
12
|
/authentication\s*failed/i,
|
|
12
13
|
/bad\s*credentials/i,
|
|
13
14
|
/invalid\s*(token|credentials)/i,
|
package/dist/sync/file-writer.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { convertContentToString } from "../config/formatter.js";
|
|
4
4
|
import { interpolateXfgContent } from "./xfg-template.js";
|
|
5
5
|
import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
|
|
6
|
+
import { hasGitHubAppCredentials } from "../vcs/commit-strategy-selector.js";
|
|
6
7
|
/**
|
|
7
8
|
* Determines if a file should be marked as executable.
|
|
8
9
|
* .sh files are auto-executable unless explicit executable: false is set.
|
|
@@ -92,6 +93,11 @@ export class FileWriter {
|
|
|
92
93
|
continue;
|
|
93
94
|
}
|
|
94
95
|
if (shouldBeExecutable(file)) {
|
|
96
|
+
if (tracked?.action === "create" && hasGitHubAppCredentials()) {
|
|
97
|
+
log.warn(`${file.fileName}: GitHub App commits cannot set executable mode on new files. ` +
|
|
98
|
+
`The file will be created as non-executable (100644). ` +
|
|
99
|
+
`See: https://anthony-spruyt.github.io/xfg/examples/executable-files/`);
|
|
100
|
+
}
|
|
95
101
|
log.info(`Setting executable: ${file.fileName}`);
|
|
96
102
|
await gitOps.setExecutable(file.fileName);
|
|
97
103
|
}
|
|
@@ -114,7 +114,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
async create(options) {
|
|
117
|
-
const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, token, } = options;
|
|
117
|
+
const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, token, labels, } = options;
|
|
118
118
|
if (!isGitHubRepo(repoInfo)) {
|
|
119
119
|
throw new Error("Expected GitHub repository");
|
|
120
120
|
}
|
|
@@ -123,7 +123,13 @@ export class GitHubPRStrategy extends BasePRStrategy {
|
|
|
123
123
|
writeFileSync(bodyFile, body, "utf-8");
|
|
124
124
|
// Token is passed via env var to avoid shell injection
|
|
125
125
|
const tokenEnv = buildTokenEnv(token);
|
|
126
|
-
|
|
126
|
+
let command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
|
|
127
|
+
// Append label flags
|
|
128
|
+
if (labels && labels.length > 0) {
|
|
129
|
+
for (const label of labels) {
|
|
130
|
+
command += ` --label ${escapeShellArg(label)}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
127
133
|
try {
|
|
128
134
|
const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
|
|
129
135
|
// Extract URL from output - use strict regex for valid PR URLs only
|
|
@@ -30,6 +30,13 @@ export declare function validateBranchName(branchName: string): void;
|
|
|
30
30
|
* This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
|
|
31
31
|
*/
|
|
32
32
|
export declare class GraphQLCommitStrategy implements ICommitStrategy {
|
|
33
|
+
/**
|
|
34
|
+
* GraphQL permanent error patterns for ref operations.
|
|
35
|
+
* Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
|
|
36
|
+
* git-CLI-specific patterns (/remote\s*rejected/i) that don't
|
|
37
|
+
* apply to GraphQL responses.
|
|
38
|
+
*/
|
|
39
|
+
private static readonly GRAPHQL_PERMANENT_ERROR_PATTERNS;
|
|
33
40
|
private executor;
|
|
34
41
|
constructor(executor?: ICommandExecutor);
|
|
35
42
|
/**
|
|
@@ -48,6 +55,9 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
|
|
|
48
55
|
* Ensure the branch exists on the remote and matches local HEAD.
|
|
49
56
|
* createCommitOnBranch requires the branch to already exist.
|
|
50
57
|
*
|
|
58
|
+
* Uses GraphQL ref mutations instead of git push to support repos
|
|
59
|
+
* with required_signatures on all branches.
|
|
60
|
+
*
|
|
51
61
|
* For PR branches (force=true): delete existing remote branch and recreate
|
|
52
62
|
* from local HEAD to ensure a fresh start from main.
|
|
53
63
|
*
|
|
@@ -66,4 +76,23 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
|
|
|
66
76
|
* This happens when the branch was updated between getting HEAD and making the commit.
|
|
67
77
|
*/
|
|
68
78
|
private isHeadOidMismatchError;
|
|
79
|
+
/**
|
|
80
|
+
* Execute a GraphQL query or mutation for ref operations.
|
|
81
|
+
* Handles command construction, retry, error sanitization, and response parsing.
|
|
82
|
+
* Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
|
|
83
|
+
*/
|
|
84
|
+
private executeGraphQLRefOp;
|
|
85
|
+
/**
|
|
86
|
+
* Query the remote for a repository's Node ID and a ref's Node ID.
|
|
87
|
+
* Returns repositoryId (always) and refId (null if branch doesn't exist).
|
|
88
|
+
*/
|
|
89
|
+
private queryRemoteRef;
|
|
90
|
+
/**
|
|
91
|
+
* Create a branch ref on the remote via GraphQL createRef mutation.
|
|
92
|
+
*/
|
|
93
|
+
private createRemoteRef;
|
|
94
|
+
/**
|
|
95
|
+
* Delete a branch ref on the remote via GraphQL deleteRef mutation.
|
|
96
|
+
*/
|
|
97
|
+
private deleteRemoteRef;
|
|
69
98
|
}
|
|
@@ -48,6 +48,24 @@ const OID_MISMATCH_PATTERNS = [
|
|
|
48
48
|
* This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
|
|
49
49
|
*/
|
|
50
50
|
export class GraphQLCommitStrategy {
|
|
51
|
+
/**
|
|
52
|
+
* GraphQL permanent error patterns for ref operations.
|
|
53
|
+
* Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
|
|
54
|
+
* git-CLI-specific patterns (/remote\s*rejected/i) that don't
|
|
55
|
+
* apply to GraphQL responses.
|
|
56
|
+
*/
|
|
57
|
+
static GRAPHQL_PERMANENT_ERROR_PATTERNS = [
|
|
58
|
+
/not\s*found/i,
|
|
59
|
+
/unauthorized/i,
|
|
60
|
+
/permission\s*denied/i,
|
|
61
|
+
/not\s*accessible\s*by\s*integration/i,
|
|
62
|
+
/bad\s*credentials/i,
|
|
63
|
+
/invalid\s*(token|credentials)/i,
|
|
64
|
+
/401\b/,
|
|
65
|
+
/403\b/,
|
|
66
|
+
/does\s*not\s*exist/i,
|
|
67
|
+
/could\s*not\s*resolve/i,
|
|
68
|
+
];
|
|
51
69
|
executor;
|
|
52
70
|
constructor(executor) {
|
|
53
71
|
this.executor = executor ?? defaultExecutor;
|
|
@@ -85,7 +103,7 @@ export class GraphQLCommitStrategy {
|
|
|
85
103
|
// Ensure the branch exists on remote and is up-to-date with local HEAD
|
|
86
104
|
// createCommitOnBranch requires the branch to already exist
|
|
87
105
|
// For PR branches (force=true), we force-update to ensure fresh start from main
|
|
88
|
-
await this.ensureBranchExistsOnRemote(branchName, workDir, options.force,
|
|
106
|
+
await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, githubInfo, token);
|
|
89
107
|
// Retry loop for expectedHeadOid mismatch
|
|
90
108
|
let lastError = null;
|
|
91
109
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
@@ -204,47 +222,31 @@ export class GraphQLCommitStrategy {
|
|
|
204
222
|
* Ensure the branch exists on the remote and matches local HEAD.
|
|
205
223
|
* createCommitOnBranch requires the branch to already exist.
|
|
206
224
|
*
|
|
225
|
+
* Uses GraphQL ref mutations instead of git push to support repos
|
|
226
|
+
* with required_signatures on all branches.
|
|
227
|
+
*
|
|
207
228
|
* For PR branches (force=true): delete existing remote branch and recreate
|
|
208
229
|
* from local HEAD to ensure a fresh start from main.
|
|
209
230
|
*
|
|
210
231
|
* For direct mode (force=false): just ensure branch exists.
|
|
211
232
|
*/
|
|
212
|
-
async ensureBranchExistsOnRemote(branchName, workDir, force,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
// Check if the branch exists on remote
|
|
216
|
-
// Use skipRetry because failure is expected for new branches
|
|
217
|
-
if (gitOps) {
|
|
218
|
-
await gitOps.lsRemote(branchName, { skipRetry: true });
|
|
219
|
-
}
|
|
220
|
-
else {
|
|
221
|
-
await this.executor.exec(`git ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, workDir);
|
|
222
|
-
}
|
|
223
|
-
// Branch exists - for PR branches, delete and recreate to ensure fresh from main
|
|
224
|
-
if (force) {
|
|
225
|
-
if (gitOps) {
|
|
226
|
-
await gitOps.pushRefspec(branchName, { delete: true });
|
|
227
|
-
// Now push fresh branch from local HEAD
|
|
228
|
-
await gitOps.pushRefspec(`HEAD:${branchName}`);
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
await this.executor.exec(`git push origin --delete ${escapeShellArg(branchName)}`, workDir);
|
|
232
|
-
// Now push fresh branch from local HEAD
|
|
233
|
-
await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
// For direct mode (force=false), leave existing branch as-is
|
|
233
|
+
async ensureBranchExistsOnRemote(branchName, workDir, force, repoInfo, token) {
|
|
234
|
+
if (!repoInfo) {
|
|
235
|
+
throw new Error("repoInfo is required for GraphQL ref operations");
|
|
237
236
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
237
|
+
const { repositoryId, refId } = await this.queryRemoteRef(repoInfo, branchName, workDir, token);
|
|
238
|
+
if (refId && force) {
|
|
239
|
+
// Branch exists + force: delete then recreate from local HEAD
|
|
240
|
+
await this.deleteRemoteRef(refId, workDir, repoInfo, token);
|
|
241
|
+
const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
|
|
242
|
+
await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
|
|
243
|
+
}
|
|
244
|
+
else if (!refId) {
|
|
245
|
+
// Branch doesn't exist: create from local HEAD
|
|
246
|
+
const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
|
|
247
|
+
await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
|
|
247
248
|
}
|
|
249
|
+
// refId exists + !force: no-op (branch already exists)
|
|
248
250
|
}
|
|
249
251
|
/**
|
|
250
252
|
* Sanitize command execution errors to remove the GraphQL payload.
|
|
@@ -284,4 +286,59 @@ export class GraphQLCommitStrategy {
|
|
|
284
286
|
// GitHub may return this generic error for OID mismatches
|
|
285
287
|
message.includes("was provided invalid value"));
|
|
286
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Execute a GraphQL query or mutation for ref operations.
|
|
291
|
+
* Handles command construction, retry, error sanitization, and response parsing.
|
|
292
|
+
* Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
|
|
293
|
+
*/
|
|
294
|
+
async executeGraphQLRefOp(queryOrMutation, repoInfo, workDir, token) {
|
|
295
|
+
const requestBody = JSON.stringify({ query: queryOrMutation });
|
|
296
|
+
const hostnameArg = repoInfo.host !== "github.com"
|
|
297
|
+
? `--hostname ${escapeShellArg(repoInfo.host)}`
|
|
298
|
+
: "";
|
|
299
|
+
const tokenPrefix = token ? `GH_TOKEN=${token} ` : "";
|
|
300
|
+
const command = `echo ${escapeShellArg(requestBody)} | ${tokenPrefix}gh api graphql ${hostnameArg} --input -`;
|
|
301
|
+
let response;
|
|
302
|
+
try {
|
|
303
|
+
response = await withRetry(() => this.executor.exec(command, workDir), {
|
|
304
|
+
permanentErrorPatterns: GraphQLCommitStrategy.GRAPHQL_PERMANENT_ERROR_PATTERNS,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
throw this.sanitizeCommandError(error, `${repoInfo.owner}/${repoInfo.repo}`);
|
|
309
|
+
}
|
|
310
|
+
const parsed = JSON.parse(response);
|
|
311
|
+
if (parsed.errors) {
|
|
312
|
+
throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
|
|
313
|
+
}
|
|
314
|
+
return parsed;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Query the remote for a repository's Node ID and a ref's Node ID.
|
|
318
|
+
* Returns repositoryId (always) and refId (null if branch doesn't exist).
|
|
319
|
+
*/
|
|
320
|
+
async queryRemoteRef(repoInfo, branchName, workDir, token) {
|
|
321
|
+
const query = `{ repository(owner: ${JSON.stringify(repoInfo.owner)}, name: ${JSON.stringify(repoInfo.repo)}) { id ref(qualifiedName: ${JSON.stringify(`refs/heads/${branchName}`)}) { id } } }`;
|
|
322
|
+
const parsed = await this.executeGraphQLRefOp(query, repoInfo, workDir, token);
|
|
323
|
+
const repo = parsed.data?.repository;
|
|
324
|
+
const repositoryId = repo?.id;
|
|
325
|
+
if (!repositoryId) {
|
|
326
|
+
throw new Error(`GraphQL response missing repository ID for ${repoInfo.owner}/${repoInfo.repo}`);
|
|
327
|
+
}
|
|
328
|
+
return { repositoryId, refId: repo?.ref?.id ?? null };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Create a branch ref on the remote via GraphQL createRef mutation.
|
|
332
|
+
*/
|
|
333
|
+
async createRemoteRef(repositoryId, branchName, oid, workDir, repoInfo, token) {
|
|
334
|
+
const mutation = `mutation { createRef(input: { repositoryId: ${JSON.stringify(repositoryId)}, name: ${JSON.stringify(`refs/heads/${branchName}`)}, oid: ${JSON.stringify(oid)} }) { clientMutationId } }`;
|
|
335
|
+
await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Delete a branch ref on the remote via GraphQL deleteRef mutation.
|
|
339
|
+
*/
|
|
340
|
+
async deleteRemoteRef(refId, workDir, repoInfo, token) {
|
|
341
|
+
const mutation = `mutation { deleteRef(input: { refId: ${JSON.stringify(refId)} }) { clientMutationId } }`;
|
|
342
|
+
await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
|
|
343
|
+
}
|
|
287
344
|
}
|
package/dist/vcs/pr-creator.d.ts
CHANGED
package/dist/vcs/pr-creator.js
CHANGED
|
@@ -97,7 +97,7 @@ export function formatPRTitle(files) {
|
|
|
97
97
|
return `chore: sync ${changedFiles.length} config files`;
|
|
98
98
|
}
|
|
99
99
|
export async function createPR(options) {
|
|
100
|
-
const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, token, } = options;
|
|
100
|
+
const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, token, labels, } = options;
|
|
101
101
|
const title = formatPRTitle(files);
|
|
102
102
|
const body = formatPRBody(files, repoInfo, prTemplate);
|
|
103
103
|
if (dryRun) {
|
|
@@ -117,6 +117,7 @@ export async function createPR(options) {
|
|
|
117
117
|
workDir,
|
|
118
118
|
retries,
|
|
119
119
|
token,
|
|
120
|
+
labels,
|
|
120
121
|
});
|
|
121
122
|
}
|
|
122
123
|
export async function mergePR(options) {
|
package/dist/vcs/types.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface PRStrategyOptions {
|
|
|
25
25
|
retries?: number;
|
|
26
26
|
/** GitHub App installation token for authentication */
|
|
27
27
|
token?: string;
|
|
28
|
+
/** Labels to apply to the created PR */
|
|
29
|
+
labels?: string[];
|
|
28
30
|
}
|
|
29
31
|
export interface MergeOptions {
|
|
30
32
|
prUrl: string;
|
|
@@ -94,7 +96,7 @@ export interface CommitOptions {
|
|
|
94
96
|
force?: boolean;
|
|
95
97
|
/** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
|
|
96
98
|
token?: string;
|
|
97
|
-
/** Authenticated git operations wrapper (used by GraphQLCommitStrategy for
|
|
99
|
+
/** Authenticated git operations wrapper (used by GraphQLCommitStrategy for fetchBranch() during OID mismatch retries) */
|
|
98
100
|
gitOps?: IAuthenticatedGitOps;
|
|
99
101
|
}
|
|
100
102
|
export interface CommitResult {
|
package/package.json
CHANGED