diff --git a/action.yaml b/action.yaml index 7db4b056..7ba11cd4 100644 --- a/action.yaml +++ b/action.yaml @@ -12,6 +12,7 @@ inputs: github-repository: description: "Repository to update. The current repository will be used by default" required: false + default: ${{ github.repository }} github-token: description: "Github Personal Access Token with permission to create branches on repo" required: false diff --git a/package-lock.json b/package-lock.json index 1aac7820..b55d8ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "all-contributors-cli": "^6.24.0", "ava": "^5.1.0", "ts-node": "^10.9.1", + "ts-pattern": "^4.0.6", "typescript": "^4.9.3", "xo": "^0.53.1" } @@ -6933,6 +6934,12 @@ } } }, + "node_modules/ts-pattern": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-4.0.6.tgz", + "integrity": "sha512-sFHQYD4KoysBi7e7a2mzDPvRBeqA4w+vEyRE+P5MU9VLq8eEYxgKCgD9RNEAT+itGRWUTYN+hry94GDPLb1/Yw==", + "dev": true + }, "node_modules/tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", @@ -13389,6 +13396,12 @@ "yn": "3.1.1" } }, + "ts-pattern": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-4.0.6.tgz", + "integrity": "sha512-sFHQYD4KoysBi7e7a2mzDPvRBeqA4w+vEyRE+P5MU9VLq8eEYxgKCgD9RNEAT+itGRWUTYN+hry94GDPLb1/Yw==", + "dev": true + }, "tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", diff --git a/package.json b/package.json index 954987ed..f993eda4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "all-contributors-cli": "^6.24.0", "ava": "^5.1.0", "ts-node": "^10.9.1", + "ts-pattern": "^4.0.6", "typescript": "^4.9.3", "xo": "^0.53.1" }, @@ -54,7 +55,6 @@ "ava/no-ignored-test-files": 0, "n/file-extension-in-import": 0, "node/prefer-global/process": 0, - "node/prefer-global/buffer": 0, "import/extensions": 0, "unicorn/prefer-node-protocol": 0 } diff --git a/src/check.ts b/src/check.ts deleted file mode 100644 index c6e6133b..00000000 --- a/src/check.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type Buffer from 'buffer' -import fs from 'fs' -import process from 'process' -import fetch from 'node-fetch' -import * as core from '@actions/core' - -/** - * Checks connection with Maven Central, throws error if unable to connect. - */ -export async function mavenCentral(): Promise { - const response = await fetch('https://repo1.maven.org/maven2/') - - if (!response.ok) { - throw new Error('Unable to connect to Maven Central') - } - - core.info('✓ Connected to Maven Central') -} - -/** - * Reads the Github Token from the `github-token` input. Throws error if the - * input is empty or returns the token in case it is not. - * - * @returns {string} The Github Token read from the `github-token` input. - */ -export function githubToken(): string { - const token: string = core.getInput('github-token') - - if (token === '') { - throw new Error('You need to provide a Github token in the `github-token` input') - } - - core.info('✓ Github Token provided as input') - - return token -} - -const defaultRepoConfLocation = '.github/.scala-steward.conf' - -/** - * Reads the path of the file containing the default Scala Steward configuration. - * - * If the provided file does not exist and is not the default one it will throw an error. - * On the other hand, if it exists it will be returned, otherwise; it will return `undefined`. - * - * @returns {string | undefined} The path indicated in the `repo-config` input, if it - * exists; otherwise, `undefined`. - */ -export function defaultRepoConf(): string | undefined { - const path = core.getInput('repo-config') - - const fileExists = fs.existsSync(path) - - if (!fileExists && path !== defaultRepoConfLocation) { - throw new Error(`Provided default repo conf file (${path}) does not exist`) - } - - if (fileExists) { - core.info(`✓ Default Scala Steward configuration set to: ${path}`) - - return path - } - - return undefined -} - -/** - * Reads a Github repository from the `github-repository` input. Fallback to the - * `GITHUB_REPOSITORY` environment variable. - * - * Throws error if the fallback fails or returns the repository in case it doesn't. - * - * If the `branch` input is set, the selected branch will be added for update instead - * of the default one. - * - * @returns {string} The Github repository read from the `github-repository` input - * or the `GITHUB_REPOSITORY` environment variable. - */ -export function githubRepository(): string { - const repo: string | undefined - = core.getInput('github-repository') || process.env.GITHUB_REPOSITORY - - if (repo === undefined) { - throw new Error( - 'Unable to read Github repository from `github-repository` ' - + 'input or `GITHUB_REPOSITORY` environment variable', - ) - } - - const branches = core.getInput('branches').split(',').filter(Boolean) - - if (branches.length === 1) { - const branch = branches[0] - - core.info(`✓ Github Repository set to: ${repo}. Will update ${branch} branch.`) - - return `- ${repo}:${branch}` - } - - if (branches.length > 1) { - core.info(`✓ Github Repository set to: ${repo}. Will update ${branches.join(', ')} branches.`) - - return branches.map((branch: string) => `- ${repo}:${branch}`).join('\n') - } - - core.info(`✓ Github Repository set to: ${repo}.`) - - return `- ${repo}` -} - -/** - * Reads the path of the file containing the list of repositories to update from the `repos-file` - * input. - * - * If the input isn't provided this function will return `undefined`. - * On the other hand, if it is provided, it will check if the path exists: - * - If the file exists, its contents will be returned. - * - If it doesn't exists, an error will be thrown. - * - * @returns {string | undefined} The contents of the file indicated in `repos-file` input, if is - * defined; otherwise, `undefined`. - */ -export function reposFile(): Buffer | undefined { - const file: string = core.getInput('repos-file') - - if (!file) { - return undefined - } - - if (fs.existsSync(file)) { - core.info(`✓ Using multiple repos file: ${file}`) - - return fs.readFileSync(file) - } - - throw new Error(`The path indicated in \`repos-file\` (${file}) does not exist`) -} - -/** - * Checks that Github App ID and private key are set together, writes the key to a temporary file. - * - * Throws error if only one of the two inputs is set. - * - * @returns {{id: string, keyFile: string} | undefined} App ID and path to the private key file or - * undefined if both inputs are empty. - */ -export function githubAppInfo(): {id: string; keyFile: string} | undefined { - const id: string = core.getInput('github-app-id') - const key: string = core.getInput('github-app-key') - - if (!id && !key) { - return undefined - } - - if (id && key) { - const keyFile = `${fs.mkdtempSync('tmp-')}/github-app-private-key.pem` - fs.writeFileSync(keyFile, key) - - core.info(`✓ Github App ID: ${id}`) - core.info(`✓ Github App private key is written to: ${keyFile}`) - return {id, keyFile} - } - - throw new Error( - '`github-app-id` and `github-app-key` inputs have to be set together. One of them is missing', - ) -} diff --git a/src/coursier.ts b/src/coursier.ts index f211feaf..6015f535 100644 --- a/src/coursier.ts +++ b/src/coursier.ts @@ -5,6 +5,7 @@ import * as core from '@actions/core' import * as tc from '@actions/tool-cache' import * as io from '@actions/io' import * as exec from '@actions/exec' +import {type NonEmptyString} from './types' /** * Install `coursier` and add its executable to the `PATH`. @@ -94,17 +95,16 @@ export async function install(app: string): Promise { * * Refer to [coursier](https://get-coursier.io/docs/cli-launch) for more information. * - * @param {string} org - The application's organization. - * @param {string} app - The application's artifact name. - * @param {string} version - The application's version. - * @param {(string | string[])[]} args - The args to pass to the application launcher. + * @param app - The application's artifact name. + * @param version - The application's version. + * @param args - The args to pass to the application launcher. */ export async function launch( app: string, - version: string, + version: NonEmptyString | undefined, args: Array = [], ): Promise { - const name = version ? `${app}:${version}` : app + const name = version ? `${app}:${version.value}` : app core.startGroup(`Launching ${name}`) diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 00000000..c997d8eb --- /dev/null +++ b/src/files.ts @@ -0,0 +1,14 @@ +/** + * Represent file operations performed by the action + */ +export type Files = { + /** + * Read file contents from the filesystem. + */ + readFileSync: (path: string, encoding: 'utf8') => string; + + /** + * Returns `true` if the provided path exists. + */ + existsSync: (path: string) => boolean; +} diff --git a/src/github.ts b/src/github.ts index 4a72974b..9dde80bf 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,5 +1,6 @@ import {getOctokit} from '@actions/github' import * as core from '@actions/core' +import {nonEmpty, type NonEmptyString} from './types' const emailErrorMessage = 'Unable to find author\'s email. Either ensure that the token\'s Github Account has the email ' @@ -13,11 +14,11 @@ const nameErrorMessage * Returns the login, email and name of the authenticated user using * the provided Github token. * - * @param {string} token - The token whose user data will be extracted. - * @returns {Promise} The login, email and name of token's user. + * @param token - The token whose user data will be extracted. + * @returns The login, email and name of token's user. */ -export async function getAuthUser(token: string): Promise { - const github = getOctokit(token) +export async function getAuthUser(token: NonEmptyString): Promise { + const github = getOctokit(token.value) try { const auth = await github.rest.users.getAuthenticated() @@ -35,21 +36,21 @@ export async function getAuthUser(token: string): Promise { throw new Error('Unable to retrieve user information from Github') } - return login + return nonEmpty(login) }, email() { if (!email) { throw new Error(emailErrorMessage) } - return email + return nonEmpty(email) }, name() { if (!name) { throw new Error(nameErrorMessage) } - return name + return nonEmpty(name) }, } } catch (error: unknown) { @@ -58,15 +59,15 @@ export async function getAuthUser(token: string): Promise { // https://github.community/t/github-actions-bot-email-address/17204/6 // https://api.github.com/users/github-actions%5Bbot%5D return { - login: () => 'github-actions[bot]', - email: () => '41898282+github-actions[bot]@users.noreply.github.com', - name: () => 'github-actions[bot]', + login: () => nonEmpty('github-actions[bot]'), + email: () => nonEmpty('41898282+github-actions[bot]@users.noreply.github.com'), + name: () => nonEmpty('github-actions[bot]'), } } } type AuthUser = { - email: () => string; - login: () => string; - name: () => string; + email: () => NonEmptyString | undefined; + login: () => NonEmptyString | undefined; + name: () => NonEmptyString | undefined; } diff --git a/src/healthcheck.ts b/src/healthcheck.ts new file mode 100644 index 00000000..48e90e93 --- /dev/null +++ b/src/healthcheck.ts @@ -0,0 +1,23 @@ +import {type Logger} from './logger' +import {type HttpClient} from './http' + +export class HealthCheck { + static from(logger: Logger, httpClient: HttpClient) { + return new HealthCheck(logger, httpClient) + } + + constructor(private readonly logger: Logger, private readonly httpClient: HttpClient) {} + + /** + * Checks connection with Maven Central, throws error if unable to connect. + */ + async mavenCentral(): Promise { + const success = await this.httpClient.run('https://repo1.maven.org/maven2/').then(response => response.ok) + + if (!success) { + throw new Error('Unable to connect to Maven Central') + } + + this.logger.info('✓ Connected to Maven Central') + } +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 00000000..c6057163 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,11 @@ +/** + * Represents an HTTP client + */ +export type HttpClient = { + run: (url: string) => Promise; +} + +export type Response = { + ok: boolean; + status: number; +} diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 00000000..4414962e --- /dev/null +++ b/src/input.ts @@ -0,0 +1,196 @@ +import {type Files} from './files' +import {type Logger} from './logger' +import {nonEmpty, type NonEmptyString} from './types' + +/** + * Retrieves (and sanitize) inputs. + */ +export class Input { + static from(inputs: {getInput: (name: string) => string}, files: Files, logger: Logger) { + return new Input(inputs, files, logger) + } + + constructor( + private readonly inputs: {getInput: (name: string) => string}, + private readonly files: Files, + private readonly logger: Logger, + ) {} + + /** + * Returns every input for this action. + */ + all() { + return { + github: { + token: this.githubToken(), + app: this.githubAppInfo(), + apiUrl: nonEmpty(this.inputs.getInput('github-api-url')), + }, + steward: { + defaultConfiguration: this.defaultRepoConf(), + repos: this.reposFile() ?? this.githubRepository(), + cacheTtl: nonEmpty(this.inputs.getInput('cache-ttl')), + version: nonEmpty(this.inputs.getInput('scala-steward-version')), + timeout: nonEmpty(this.inputs.getInput('timeout')), + ignoreOptsFiles: /true/i.test(this.inputs.getInput('ignore-opts-files')), + extraArgs: nonEmpty(this.inputs.getInput('other-args')), + }, + migrations: { + scalafix: nonEmpty(this.inputs.getInput('scalafix-migrations')), + artifacts: nonEmpty(this.inputs.getInput('artifact-migrations')), + }, + commits: { + sign: { + enabled: /true/i.test(this.inputs.getInput('sign-commits')), + key: nonEmpty(this.inputs.getInput('signing-key')), + }, + author: { + email: nonEmpty(this.inputs.getInput('author-email')), + name: nonEmpty(this.inputs.getInput('author-name')), + }, + }, + } + } + + /** + * Reads the Github Token from the `github-token` input. Throws error if the + * input is empty or returns the token in case it is not. + * + * @returns {string} The Github Token read from the `github-token` input. + */ + githubToken(): NonEmptyString { + const token = nonEmpty(this.inputs.getInput('github-token')) + + if (!token) { + throw new Error('You need to provide a Github token in the `github-token` input') + } + + this.logger.info('✓ Github Token provided as input') + + return token + } + + /** + * Reads the path of the file containing the default Scala Steward configuration. + * + * If the provided file does not exist and is not the default one it will throw an error. + * On the other hand, if it exists it will be returned, otherwise; it will return `undefined`. + * + * @returns {string | undefined} The path indicated in the `repo-config` input, if it + * exists; otherwise, `undefined`. + */ + defaultRepoConf(): NonEmptyString | undefined { + const path = nonEmpty(this.inputs.getInput('repo-config')) + + if (!path) { + return undefined + } + + const fileExists = this.files.existsSync(path.value) + + if (!fileExists && path.value !== '.github/.scala-steward.conf') { + throw new Error(`Provided default repo conf file (${path.value}) does not exist`) + } + + if (fileExists) { + this.logger.info(`✓ Default Scala Steward configuration set to: ${path.value}`) + + return path + } + + return undefined + } + + /** + * Returns the GitHub repository set to update. + * + * It reads it from the `github-repository` input. + * + * Throws error if input is empty or missing. + * + * If the `branches` input is set, the selected branches will be added. + * + * @returns {string} The Github repository read from the `github-repository` input. + */ + githubRepository(): string { + const repo = nonEmpty(this.inputs.getInput('github-repository')) + + if (!repo) { + throw new Error('Unable to read Github repository from `github-repository` input') + } + + const branches = this.inputs.getInput('branches').split(',').filter(Boolean) + + if (branches.length === 1) { + const branch = branches[0] + + this.logger.info(`✓ Github Repository set to: ${repo.value}. Will update ${branch} branch.`) + + return `- ${repo.value}:${branch}` + } + + if (branches.length > 1) { + this.logger.info(`✓ Github Repository set to: ${repo.value}. Will update ${branches.join(', ')} branches.`) + + return branches.map((branch: string) => `- ${repo.value}:${branch}`).join('\n') + } + + this.logger.info(`✓ Github Repository set to: ${repo.value}.`) + + return `- ${repo.value}` + } + + /** + * Reads the path of the file containing the list of repositories to update from the `repos-file` + * input. + * + * If the input isn't provided this function will return `undefined`. + * On the other hand, if it is provided, it will check if the path exists: + * - If the file exists, its contents will be returned. + * - If it doesn't exists, an error will be thrown. + * + * @returns {string | undefined} The contents of the file indicated in `repos-file` input, if is + * defined; otherwise, `undefined`. + */ + reposFile(): string | undefined { + const file = nonEmpty(this.inputs.getInput('repos-file')) + + if (!file) { + return undefined + } + + if (this.files.existsSync(file.value)) { + this.logger.info(`✓ Using multiple repos file: ${file.value}`) + + return this.files.readFileSync(file.value, 'utf8') + } + + throw new Error(`The path indicated in \`repos-file\` (${file.value}) does not exist`) + } + + /** + * Checks that Github App ID and private key are set together. + * + * Throws error if only one of the two inputs is set. + * + * @returns {{id: string, key: string} | undefined} App ID and key or undefined if both inputs are empty. + */ + githubAppInfo(): {id: NonEmptyString; key: NonEmptyString} | undefined { + const id = nonEmpty(this.inputs.getInput('github-app-id')) + const key = nonEmpty(this.inputs.getInput('github-app-key')) + + if (!id && !key) { + return undefined + } + + if (id && key) { + this.logger.info(`✓ Github App ID: ${id.value}`) + this.logger.info('✓ Github App private key will be written to the Scala Steward workspace') + return {id, key} + } + + throw new Error( + '`github-app-id` and `github-app-key` inputs have to be set together. One of them is missing', + ) + } +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..611d33fd --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,18 @@ +/** + * Represents the logger used across the action. + */ +export type Logger = { + info(message: string): void; + + debug(message: string): void; + + error(message: string): void; + + warning(message: string): void; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare, @typescript-eslint/naming-convention +export const Logger = { + noOp: {info() {}, debug() {}, error() {}, warning() {}}, // eslint-disable-line @typescript-eslint/no-empty-function + +} diff --git a/src/main.ts b/src/main.ts index 9c876190..a7ce933c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,17 @@ -import {Buffer} from 'buffer' import process from 'process' +import fs from 'fs' import * as core from '@actions/core' +import fetch from 'node-fetch' import * as github from './github' -import * as check from './check' +import {HealthCheck} from './healthcheck' import * as workspace from './workspace' import * as coursier from './coursier' +import {type Logger} from './logger' +import {Input} from './input' +import {type HttpClient} from './http' import * as mill from './mill' +import {type Files} from './files' +import {nonEmpty, NonEmptyString} from './types' /** * Runs the action main code. In order it will do the following: @@ -18,85 +24,52 @@ import * as mill from './mill' */ async function run(): Promise { try { - await check.mavenCentral() - await coursier.selfInstall() - const token = check.githubToken() - const user = await github.getAuthUser(token) - - const authorEmail = core.getInput('author-email') || user.email() - const authorName = core.getInput('author-name') || user.name() - - const githubAppInfo = check.githubAppInfo() - - const defaultRepoConfPath = check.defaultRepoConf() + const logger: Logger = core + const httpClient: HttpClient = {run: async url => fetch(url)} + const files: Files = fs + const inputs = Input.from(core, files, logger).all() + const healthCheck: HealthCheck = HealthCheck.from(logger, httpClient) - // Content of the repos.md file either comes from the input file - // or is empty (replaced by the Github App info) or is a single repo - const reposList - = check.reposFile() - ?? (githubAppInfo ? Buffer.from('') : Buffer.from(check.githubRepository())) + await healthCheck.mavenCentral() - const workspaceDir = await workspace.prepare(reposList, token) + await coursier.selfInstall() + await coursier.install('scalafmt') + await coursier.install('scalafix') + await mill.install() - const cacheTtl = core.getInput('cache-ttl') + const user = await github.getAuthUser(inputs.github.token) + const workspaceDir = await workspace.prepare(inputs.steward.repos, inputs.github.token, inputs.github.app?.key) await workspace.restoreWorkspaceCache(workspaceDir) - const timeout = core.getInput('timeout') - - const version = core.getInput('scala-steward-version') - - const signCommits = /true/i.test(core.getInput('sign-commits')) - const signingKey = core.getInput('signing-key') - const ignoreOptionsFiles = /true/i.test(core.getInput('ignore-opts-files')) - const githubApiUrl = core.getInput('github-api-url') - const scalafixMigrations = core.getInput('scalafix-migrations') - ? ['--scalafix-migrations', core.getInput('scalafix-migrations')] - : [] - const artifactMigrations = core.getInput('artifact-migrations') - ? ['--artifact-migrations', core.getInput('artifact-migrations')] - : [] - const defaultRepoConf = defaultRepoConfPath ? ['--repo-config', defaultRepoConfPath] : [] - - const githubAppArgs = githubAppInfo - ? ['--github-app-id', githubAppInfo.id, '--github-app-key-file', githubAppInfo.keyFile] - : [] - if (process.env.RUNNER_DEBUG) { core.debug('Debug mode activated for Scala Steward') core.exportVariable('LOG_LEVEL', 'TRACE') core.exportVariable('ROOT_LOG_LEVEL', 'TRACE') } - const otherArgs = core.getInput('other-args') - ? core.getInput('other-args').split(' ') - : [] - - await coursier.install('scalafmt') - await coursier.install('scalafix') - await mill.install() - - await coursier.launch('scala-steward', version, [ - ['--workspace', `${workspaceDir}/workspace`], - ['--repos-file', `${workspaceDir}/repos.md`], - ['--git-ask-pass', `${workspaceDir}/askpass.sh`], - ['--git-author-email', `${authorEmail}"`], - ['--git-author-name', `${authorName}"`], - ['--vcs-login', `${user.login()}"`], - ['--env-var', '"SBT_OPTS=-Xmx2048m -Xss8m -XX:MaxMetaspaceSize=512m"'], - ['--process-timeout', timeout], - ['--vcs-api-host', githubApiUrl], - ignoreOptionsFiles ? '--ignore-opts-files' : [], - signCommits ? '--sign-commits' : [], - signingKey ? ['--git-author-signing-key', signingKey] : [], - ['--cache-ttl', cacheTtl], - scalafixMigrations, - artifactMigrations, - defaultRepoConf, + await coursier.launch('scala-steward', inputs.steward.version, [ + arg('--workspace', nonEmpty(`${workspaceDir}/workspace`)), + arg('--repos-file', nonEmpty(`${workspaceDir}/repos.md`)), + arg('--git-ask-pass', nonEmpty(`${workspaceDir}/askpass.sh`)), + arg('--git-author-email', inputs.commits.author.email ?? user.email()), + arg('--git-author-name', inputs.commits.author.name ?? user.name()), + arg('--vcs-login', user.login()), + arg('--env-var', nonEmpty('"SBT_OPTS=-Xmx2048m -Xss8m -XX:MaxMetaspaceSize=512m"')), + arg('--process-timeout', inputs.steward.timeout), + arg('--vcs-api-host', inputs.github.apiUrl), + arg('--ignore-opts-files', inputs.steward.ignoreOptsFiles), + arg('--sign-commits', inputs.commits.sign.enabled), + arg('--git-author-signing-key', inputs.commits.sign.key), + arg('--cache-ttl', inputs.steward.cacheTtl), + arg('--scalafix-migrations', inputs.migrations.scalafix), + arg('--artifact-migrations', inputs.migrations.artifacts), + arg('--repo-config', inputs.steward.defaultConfiguration), + arg('--github-app-id', inputs.github.app?.id), + arg('--github-app-key-file', inputs.github.app ? nonEmpty(`${workspaceDir}/app.pem`) : undefined), '--do-not-fork', '--disable-sandbox', - githubAppArgs, - otherArgs, + inputs.steward.extraArgs ? inputs.steward.extraArgs.value.split(' ') : [], ]).finally(() => { workspace.saveWorkspaceCache(workspaceDir).catch((error: unknown) => { core.setFailed(` ✕ ${(error as Error).message}`) @@ -107,5 +80,24 @@ async function run(): Promise { } } +/** + * Creates an optional argument depending on an input's value. + * + * @param name Name of the arg being added. + * @param value The argument's value, empty string, false booleans or undefined will be skipped. + * @returns the argument to add if it should be added; otherwise returns `[]`. + */ +function arg(name: string, value: NonEmptyString | boolean | undefined) { + if (value instanceof NonEmptyString) { + return [name, value.value] + } + + if (value === undefined) { + return [] + } + + return value ? [name] : [] +} + // eslint-disable-next-line unicorn/prefer-top-level-await void run() diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..d3203709 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ +export class NonEmptyString { + static from(string: string): NonEmptyString | undefined { + return (string === '') ? undefined : new NonEmptyString(string) + } + + private constructor(readonly value: string) {} +} + +export function nonEmpty(string: string): NonEmptyString | undefined { + return NonEmptyString.from(string) +} diff --git a/src/workspace.ts b/src/workspace.ts index 7e94582c..bd4a1970 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -1,4 +1,3 @@ -import type Buffer from 'buffer' import fs from 'fs' import os from 'os' import path from 'path' @@ -7,6 +6,7 @@ import * as core from '@actions/core' import * as io from '@actions/io' import * as exec from '@actions/exec' import jsSHA from 'jssha/dist/sha256' +import {type NonEmptyString} from './types' /** * Gets the first eight characters of the SHA-256 hash value for the @@ -91,18 +91,25 @@ export async function saveWorkspaceCache(workspace: string): Promise { * - Creating a `askpass.sh` file inside workspace containing the Github token. * - Making the previous file executable. * - * @param {Buffer} reposList - The Markdown list of repositories to write to the `repos.md` file. + * @param {string} reposList - The Markdown list of repositories to write to the `repos.md` file. It is only used if no + * GitHub App key is provided on `gitHubAppKey` parameter. * @param {string} token - The Github Token used to authenticate into Github. + * @param {string | undefined} gitHubAppKey - The Github App private key (optional). * @returns {string} The workspace directory path */ -export async function prepare(reposList: Buffer, token: string): Promise { +export async function prepare(reposList: string, token: NonEmptyString, gitHubAppKey: NonEmptyString | undefined): Promise { try { const stewarddir = `${os.homedir()}/scala-steward` await io.mkdirP(stewarddir) - fs.writeFileSync(`${stewarddir}/repos.md`, reposList) + if (gitHubAppKey === undefined) { + fs.writeFileSync(`${stewarddir}/repos.md`, reposList) + } else { + fs.writeFileSync(`${stewarddir}/repos.md`, '') + fs.writeFileSync(`${stewarddir}/app.pem`, gitHubAppKey.value) + } - fs.writeFileSync(`${stewarddir}/askpass.sh`, `#!/bin/sh\n\necho '${token}'`) + fs.writeFileSync(`${stewarddir}/askpass.sh`, `#!/bin/sh\n\necho '${token.value}'`) await exec.exec('chmod', ['+x', `${stewarddir}/askpass.sh`], {silent: true}) core.info('✓ Scala Steward workspace created') diff --git a/tests/check.test.ts b/tests/check.test.ts deleted file mode 100644 index 467023a1..00000000 --- a/tests/check.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import fs from 'fs' -import process from 'process' -import test from 'ava' -import * as check from '../src/check' - -test.beforeEach(() => { - process.env['INPUT_REPOS-FILE'] = '' - process.env.GITHUB_REPOSITORY = '' - process.env.INPUT_BRANCHES = '' - process.env['INPUT_GITHUB-REPOSITORY'] = '' - process.env['INPUT_REPO-CONFIG'] = '' -}) - -test.serial('`check.reposFile()` should return undefined on missing input', t => { - const file = check.reposFile() - t.is(file, undefined) -}) - -test.serial('`check.reposFile()` should return undefined on empty input', t => { - process.env['INPUT_REPOS-FILE'] = '' - const file = check.reposFile() - t.is(file, undefined) -}) - -test.serial('`check.reposFile()` should return contents if file exists', t => { - process.env['INPUT_REPOS-FILE'] = 'tests/resources/repos.test.md' - const file = check.reposFile() ?? '' - - const expected = '- owner1/repo1\n- owner1/repo2\n- owner2/repo' - - t.is(file.toString(), expected) -}) - -test.serial('`check.reposFile()` should throw error if file doesn\'t exists', t => { - process.env['INPUT_REPOS-FILE'] = 'this/does/not/exist.md' - - const expected = 'The path indicated in `repos-file` (this/does/not/exist.md) does not exist' - - const error = t.throws(() => check.reposFile(), {instanceOf: Error}) - - t.is(error?.message, expected) -}) - -test.serial('`check.githubRepository()` should return current repository if input not present', t => { - process.env.GITHUB_REPOSITORY = 'owner/repo' - - const content = check.githubRepository() - - const expected = '- owner/repo' - - t.is(content, expected) -}) - -test.serial('`check.githubRepository()` should return current repository if input not present with custom branch', t => { - process.env.GITHUB_REPOSITORY = 'owner/repo' - process.env.INPUT_BRANCHES = '0.1.x' - - const content = check.githubRepository() - - const expected = '- owner/repo:0.1.x' - - t.is(content, expected) -}) - -test.serial('`check.githubRepository()` should return current repository if input not present with multiple custom branches', t => { - process.env.GITHUB_REPOSITORY = 'owner/repo' - process.env.INPUT_BRANCHES = 'main,0.1.x,0.2.x' - - const content = check.githubRepository() - - const expected = '- owner/repo:main\n- owner/repo:0.1.x\n- owner/repo:0.2.x' - - t.is(content, expected) -}) - -test.serial('`check.githubRepository()` should return repository from input', t => { - process.env['INPUT_GITHUB-REPOSITORY'] = 'owner/repo' - - const content = check.githubRepository() - - const expected = '- owner/repo' - - t.is(content, expected) -}) - -test.serial('`check.githubRepository()` should return repository from input with custom branch', t => { - process.env['INPUT_GITHUB-REPOSITORY'] = 'owner/repo' - process.env.INPUT_BRANCHES = '0.1.x' - - const content = check.githubRepository() - - const expected = '- owner/repo:0.1.x' - - t.is(content, expected) -}) - -test.serial('`check.githubRepository()` should return repository from input with multiple custom branches', t => { - process.env['INPUT_GITHUB-REPOSITORY'] = 'owner/repo' - process.env.INPUT_BRANCHES = 'main,0.1.x,0.2.x' - - const content = check.githubRepository() - - const expected = '- owner/repo:main\n- owner/repo:0.1.x\n- owner/repo:0.2.x' - - t.is(content, expected) -}) - -test.serial('`check.defaultRepoConf()` should return the path if it exists', t => { - process.env['INPUT_REPO-CONFIG'] = 'tests/resources/.scala-steward.conf' - - const path = check.defaultRepoConf() - - const expected = 'tests/resources/.scala-steward.conf' - - t.is(path, expected) -}) - -test.serial('`check.defaultRepoConf()` should return the default path if it exists', t => { - fs.writeFileSync('.github/.scala-steward.conf', '') - process.env['INPUT_REPO-CONFIG'] = '.github/.scala-steward.conf' - - const path = check.defaultRepoConf() - - const expected = '.github/.scala-steward.conf' - - t.is(path, expected) - - fs.rmSync('.github/.scala-steward.conf') -}) - -test.serial('`check.defaultRepoConf()` should return undefined if the default path do not exist', t => { - process.env['INPUT_REPO-CONFIG'] = '.github/.scala-steward.conf' - - const path = check.defaultRepoConf() - - t.is(path, undefined) -}) - -test.serial('`check.defaultRepoConf()` throws error if provided non-default file do not exist', t => { - process.env['INPUT_REPO-CONFIG'] = 'tests/resources/.scala-steward-new.conf' - - const expected = 'Provided default repo conf file (tests/resources/.scala-steward-new.conf) does not exist' - - const error = t.throws(() => check.defaultRepoConf(), {instanceOf: Error}) - - t.is(error?.message, expected) -}) diff --git a/tests/input.test.ts b/tests/input.test.ts new file mode 100644 index 00000000..b9c38168 --- /dev/null +++ b/tests/input.test.ts @@ -0,0 +1,320 @@ +import {fail} from 'assert' +import test from 'ava' +import {match} from 'ts-pattern' +import {type Files} from '../src/files' +import {Input} from '../src/input' +import {Logger} from '../src/logger' +import {nonEmpty} from '../src/types' + +test('`Input.all` should return all inputs', t => { + const inputs = (name: string) => match(name) + .with('github-token', () => '123') + .with('repo-config', () => '.github/defaults/.scala-steward.conf') + .with('github-repository', () => 'owner/repo') + .with('branches', () => '1.0x,2.0x') + .with('author-email', () => 'alex@example.com') + .with('author-name', () => 'Alex') + .with('github-api-url', () => 'github.my-org.com') + .with('cache-ttl', () => '20m') + .with('timeout', () => '60s') + .with('scala-steward-version', () => '1.0') + .with('ignore-opts-files', () => 'true') + .with('artifact-migrations', () => '.github/artifact-migrations.conf') + .with('scalafix-migrations', () => '.github/scalafix-migrations.conf') + .with('other-args', () => '--help') + .with('sign-commits', () => 'true') + .with('signing-key', () => '42') + .otherwise(() => '') + + const files: Files = { + existsSync: name => name === '.github/defaults/.scala-steward.conf', + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const expected = { + github: { + token: nonEmpty('123'), + app: undefined, + apiUrl: nonEmpty('github.my-org.com'), + }, + steward: { + defaultConfiguration: nonEmpty('.github/defaults/.scala-steward.conf'), + repos: '- owner/repo:1.0x\n- owner/repo:2.0x', + cacheTtl: nonEmpty('20m'), + version: nonEmpty('1.0'), + timeout: nonEmpty('60s'), + ignoreOptsFiles: true, + extraArgs: nonEmpty('--help'), + }, + migrations: { + scalafix: nonEmpty('.github/scalafix-migrations.conf'), + artifacts: nonEmpty('.github/artifact-migrations.conf'), + }, + commits: { + sign: { + enabled: true, + key: nonEmpty('42'), + }, + author: { + email: nonEmpty('alex@example.com'), + name: nonEmpty('Alex'), + }, + }, + } + + t.deepEqual(input.all(), expected) +}) + +test('`Input.githubAppInfo()` should return GitHub App info', t => { + const inputs = (name: string) => match(name) + .with('github-app-id', () => '123') + .with('github-app-key', () => '42') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const file = input.githubAppInfo() + + t.deepEqual(file, {id: nonEmpty('123'), key: nonEmpty('42')}) +}) + +test('`Input.githubAppInfo()` should return undefined on missing inputs', t => { + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: () => ''}, files, Logger.noOp) + + const file = input.githubAppInfo() + + t.is(file, undefined) +}) + +test('`Input.githubAppInfo()` should return error if only id input present', t => { + const inputs = (name: string) => match(name) + .with('github-app-id', () => '123') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const expected = '`github-app-id` and `github-app-key` inputs have to be set together. One of them is missing' + + const error = t.throws(() => input.githubAppInfo(), {instanceOf: Error}) + + t.is(error?.message, expected) +}) + +test('`Input.githubAppInfo()` should return error if only key input present', t => { + const inputs = (name: string) => match(name) + .with('github-app-key', () => '42') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const expected = '`github-app-id` and `github-app-key` inputs have to be set together. One of them is missing' + + const error = t.throws(() => input.githubAppInfo(), {instanceOf: Error}) + + t.is(error?.message, expected) +}) + +test('`Input.reposFile()` should return undefined on missing input', t => { + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: () => ''}, files, Logger.noOp) + + const file = input.reposFile() + t.is(file, undefined) +}) + +test('`Input.reposFile()` should return contents if file exists', t => { + const inputs = (name: string) => match(name) + .with('repos-file', () => 'repos.md') + .otherwise(() => '') + + const contents = '- owner1/repo1\n- owner1/repo2\n- owner2/repo' + + const files: Files = { + existsSync: name => match(name).with('repos.md', () => true).run(), + readFileSync: name => match(name).with('repos.md', () => contents).run(), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const file = input.reposFile() ?? '' + + t.is(file.toString(), contents) +}) + +test('`Input.reposFile()` should throw error if file doesn\'t exists', t => { + const inputs = (name: string) => match(name) + .with('repos-file', () => 'this/does/not/exist.md') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const expected = 'The path indicated in `repos-file` (this/does/not/exist.md) does not exist' + + const error = t.throws(() => input.reposFile(), {instanceOf: Error}) + + t.is(error?.message, expected) +}) + +test('`Input.githubRepository()` should return repository from input', t => { + const inputs = (name: string) => match(name) + .with('github-repository', () => 'owner/repo') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const content = input.githubRepository() + + const expected = '- owner/repo' + + t.is(content, expected) +}) + +test('`Input.githubRepository()` should return repository from input with custom branch', t => { + const inputs = (name: string) => match(name) + .with('github-repository', () => 'owner/repo') + .with('branches', () => '0.1.x') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const content = input.githubRepository() + + const expected = '- owner/repo:0.1.x' + + t.is(content, expected) +}) + +test('`Input.githubRepository()` should return repository from input with multiple custom branches', t => { + const inputs = (name: string) => match(name) + .with('github-repository', () => 'owner/repo') + .with('branches', () => 'main,0.1.x,0.2.x') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const content = input.githubRepository() + + const expected = '- owner/repo:main\n- owner/repo:0.1.x\n- owner/repo:0.2.x' + + t.is(content, expected) +}) + +test('`Input.defaultRepoConf()` should return the path if it exists', t => { + const inputs = (name: string) => match(name) + .with('repo-config', () => '.scala-steward.conf') + .otherwise(() => '') + + const files: Files = { + existsSync: name => match(name).with('.scala-steward.conf', () => true).run(), + readFileSync: () => fail('This should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const path = input.defaultRepoConf() + + const expected = '.scala-steward.conf' + + t.is(path?.value, expected) +}) + +test('`Input.defaultRepoConf()` should return the default path if it exists', t => { + const inputs = (name: string) => match(name) + .with('repo-config', () => '.github/.scala-steward.conf') + .otherwise(() => '') + + const files: Files = { + existsSync: name => match(name).with('.github/.scala-steward.conf', () => true).run(), + readFileSync: () => fail('This should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const path = input.defaultRepoConf() + + const expected = '.github/.scala-steward.conf' + + t.is(path?.value, expected) +}) + +test('`Input.defaultRepoConf()` should return undefined if the default path do not exist', t => { + const inputs = (name: string) => match(name) + .with('repo-config', () => '.github/.scala-steward.conf') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const path = input.defaultRepoConf() + + t.is(path, undefined) +}) + +test('`Input.defaultRepoConf()` throws error if provided non-default file do not exist', t => { + const inputs = (name: string) => match(name) + .with('repo-config', () => 'tests/resources/.scala-steward-new.conf') + .otherwise(() => '') + + const files: Files = { + existsSync: () => false, + readFileSync: () => fail('Should not be called'), + } + + const input = Input.from({getInput: inputs}, files, Logger.noOp) + + const expected = 'Provided default repo conf file (tests/resources/.scala-steward-new.conf) does not exist' + + const error = t.throws(() => input.defaultRepoConf(), {instanceOf: Error}) + + t.is(error?.message, expected) +}) diff --git a/tests/resources/.scala-steward.conf b/tests/resources/.scala-steward.conf deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/resources/repos.test.md b/tests/resources/repos.test.md deleted file mode 100644 index 930ec395..00000000 --- a/tests/resources/repos.test.md +++ /dev/null @@ -1,3 +0,0 @@ -- owner1/repo1 -- owner1/repo2 -- owner2/repo \ No newline at end of file