backport
Version:
A CLI tool that automates the process of backporting commits
466 lines • 17.5 kB
JavaScript
/**
* Defines all CLI options via yargs.
*
* Exports:
* - `getOptionsFromCliArgs()` — full parse + transform of process.argv
* - `getRuntimeArguments()` — lightweight pre-parse for logger setup (interactive, logFilePath, ls)
*/
import yargs from 'yargs';
import yargsParser from 'yargs-parser';
import { excludeUndefined } from '../utils/exclude-undefined.js';
import { getPackageVersion } from '../utils/package-version.js';
import { defaultConfigOptions } from './option-schema.js';
// ── Main entry point ────────────────────────────────────────────────
export function getOptionsFromCliArgs(processArgs) {
const rawArgs = parseYargsOptions(processArgs);
return transformCliArgs(rawArgs);
}
// ── Lightweight pre-parse (used before full parse for logger setup) ─
export function getRuntimeArguments(processArgs, optionsFromModule) {
const { nonInteractive, json, logFilePath, ls } = yargsParser(processArgs);
const base = { ...defaultConfigOptions, ...optionsFromModule };
return {
interactive: nonInteractive === undefined && json === undefined
? base.interactive
: !(nonInteractive === true || json === true),
logFilePath,
ls,
};
}
// ── Yargs definition ────────────────────────────────────────────────
/**
* Defines all CLI options and returns the raw parsed result from yargs.
* No renaming or transformation happens here — that's done in `transformCliArgs`.
*/
function parseYargsOptions(processArgs) {
const y = yargs(processArgs);
const yargsInstance = y
.strict()
.parserConfiguration({
'strip-dashed': true,
'strip-aliased': true,
'boolean-negation': false,
})
.usage('$0 [args]')
.wrap(Math.max(100, Math.min(120, y.terminalWidth())))
// ── GitHub & Authentication ──────────────────────────────────────
.option('githubToken', {
alias: ['token', 'accessToken', 'accesstoken'],
description: 'GitHub token',
type: 'string',
})
// ── Source Commit Selection ──────────────────────────────────────
.option('all', {
description: 'List all commits',
alias: 'a',
type: 'boolean',
conflicts: 'author',
})
// ── Target PR Creation ──────────────────────────────────────────
.option('assignee', {
description: 'Add assignees to the target pull request',
alias: 'assign',
type: 'array',
string: true,
conflicts: ['autoAssign'],
})
.option('autoAssign', {
description: 'Auto assign the target pull request to yourself',
type: 'boolean',
conflicts: ['assignees'],
})
.option('autoMerge', {
description: 'Enable auto-merge for created pull requests',
type: 'boolean',
})
.option('autoMergeMethod', {
description: 'Sets auto-merge method when using --auto-merge. Default: merge',
type: 'string',
choices: ['merge', 'rebase', 'squash'],
})
.option('author', {
description: 'Show commits by a specific user',
type: 'string',
conflicts: 'all',
})
// ── Cherry-pick & Conflict Handling ─────────────────────────────
.option('cherryPickRef', {
description: 'Append commit message with "(cherry picked from commit...)',
type: 'boolean',
conflicts: ['noCherryPickRef'],
})
.option('conflictResolution', {
description: 'Conflict resolution strategy. Defaults to "abort"',
type: 'string',
choices: ['abort', 'commit', 'theirs'],
})
// ── Paths & Config ─────────────────────────────────────────────
.option('cwd', {
hidden: true,
description: 'Path to source repo',
type: 'string',
})
.option('projectConfigFile', {
alias: 'config',
description: 'Path to project config',
type: 'string',
})
.option('globalConfigFile', {
description: 'Path to global config',
type: 'string',
})
.option('since', {
description: 'ISO-8601 date for filtering commits',
type: 'string',
coerce: (since) => {
if (since) {
return new Date(since).toISOString();
}
},
})
.option('until', {
description: 'ISO-8601 date for filtering commits',
type: 'string',
coerce: (until) => {
if (until) {
return new Date(until).toISOString();
}
},
})
.option('workdir', {
description: 'Path to temporary backport repo',
alias: 'dir',
type: 'string',
})
.option('verbose', {
description: 'Show details about each commit',
alias: 'details',
type: 'boolean',
})
.option('draft', {
description: 'Publish pull request as draft',
type: 'boolean',
})
.option('dryRun', {
description: 'Run backport locally without pushing to Github',
type: 'boolean',
})
.option('editor', {
description: 'Editor to be opened during conflict resolution',
type: 'string',
})
.option('skipRemoteConfig', {
description: 'Use local .backportrc.json config instead of loading from Github',
type: 'boolean',
})
// push target branch to {repoForkOwner}/{repoName}
.option('fork', {
description: 'Create backports in fork or origin repo. Defaults to true',
type: 'boolean',
conflicts: ['noFork'],
})
.option('gitHostname', {
hidden: true,
description: 'Hostname for Github',
type: 'string',
})
.option('githubApiBaseUrlV3', {
hidden: true,
description: `Base url for Github's REST (v3) API`,
type: 'string',
})
.option('githubApiBaseUrlV4', {
hidden: true,
description: `Base url for Github's GraphQL (v4) API`,
type: 'string',
})
.option('gitAuthorName', {
description: `Set commit author name`,
type: 'string',
})
.option('gitAuthorEmail', {
description: `Set commit author email`,
type: 'string',
})
// ── Output & Mode ──────────────────────────────────────────────
.option('nonInteractive', {
alias: ['json'],
description: 'Disable interactive prompts and return response as JSON',
type: 'boolean',
conflicts: 'interactive',
})
.option('logFilePath', {
hidden: true,
description: `Path to log file`,
type: 'string',
})
.option('ls', {
description: 'List commits instead of backporting them',
type: 'boolean',
})
.option('mainline', {
description: 'Parent id of merge commit. Defaults to 1 when supplied without arguments',
type: 'number',
coerce: (mainline) => {
if (mainline === undefined) {
// return 1 if `--mainline` is given without a value
// return undefined if --mainline is not supplied at all
return processArgs.includes('--mainline') ? 1 : undefined;
}
// use specified mainline parent
if (Number.isInteger(mainline)) {
return mainline;
}
// Invalid value provided
throw new Error(`--mainline must be an integer. Received: ${mainline}`);
},
})
.option('signoff', {
description: 'Pass the --signoff option to the cherry-pick command',
type: 'boolean',
alias: ['s'],
})
// limit number of commits to choose from
.option('maxCount', {
description: 'Number of commits to choose from',
alias: ['n', 'number'],
type: 'number',
})
// cli-only
.option('multiple', {
description: 'Select multiple branches/commits',
type: 'boolean',
conflicts: ['multipleBranches', 'multipleCommits'],
})
// allow picking multiple target branches
.option('multipleBranches', {
description: 'Backport to multiple branches',
type: 'boolean',
conflicts: ['multiple'],
})
// allow picking multiple commits
.option('multipleCommits', {
description: 'Backport multiple commits',
type: 'boolean',
conflicts: ['multiple'],
})
.option('noCherryPickRef', {
description: 'Do not append commit message with "(cherry picked from commit...)"',
type: 'boolean',
conflicts: ['cherryPickRef'],
})
// cli-only
// negation of `publishStatusCommentOnSuccess` and `publishStatusCommentOnFailure`
.option('noStatusComment', {
description: "Don't publish status comment to Github",
type: 'boolean',
})
.option('noVerify', {
description: 'Bypass the pre-commit and commit-msg hooks',
type: 'boolean',
})
.option('noFork', {
description: 'Create backports in the origin repo',
type: 'boolean',
conflicts: ['fork', 'repoForkOwner'],
})
.option('onlyMissing', {
description: 'Only list commits with missing or unmerged backports',
type: 'boolean',
})
.option('path', {
description: 'Only list commits touching files under the specified path',
alias: 'p',
type: 'array',
string: true,
})
.option('prDescription', {
description: 'Description to be added to pull request',
alias: 'description',
type: 'string',
})
.option('prTitle', {
description: 'Title of pull request',
alias: 'title',
type: 'string',
})
.option('prQuery', {
conflicts: ['pullNumber', 'sha', 'path'],
description: `Filter source pull requests by a query`,
alias: ['q'],
type: 'string',
})
.option('pullNumber', {
conflicts: ['sha', 'prQuery'],
description: 'Pull request to backport',
alias: ['pr'],
type: 'number',
})
.option('resetAuthor', {
description: 'Set yourself as commit author',
type: 'boolean',
})
.option('reviewer', {
description: 'Add reviewer to the target PR',
type: 'array',
string: true,
})
.option('repoForkOwner', {
description: 'The owner of the fork where the backport branch is pushed. Defaults to the currently authenticated user',
type: 'string',
conflicts: ['noFork'],
})
.option('repoOwner', {
hidden: true,
description: 'Repository owner',
type: 'string',
conflicts: ['repo'],
})
.option('repoName', {
hidden: true,
description: 'Repository name',
type: 'string',
conflicts: ['repo'],
})
.option('repo', {
description: 'Repo owner and name',
alias: 'upstream',
type: 'string',
conflicts: ['repoName', 'repoOwner'],
})
.option('sha', {
conflicts: ['pullNumber', 'prQuery'],
description: 'Commit sha to backport',
alias: 'commit',
type: 'string',
})
.option('noUnmergedBackportsHelp', {
description: 'Do not list the unmerged backports in PR comment',
type: 'boolean',
})
// ── Branch Selection ────────────────────────────────────────────
.option('sourceBranch', {
description: `Specify a non-default branch (normally "master") to backport from`,
type: 'string',
})
.option('sourcePRLabel', {
description: 'Add labels to the source (original) PR',
alias: 'sourcePrLabel',
type: 'array',
string: true,
})
.option('copySourcePRLabels', {
description: 'Copy labels from source PR to the target PR',
alias: 'copySourcePrLabels',
type: 'boolean',
})
.option('copySourcePRReviewers', {
description: 'Copy reviewers from the source PR to the target PR',
alias: ['copySourcePrReviewers', 'addOriginalReviewers'],
type: 'boolean',
})
.option('targetBranch', {
description: 'Branch(es) to backport to',
alias: ['branch', 'b'],
type: 'array',
string: true, // ensure `6.0` is not coerced to `6`
})
.option('targetBranchChoice', {
description: 'List branches to backport to',
type: 'array',
string: true,
})
.option('targetPRLabel', {
description: 'Add labels to the target (backport) PR',
alias: ['label', 'l'],
type: 'array',
string: true,
})
.option('backportBranchName', {
description: 'Name template to use for the branch name of the backport',
type: 'string',
})
// cli-only
.option('verify', {
description: `Opposite of no-verify`,
type: 'boolean',
})
.version(getPackageVersion())
.alias('version', 'v')
.help()
.exitProcess(!processArgs.includes('--noExitProcess'))
.epilogue('For bugs, feature requests or questions: https://github.com/sorenlouv/backport/issues\nOr contact me directly: https://twitter.com/sorenlouv')
// don't kill process upon error
// and don't log error to console
.fail((msg, err) => {
if (err) {
throw err;
}
throw new Error(msg);
});
return yargsInstance.parseSync();
}
// ── CLI transform ───────────────────────────────────────────────────
/**
* Transforms raw yargs output into the canonical option shape:
* - Resolves negation flags (--no-fork → fork: false)
* - Renames singular → plural arrays (assignee → assignees)
* - Resolves shorthands (--repo owner/name → repoOwner + repoName)
* - Resolves --all → author: null
* - Resolves --multiple → multipleBranches + multipleCommits
* - Resolves --editor false → editor: undefined
* - Strips yargs internal fields ($0, _)
*/
function transformCliArgs(rawArgs) {
const {
/* eslint-disable @typescript-eslint/no-unused-vars */
$0, _,
/* eslint-enable @typescript-eslint/no-unused-vars */
// cli-only flags that get transformed below
multiple, multipleBranches, multipleCommits, all,
// shorthands
repo,
// filters
author,
// negation flags
noCherryPickRef, noFork, noStatusComment, noVerify, verify, nonInteractive,
// singular array types → renamed to plural
assignee, path, reviewer, sourcePRLabel, targetBranch, targetBranchChoice, targetPRLabel, ...restOptions } = rawArgs;
// resolve --repo owner/name → repoOwner + repoName
const [repoOwner, repoName] = repo?.split('/') ?? [
restOptions.repoOwner,
restOptions.repoName,
];
// resolve --editor false → editor: undefined
const editor = restOptions.editor === 'false' ? undefined : restOptions.editor;
return excludeUndefined({
...restOptions,
// repo
repoOwner,
repoName,
// editor
editor,
// --all sets author to null (show all commits)
author: all ? null : author,
// --multiple is a cli-only shorthand for both multipleBranches + multipleCommits
multipleBranches: multiple ?? multipleBranches,
multipleCommits: multiple ?? multipleCommits,
// rename singular array types to plural (matches config option names)
assignees: assignee,
commitPaths: path,
reviewers: reviewer,
sourcePRLabels: sourcePRLabel,
targetBranchChoices: targetBranchChoice,
targetBranches: targetBranch,
targetPRLabels: targetPRLabel,
// negation flags → resolved to their canonical option names
cherryPickRef: noCherryPickRef === true ? false : restOptions.cherryPickRef,
fork: noFork === true ? false : restOptions.fork,
noVerify: verify ?? noVerify,
publishStatusCommentOnSuccess: noStatusComment === true ? false : undefined,
publishStatusCommentOnFailure: noStatusComment === true ? false : undefined,
publishStatusCommentOnAbort: noStatusComment === true ? false : undefined,
interactive: nonInteractive === true ? false : undefined,
});
}
//# sourceMappingURL=cli-args.js.map