backport
Version:
A CLI tool that automates the process of backporting commits
211 lines • 8.65 kB
JavaScript
/**
* Merges config from defaults, config files, GitHub remote config, and CLI args
* into a fully validated `ValidConfigOptions`.
*
* Precedence order (highest wins):
* 1. defaults (from Zod schema)
* 2. global config file (~/.backport/config.json)
* 3. project config file (.backportrc.json)
* 4. module options (programmatic API)
* 5. remote GitHub config (.backportrc.json on default branch)
* 6. CLI args (highest precedence)
*/
import chalk from 'chalk';
import { ZodError } from 'zod';
import { BackportError } from '../lib/backport-error.js';
import { getGlobalConfigPath } from '../lib/env.js';
import { getRepoOwnerAndNameFromGitRemotes } from '../lib/github/v4/get-repo-owner-and-name-from-git-remotes.js';
import { getOptionsFromGithub } from '../lib/github/v4/getOptionsFromGithub/get-options-from-github.js';
import { setGithubToken } from '../lib/logger.js';
import { getOptionsFromConfigFiles } from './config/config.js';
import { normalizeDeprecatedOptions } from './config/read-config-file.js';
import { defaultConfigOptions, validOptionsSchema, GLOBAL_CONFIG_DOCS_LINK, PROJECT_CONFIG_DOCS_LINK, } from './option-schema.js';
export { defaultConfigOptions } from './option-schema.js';
export async function getOptions({ optionsFromCliArgs, optionsFromModule, }) {
// ── Step 1: load config files ─────────────────────────────────────
const { globalConfig, projectConfig } = await getOptionsFromConfigFiles({
optionsFromCliArgs,
optionsFromModule,
});
// ── Step 2: merge to resolve github token + repo ──────────────────
// Normalize all legacy options (e.g. accessToken -> githubToken, maxNumber -> maxCount)
// from every source so that downstream code only sees canonical names.
//
// First partial merge resolves just enough (global + project + module + CLI) to obtain
// the github token, repo owner/name needed for the GitHub API call.
const normalizedModuleOptions = normalizeDeprecatedOptions(optionsFromModule);
// Apply layers in precedence order (lowest → highest) to determine
// the github token, repo owner/name needed for the GitHub API call.
const combined = {
...defaultConfigOptions,
...globalConfig,
...projectConfig,
...normalizedModuleOptions,
...optionsFromCliArgs,
};
const { githubToken, repoName, repoOwner } = await resolveRequiredOptions(combined);
// update logger
setGithubToken(githubToken);
// ── Step 3: fetch options from GitHub ──────────────────────────────
const optionsFromGithub = await getOptionsFromGithub({
...combined,
githubToken,
repoName,
repoOwner,
});
// ── Step 4: final merge with full precedence ──────────────────────
const merged = mergeOptions({
defaultConfigOptions,
globalConfig,
projectConfig,
optionsFromModule: normalizedModuleOptions,
optionsFromGithub,
optionsFromCliArgs,
githubToken,
repoName,
repoOwner,
});
// ── Step 5: reject empty strings before Zod parse ─────────────────
throwForEmptyStringOptions(merged);
// ── Step 6: validate via Zod ──────────────────────────────────────
try {
return validOptionsSchema.parse(merged);
}
catch (error) {
if (error instanceof ZodError) {
throw new BackportError({
code: 'config-error-exception',
message: error.issues.map((i) => i.message).join('\n'),
});
}
throw error;
}
}
/**
* Merges all option layers into a single object with explicit precedence.
* Each subsequent spread wins over previous ones.
*/
function mergeOptions({ defaultConfigOptions, globalConfig, projectConfig, optionsFromModule, optionsFromGithub, optionsFromCliArgs, githubToken, repoName, repoOwner, }) {
return {
// defaults for author and repoForkOwner come from the authenticated user
author: optionsFromGithub.authenticatedUsername,
repoForkOwner: optionsFromGithub.authenticatedUsername,
// 1. schema defaults (lowest precedence)
...defaultConfigOptions,
// 2. global config (~/.backport/config.json)
...globalConfig,
// 3. project config (.backportrc.json)
...projectConfig,
// 4. module options (programmatic API)
...optionsFromModule,
// 5. remote GitHub config
...optionsFromGithub,
// 6. CLI args (highest precedence)
...optionsFromCliArgs,
// required properties (always set regardless of precedence)
githubToken,
repoName,
repoOwner,
};
}
/**
* Resolves github token, repo owner, and repo name — the minimum required
* options that must be available before we can call the GitHub API.
*/
async function resolveRequiredOptions(combined) {
const { githubToken, repoName, repoOwner, globalConfigFile } = combined;
if (githubToken && repoName && repoOwner) {
return { githubToken, repoName, repoOwner };
}
// require github token
if (!githubToken) {
const globalConfigPath = getGlobalConfigPath(globalConfigFile);
throw new BackportError({
code: 'invalid-credentials-exception',
message: `Please update your config file: "${globalConfigPath}".\nIt must contain a valid "githubToken".\n\nRead more: ${GLOBAL_CONFIG_DOCS_LINK}`,
});
}
// attempt to retrieve repo-owner and repo-name from git remote
const gitRemote = await getRepoOwnerAndNameFromGitRemotes({
cwd: combined.cwd,
githubApiBaseUrlV4: combined.githubApiBaseUrlV4,
githubToken,
});
if (!gitRemote.repoName || !gitRemote.repoOwner) {
throw new BackportError({
code: 'config-error-exception',
message: `Please specify a repository: "--repo elastic/kibana".\n\nRead more: ${PROJECT_CONFIG_DOCS_LINK}`,
});
}
return {
githubToken,
repoName: gitRemote.repoName,
repoOwner: gitRemote.repoOwner,
};
}
// ── Empty string validation ─────────────────────────────────────────
// Disallow empty strings for options that should be undefined instead.
// This is primarily an issue in Github Actions where inputs default to empty
// strings instead of undefined — failing early provides a better UX.
const DISALLOW_EMPTY_STRING_OPTIONS = [
'githubToken',
'author',
'autoMergeMethod',
'backportBinary',
'backportBranchName',
'workdir',
'editor',
'gitHostname',
'githubApiBaseUrlV3',
'githubApiBaseUrlV4',
'logFilePath',
'prDescription',
'projectConfigFile',
'prTitle',
'repoForkOwner',
'repoName',
'repoOwner',
'sha',
'sourceBranch',
];
function throwForEmptyStringOptions(options) {
for (const optionName of DISALLOW_EMPTY_STRING_OPTIONS) {
if (options[optionName] === '') {
throw new BackportError({
code: 'config-error-exception',
message: `"${optionName}" cannot be empty!`,
});
}
}
}
export function getActiveOptionsFormatted(options) {
const customOptions = [
['repo', `${options.repoOwner}/${options.repoName}`],
['sourceBranch', `${options.sourceBranch}`],
];
if (options.pullNumber) {
customOptions.push(['pr', `${options.pullNumber}`]);
}
if (options.sha) {
customOptions.push(['sha', `${options.sha}`]);
}
if (options.author) {
customOptions.push(['author', `${options.author}`]);
}
if (options.autoMerge === true) {
customOptions.push(['autoMerge', `${options.autoMerge}`]);
}
if (options.maxCount !== defaultConfigOptions.maxCount) {
customOptions.push(['maxCount', `${options.maxCount}`]);
}
if (options.since) {
customOptions.push(['since', `${options.since}`]);
}
if (options.until) {
customOptions.push(['until', `${options.until}`]);
}
return (customOptions
.map(([key, value]) => `${key}: ${chalk.bold(value)}`)
.join(' | ') + `\n`);
}
//# sourceMappingURL=options.js.map