diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2095e9c5..4f6e4de3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,9 @@ -on: [push] +name: RHDA Github Action Workflow +on: + pull_request_target: + types: [assigned, opened, synchronize, reopened, edited] + jobs: rhda_job: runs-on: ubuntu-latest diff --git a/package.json b/package.json index df39bca7..bfccccaa 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@actions/artifact": "^2.1.4", "@actions/core": "^1.10.1", + "@actions/exec": "^1.1.1", "@actions/github": "^6.0.0", "@redhat-actions/action-io-generator": "^1.5.0", "@rhecosystemappeng/exhort-javascript-api": "^0.1.1-ea.26", diff --git a/src/exhortServices.ts b/src/exhortServices.ts index 6c4eb71a..0819aae6 100644 --- a/src/exhortServices.ts +++ b/src/exhortServices.ts @@ -16,44 +16,4 @@ async function stackAnalysisService(pathToManifest, options): Promise { - try { - - // Get token validation status code - const tokenValidationStatus = await exhort.validateToken(options); - - if ( - tokenValidationStatus === 200 - ) { - return; - } else if ( - tokenValidationStatus === 400 - ) { - return `Missing token. Please provide a valid ${source} Token in the extension workspace settings. Status: ${tokenValidationStatus}`; - } else if ( - tokenValidationStatus === 401 - ) { - return `Invalid token. Please provide a valid ${source} Token in the extension workspace settings. Status: ${tokenValidationStatus}`; - } else if ( - tokenValidationStatus === 403 - ) { - return `Forbidden. The token does not have permissions. Please provide a valid ${source} Token in the extension workspace settings. Status: ${tokenValidationStatus}`; - } else if ( - tokenValidationStatus === 429 - ) { - return `Too many requests. Rate limit exceeded. Please try again in a little while. Status: ${tokenValidationStatus}`; - } else { - return `Failed to validate token. Status: ${tokenValidationStatus}`; - } - } catch (error) { - return `Failed to validate token, Error: ${error.message}`; - } -} - -export { stackAnalysisService, tokenValidationService }; \ No newline at end of file +export { stackAnalysisService }; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8d1e4f08..c9478ffa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,47 @@ import * as ghCore from '@actions/core'; -import * as github from '@actions/github'; import path from 'path'; -import * as utils from './utils/utils.js' +import * as utils from './utils.js' import { resolveManifestFilePath } from './manifestHandler.js'; import { generateRHDAReport } from './rhda.js'; import { Inputs, Outputs } from './generated/inputs-outputs.js'; import { generateArtifacts } from './artifactHandler.js'; import { handleSarif } from './sarif/handler.js'; +import { handlePr } from './pr/handler.js'; +import { getOriginalCheckoutBranch, checkoutCleanup } from './pr/checkout.js' +import { PrData } from './pr/types.js' +import { RhdaLabels, addLabelsToPr } from './pr/labels.js' + + +let prData: PrData | undefined; +let originalCheckoutBranch: string; async function run(): Promise { + ghCore.info(`Working directory is ${process.cwd()}`); + + ghCore.debug(`Runner OS is ${utils.getOS()}`); + ghCore.debug(`Node version is ${process.version}`); + + console.log(`CHECKPOINT: start`); + // checkout branch securly when payload originates from a pull request + originalCheckoutBranch = await getOriginalCheckoutBranch(); + console.log(`CHECKPOINT: originalCheckoutBranch ${originalCheckoutBranch}`); + prData = await handlePr(); + const analysisStartTime = new Date().toISOString(); ghCore.debug(`Analysis started at ${analysisStartTime}`); let sha; let ref; - sha = await utils.getEnvVar("GITHUB_SHA"); - ref = utils.getEnvVar("GITHUB_REF"); + if (prData != null) { + ({ sha, ref } = prData); + } + else { + sha = await utils.getCommitSha(); + ref = utils.getEnvVar("GITHUB_REF"); + } ghCore.info(`Ref to analyze is "${ref}"`); ghCore.info(`Commit to analyze is "${sha}"`); @@ -43,10 +66,71 @@ async function run(): Promise { /* Label the PR with the scan status, if applicable */ +// if (prData) { +// let resultLabel: string; + +// switch (vulSeverity) { +// case "error": +// resultLabel = CrdaLabels.CRDA_FOUND_ERROR; +// break; +// case "warning": +// resultLabel = CrdaLabels.CRDA_FOUND_WARNING; +// break; +// default: +// resultLabel = CrdaLabels.CRDA_SCAN_PASSED; +// break; +// } + +// await labels.addLabelsToPr(prData.number, [ resultLabel ]); +// } + +// /* Evaluate fail_on and set the workflow step exit code accordingly */ + +// const failOn = ghCore.getInput(Inputs.FAIL_ON) || "error"; + +// if (vulSeverity !== "none") { +// if (failOn !== "never") { +// if (failOn === "warning") { +// ghCore.info( +// `Input "${Inputs.FAIL_ON}" is "${failOn}", and at least one warning was found. Failing workflow.` +// ); +// ghCore.setFailed(`Found vulnerabilities in the project.`); +// } +// else if (failOn === "error" && vulSeverity === "error") { +// ghCore.info( +// `Input "${Inputs.FAIL_ON}" is "${failOn}", and at least one error was found. Failing workflow.` +// ); +// ghCore.setFailed(`Found high severity vulnerabilities in the project.`); +// } +// } +// else { +// ghCore.warning(`Found ${utils.capitalizeFirstLetter(vulSeverity)} level vulnerabilities`); +// ghCore.info(`Input "${Inputs.FAIL_ON}" is "${failOn}". Not failing workflow.`); +// } +// } +// else { +// ghCore.info(`✅ No vulnerabilities were found`); +// } + /* Handle artifacts */ await generateArtifacts([rhdaReportJsonFilePath, rhdaReportSarifFilePath]); } -run() \ No newline at end of file +run() + .then(() => { + // nothing + }) + .catch(async (err) => { + if (prData != null) { + await addLabelsToPr(prData.number, [ RhdaLabels.RHDA_SCAN_FAILED ]); + } + ghCore.setFailed(err.message); + }) + .finally(async () => { + if (prData != null) { + console.log(`CHECKPOINT cleaning up checkout now`) + await checkoutCleanup(prData.number, originalCheckoutBranch); + } + }); \ No newline at end of file diff --git a/src/pr/authorization.ts b/src/pr/authorization.ts new file mode 100644 index 00000000..04d9bd57 --- /dev/null +++ b/src/pr/authorization.ts @@ -0,0 +1,106 @@ +import * as ghCore from "@actions/core"; +import * as github from "@actions/github"; +import { Octokit } from "@octokit/core"; + +import * as types from './types.js' +import * as labels from "./labels.js"; +import { RhdaLabels } from "./labels.js"; +import { getGhToken, prettifyHttpError } from "../utils.js"; + +export async function isPrScanApproved(pr: types.PrData): Promise { + ghCore.info(`Scan is running in a pull request, checking for approval label...`); + + // get author authorization + let prAuthorHasWriteAccess = await canPrAuthorWrite(pr); + + // update labels + const availableLabels = await labels.getLabels(pr.number); + if (availableLabels.length !== 0) { + ghCore.info(`Pull request labels are: ${availableLabels.map((s) => `"${s}"`).join(", ")}`); + } + else { + ghCore.info("No labels found"); + } + + const prAction = github.context.payload.action; + ghCore.info(`Action performed is "${prAction}"`); + + if (prAction === "edited" || prAction === "synchronize") { + ghCore.info(`Code change detected`); + + let labelsToRemove = labels.findLabelsToRemove(availableLabels); + + // if pr author has write access do not remove approved label + if (prAuthorHasWriteAccess) { + labelsToRemove = labelsToRemove.filter(label => label !== RhdaLabels.RHDA_SCAN_APPROVED); + } + + if (labelsToRemove.length > 0) { + await labels.removeLabelsFromPr(pr.number, labelsToRemove); + } + + if (prAuthorHasWriteAccess) { + return true; + } + ghCore.info(`Adding "${RhdaLabels.RHDA_SCAN_PENDING}" label.`); + await labels.addLabelsToPr(pr.number, [ RhdaLabels.RHDA_SCAN_PENDING ]); + + return false; + } + + if (availableLabels.includes(RhdaLabels.RHDA_SCAN_APPROVED)) { + if (availableLabels.includes(RhdaLabels.RHDA_SCAN_PENDING)) { + await labels.removeLabelsFromPr(pr.number, [ RhdaLabels.RHDA_SCAN_PENDING ]); + } + ghCore.info(`"${RhdaLabels.RHDA_SCAN_APPROVED}" label is present`); + return true; + } + + if (prAuthorHasWriteAccess) { + await labels.addLabelsToPr(pr.number, [ RhdaLabels.RHDA_SCAN_APPROVED ]); + + return true; + } + + if (!availableLabels.includes(RhdaLabels.RHDA_SCAN_PENDING)) { + await labels.addLabelsToPr(pr.number, [ RhdaLabels.RHDA_SCAN_PENDING ]); + } + + return false; +} + +// API documentation: https://docs.github.com/en/rest/reference/repos#get-repository-permissions-for-a-user +async function canPrAuthorWrite(pr: types.PrData): Promise { + if (!pr.author) { + ghCore.warning(`Failed to determine pull request author`); + return false; + } + ghCore.info(`Pull request author is "${pr.author}"`); + + const octokit = new Octokit({ auth: getGhToken() }); + const { owner, repo } = github.context.repo; + let authorPermissionResponse; + try { + ghCore.info(`Checking if the user "${pr.author}" has write ` + + `access to repository "${owner}/${repo}"`); + authorPermissionResponse = await octokit.request( + "GET /repos/{owner}/{repo}/collaborators/{username}/permission", { + owner, + repo, + username: pr.author, + } + ); + } + catch (err) { + throw prettifyHttpError(err); + } + + const permission = authorPermissionResponse.data.permission; + if (permission === "admin" || permission === "write") { + ghCore.info(`User has write access to the repository`); + return true; + } + ghCore.info(`User doesn't has write access to the repository`); + + return false; +} \ No newline at end of file diff --git a/src/pr/checkout.ts b/src/pr/checkout.ts new file mode 100644 index 00000000..95081d99 --- /dev/null +++ b/src/pr/checkout.ts @@ -0,0 +1,52 @@ +import * as ghCore from '@actions/core'; + +import { getGitExecutable, execCommand } from "../utils.js"; + +export async function getOriginalCheckoutBranch(): Promise { + const { exitCode, stdout, stderr } = await execCommand(getGitExecutable(), [ "branch", "--show-current" ]); + if (exitCode != 0) { + throw(new Error(stderr)); + } + return stdout.trim(); +} + +/** + * Checkout PR code to run the CRDA Analysis on a PR, + * After completion of the scan this created remote and branch + * will be deleted and branch will be checkedout the present branch + */ +export async function checkoutPr(baseRepoUrl: string, prNumber: number): Promise { + const remoteName = getPRRemoteName(prNumber); + const localbranchName = getPRBranchName(prNumber); + + ghCore.info(`Adding remote ${baseRepoUrl}`); + await execCommand(getGitExecutable(), [ "remote", "add", remoteName, baseRepoUrl ]); + + ghCore.info(`⬇️ Checking out PR #${prNumber} to run RHDA analysis.`); + await execCommand(getGitExecutable(), [ "fetch", remoteName, `pull/${prNumber}/head:${localbranchName}` ]); + await execCommand(getGitExecutable(), [ "checkout", localbranchName ]); +} + +// Do cleanup after the crda scan and checkout +// back to the original branch +export async function checkoutCleanup(prNumber: number, originalCheckoutBranch: string): Promise { + const remoteName = getPRRemoteName(prNumber); + const branchName = getPRBranchName(prNumber); + + ghCore.info(`Checking out back to ${originalCheckoutBranch} branch.`); + await execCommand(getGitExecutable(), [ "checkout", originalCheckoutBranch ]); + + ghCore.info(`Removing the created remote "${remoteName}"`); + await execCommand(getGitExecutable(), [ "remote", "remove", remoteName ]); + + ghCore.info(`Removing created branch "${branchName}"`); + await execCommand(getGitExecutable(), [ "branch", "-D", `${branchName}` ]); +} + +function getPRRemoteName(prNumber: number): string { + return `remote-${prNumber}`; +} + +function getPRBranchName(prNumber: number): string { + return `pr-${prNumber}`; +} diff --git a/src/pr/handler.ts b/src/pr/handler.ts new file mode 100644 index 00000000..7aec6bf3 --- /dev/null +++ b/src/pr/handler.ts @@ -0,0 +1,82 @@ +import * as ghCore from '@actions/core'; +import * as github from '@actions/github'; +import { components } from "@octokit/openapi-types"; + +import * as types from './types.js' +import * as auth from './authorization.js' +import { RhdaLabels, createRepoLabels } from './labels.js' +import * as checkout from './checkout.js' + +type pullRequest = components["schemas"]["pull-request-simple"]; + +async function handlePr(): Promise { + + // check if event is pull request + const prRawData = github.context.payload.pull_request as pullRequest; + if (prRawData == null) { + ghCore.info(`No checkout required, item is not a pull request`); + return; + } + + // parse PR data + const pr = parsePrData(prRawData); + ghCore.info(`PR number is ${pr.number}`); + ghCore.info( + `PR authored by ${pr.author} is coming from ${pr.headRepo.htmlUrl} against ${pr.baseRepo.htmlUrl}` + ); + console.log(`CHECKPOINT: prData ${JSON.stringify(pr, null, 2)}`); + + + // create and load pr labels + await createRepoLabels(); + + // check pr approval status + const prApproved = await auth.isPrScanApproved(pr); + + if (!prApproved) { + // no-throw so we don't add the failed label too. + ghCore.error( + `"${RhdaLabels.RHDA_SCAN_APPROVED}" label is needed to scan this pull request with RHDA. ` + + `Refer to https://github.com/redhat-actions/rhda/#scanning-pull-requests` + ); + return; + } + + ghCore.info(`✅ Pull request scan is approved`); + + // checkout pr + await checkout.checkoutPr(pr.baseRepo.htmlUrl, pr.number); + + return pr; +} + +function parsePrData(pr: pullRequest): types.PrData { + + const baseOwner = pr.base.repo.owner?.login; + if (!baseOwner) { + throw new Error(`Could not determine owner of pull request base repository`); + } + const headOwner = pr.head.repo.owner?.login; + if (!headOwner) { + throw new Error(`Could not determine owner of pull request head repository`); + } + + return { + author: pr.user?.login, + number: pr.number, + sha: pr.head.sha, + ref: `refs/pull/${pr.number}/head`, + baseRepo: { + htmlUrl: pr.base.repo.html_url, + owner: baseOwner, + repo: pr.base.repo.name, + }, + headRepo: { + htmlUrl: pr.head.repo.html_url, + owner: headOwner, + repo: pr.head.repo.name, + }, + }; +} + +export { handlePr }; \ No newline at end of file diff --git a/src/pr/labels.ts b/src/pr/labels.ts new file mode 100644 index 00000000..0b047613 --- /dev/null +++ b/src/pr/labels.ts @@ -0,0 +1,201 @@ +import { Octokit } from "@octokit/core"; +import * as github from "@actions/github"; +import { paginateRest } from "@octokit/plugin-paginate-rest"; +import { components } from "@octokit/openapi-types"; +import * as ghCore from "@actions/core"; + +import { getGhToken, prettifyHttpError } from "../utils.js"; + +type Label = components["schemas"]["label"]; + +/** + * RHDA labels to be added to a PR + */ +export enum RhdaLabels { + RHDA_SCAN_PENDING = "RHDA Scan Pending", + RHDA_SCAN_APPROVED = "RHDA Scan Approved", + RHDA_SCAN_PASSED = "RHDA Scan Passed", + RHDA_SCAN_FAILED = "RHDA Scan Failed", + RHDA_FOUND_WARNING = "RHDA Found Warning", + RHDA_FOUND_ERROR = "RHDA Found Error" +} + +export const repoLabels = [ + RhdaLabels.RHDA_SCAN_PENDING, + RhdaLabels.RHDA_SCAN_APPROVED, + RhdaLabels.RHDA_SCAN_FAILED, + RhdaLabels.RHDA_SCAN_PASSED, + RhdaLabels.RHDA_FOUND_WARNING, + RhdaLabels.RHDA_FOUND_ERROR, +]; + +export const labelsToCheckForRemoval = [ + RhdaLabels.RHDA_SCAN_APPROVED, + RhdaLabels.RHDA_SCAN_FAILED, + RhdaLabels.RHDA_SCAN_PASSED, + RhdaLabels.RHDA_FOUND_WARNING, + RhdaLabels.RHDA_FOUND_ERROR, +]; + +export function getLabelColor(label: string): string { + switch (label) { + case RhdaLabels.RHDA_SCAN_APPROVED: + return "008080"; // teal color + case RhdaLabels.RHDA_SCAN_PENDING: + return "FBCA04"; // blue color + case RhdaLabels.RHDA_SCAN_PASSED: + return "0E8A16"; // green color + case RhdaLabels.RHDA_SCAN_FAILED: + return "E11D21"; // red color + case RhdaLabels.RHDA_FOUND_WARNING: + return "EE9900"; // yellow color + case RhdaLabels.RHDA_FOUND_ERROR: + return "B60205"; // red color + default: + return "FBCA04"; + } +} + +export function getLabelDescription(label: string): string { + switch (label) { + case RhdaLabels.RHDA_SCAN_APPROVED: + return "RHDA scan approved by a collaborator"; + case RhdaLabels.RHDA_SCAN_PENDING: + return "RHDA scan waiting for approval"; + case RhdaLabels.RHDA_SCAN_PASSED: + return "RHDA found no vulnerabilities"; + case RhdaLabels.RHDA_SCAN_FAILED: + return "RHDA scan failed unexpectedly"; + case RhdaLabels.RHDA_FOUND_WARNING: + return `RHDA found "warning" level vulnerabilities`; + case RhdaLabels.RHDA_FOUND_ERROR: + return `RHDA found "error" level vulnerabilities`; + default: + return ""; + } +} + +export async function createRepoLabels(): Promise { + const availableLabels = await getLabels(); + if (availableLabels.length !== 0) { + ghCore.info(`Available Repo labels: ${availableLabels.map((s) => `"${s}"`).join(", ")}`); + } + else { + ghCore.info("No labels found in the repository"); + } + const labelsToCreate: string[] = []; + repoLabels.forEach((label) => { + if (!availableLabels.includes(label)) { + labelsToCreate.push(label); + } + }); + + if (labelsToCreate.length !== 0) { + ghCore.info(`Labels to create in the repository: ${labelsToCreate.map((s) => `"${s}"`).join(", ")}`); + } + else { + ghCore.info("Required labels are already present in the repository. " + + "No labels need to be created."); + } + + await createLabels(labelsToCreate); +} + +// API documentation: https://docs.github.com/en/rest/reference/issues#list-labels-for-an-issue +export async function getLabels(prNumber?: number): Promise { + const ActionsOctokit = Octokit.plugin(paginateRest); + const octokit = new ActionsOctokit({ auth: getGhToken() }); + let labelsResponse: Label[]; + try { + if (prNumber) { + labelsResponse = await octokit.paginate("GET /repos/{owner}/{repo}/issues/{issue_number}/labels", { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + }); + } else { + labelsResponse = await octokit.paginate("GET /repos/{owner}/{repo}/labels", { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + }); + } + } + catch (err) { + throw prettifyHttpError(err); + } + + const availableLabels: string[] = labelsResponse.map( + (labels: Label) => labels.name + ); + return availableLabels; +} + +// API documentation: https://docs.github.com/en/rest/reference/issues#create-a-label +async function createLabels(labels: string[]): Promise { + const octokit = new Octokit({ auth: getGhToken() }); + labels.forEach(async (label) => { + try { + ghCore.info(`Creating label ${label}`); + await octokit.request("POST /repos/{owner}/{repo}/labels", { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + name: label, + color: getLabelColor(label), + description: getLabelDescription(label), + }); + } + catch (err) { + throw prettifyHttpError(err); + } + }); +} + +// Find the labels present in the PR which can be removed +export function findLabelsToRemove(availableLabels: string[]): string[] { + const labelsToRemove: string[] = []; + labelsToCheckForRemoval.forEach((label) => { + if (availableLabels.includes(label)) { + labelsToRemove.push(label); + } + }); + + return labelsToRemove; +} + +// API documentation: https://docs.github.com/en/rest/reference/issues#remove-a-label-from-an-issue +export async function removeLabelsFromPr(prNumber: number, labels: string[]): Promise { + ghCore.info(`Removing labels ${labels.map((s) => `"${s}"`).join(", ")} from pull request`); + + const octokit = new Octokit({ auth: getGhToken() }); + labels.forEach(async (label) => { + try { + await octokit.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + name: label, + }); + } + catch (err) { + throw prettifyHttpError(err); + } + }); +} + +// API documentation: https://docs.github.com/en/rest/reference/issues#add-labels-to-an-issue +export async function addLabelsToPr(prNumber: number, labels: string[]): Promise { + ghCore.info(`Adding labels ${labels.map((s) => `"${s}"`).join(", ")} to pull request`); + + const octokit = new Octokit({ auth: getGhToken() }); + try { + await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + labels, + }); + } + catch (err) { + throw prettifyHttpError(err); + } +} \ No newline at end of file diff --git a/src/pr/types.ts b/src/pr/types.ts new file mode 100644 index 00000000..d7dae95e --- /dev/null +++ b/src/pr/types.ts @@ -0,0 +1,22 @@ +export interface PrData { + author: string | undefined, + number: number, + sha: string, + ref: string, + /** + * The forked repo that the PR is coming from + */ + headRepo: { + owner: string, + repo: string, + htmlUrl: string, + }, + /** + * The upstream repo that the PR wants to merge into + */ + baseRepo: { + owner: string, + repo: string, + htmlUrl: string, + } +}; \ No newline at end of file diff --git a/src/sarif/convert.ts b/src/sarif/convert.ts index 748769a1..d5e585b2 100644 --- a/src/sarif/convert.ts +++ b/src/sarif/convert.ts @@ -5,7 +5,7 @@ import * as fs from "fs"; import * as result from './results.js'; import * as types from './types.js'; import { SARIF_SCHEMA_URL, SARIF_SCHEMA_VERSION } from '../constants.js'; -import { isDefined } from '../utils/utils.js' +import { isDefined } from '../utils.js' function rhdaJsonToSarif(rhdaData: types.RhdaData, manifestFilePath: string) { /* diff --git a/src/sarif/handler.ts b/src/sarif/handler.ts index bac0be30..0af16f0c 100644 --- a/src/sarif/handler.ts +++ b/src/sarif/handler.ts @@ -1,11 +1,11 @@ import * as ghCore from '@actions/core'; import * as github from "@actions/github"; +import path from 'path'; import { Inputs, Outputs } from '../generated/inputs-outputs.js'; import * as convert from './convert.js'; import * as upload from './upload.js'; -import * as utils from '../utils/utils.js' -import path from 'path'; +import * as utils from '../utils.js' export async function handleSarif(rhdaReportJson: any, manifestFilePath: string, sha: string, ref: string, analysisStartTime: string): Promise { diff --git a/src/sarif/upload.ts b/src/sarif/upload.ts index 501f0fd5..f067a91d 100644 --- a/src/sarif/upload.ts +++ b/src/sarif/upload.ts @@ -1,13 +1,8 @@ import * as ghCore from '@actions/core'; -import * as sarif from "sarif"; -import * as fs from "fs"; import { Octokit } from "@octokit/core"; import * as github from "@actions/github"; -import { URLSearchParams } from "url"; -import { Inputs, Outputs } from '../generated/inputs-outputs.js'; -import * as utils from '../utils/utils.js' -import path from 'path'; +import * as utils from '../utils.js' export async function uploadSarifFile( ghToken: string, sarifPath: string, diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..ca1ae650 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,172 @@ +import * as fs from 'fs'; +import * as zlib from "zlib"; +import * as ghCore from "@actions/core"; +import * as ghExec from "@actions/exec"; +import { Inputs } from "./generated/inputs-outputs.js"; + +type OS = "linux" | "macos" | "windows"; +let currentOS: OS | undefined; +export function getOS(): OS { + if (currentOS == null) { + const rawOS = process.platform; + if (rawOS === "win32") { + currentOS = "windows"; + } + else if (rawOS === "darwin") { + currentOS = "macos"; + } + else if (rawOS !== "linux") { + ghCore.warning(`Unrecognized OS "${rawOS}"`); + currentOS = "linux"; + } + else { + currentOS = "linux"; + } + } + + return currentOS; +} + + +let ghToken: string | undefined; +/** + * + * @returns GitHub token provided by the user. + * If no token is provided, returns the empty string. + */ +export function getGhToken(): string { + if (ghToken == null) { + ghToken = ghCore.getInput(Inputs.GITHUB_TOKEN); + + // this to only solve the problem of local development + if (!ghToken && process.env.GITHUB_TOKEN) { + ghToken = process.env.GITHUB_TOKEN; + } + } + return ghToken; +} + +let gitExecutable: string | undefined; +export function getGitExecutable(): string { + if (gitExecutable) { + return gitExecutable; + } + + const git = getOS() === "windows" ? "git.exe" : "git"; + gitExecutable = git; + return git; +} + +export function getEnvVar(envName: string): string { + const value = process.env[envName]; + if (value === undefined || value.length === 0) { + throw new Error(`❌ ${envName} environment variable must be set`); + } + return value; +} + +export function writeToFile(data, path) { + try { + fs.writeFileSync(path, data, "utf-8"); + } catch (err) { + throw (err); + } +} + +export function escapeWindowsPathForActionsOutput(p: string): string { + return p.replace(/\\/g, "\\\\"); +} + +/** + * + * @returns The given file as a gzipped string. + */ +export async function zipFile(file: string): Promise { + const fileContents = await fs.readFileSync(file, "utf-8"); + // ghCore.debug(`Raw upload size: ${utils.convertToHumanFileSize(fileContents.length)}`); + const zippedContents = (await zlib.gzipSync(fileContents)).toString("base64"); + // ghCore.debug(`Zipped file: ${zippedContents}`); + // ghCore.info(`Zipped upload size: ${utils.convertToHumanFileSize(zippedContents.length)}`); + + return zippedContents; +} + + +/** + * Checks if the specified keys are defined within the provided object. + * @param obj - The object to check for key definitions. + * @param keys - The keys to check for within the object. + * @returns A boolean indicating whether all specified keys are defined within the object. + */ +export function isDefined(obj: any, ...keys: string[]): boolean { + for (const key of keys) { + if (!obj || !obj[key]) { + return false; + } + obj = obj[key]; + } + return true; +} + +/** + * The errors messages from octokit HTTP requests can be poor; prepending the status code helps clarify the problem. + */ +export function prettifyHttpError(err: any): Error { + const status = err.status; + if (status && err.message) { + return new Error(`Received status ${status}: ${err.message}`); + } + return err; +} + +/** + * Run 'crda' with the given arguments. + * + * @throws If the exitCode is not 0, unless execOptions.ignoreReturnCode is set. + * + * @param args Arguments and options to 'crda'. Use getOptions to convert an options mapping into a string[]. + * @param execOptions Options for how to run the exec. See note about hideOutput on windows. + * @returns Exit code and the contents of stdout/stderr. + */ + +export async function execCommand( + executable: string, + args: string[], + options: ghExec.ExecOptions = {} +): Promise<{ exitCode: number, stdout: string, stderr: string }> { + ghCore.info(`running "${executable} ${args.join(" ")}"`); + + let stdout = ""; + let stderr = ""; + + const execOptions = { + ...options, + listeners: { + stdout: (data: Buffer) => { + stdout += data.toString(); + }, + stderr: (data: Buffer) => { + stderr += data.toString(); + } + } + }; + + try { + const exitCode = await ghExec.exec(executable, args, execOptions); + ghCore.debug(`Exit code ${exitCode}`); + + console.log(`exitCode: ${exitCode}`) + console.log(`stdout ${stdout}`) + console.log(`stderr ${stderr}`) + + return { exitCode, stdout, stderr }; + } catch (error) { + ghCore.setFailed(`Execution failed with error: ${error.message}`); + throw error; + } +} + +export async function getCommitSha(): Promise { + const commitSha = (await execCommand(getGitExecutable(), [ "rev-parse", "HEAD" ])).stdout; + return commitSha.trim(); +} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts deleted file mode 100644 index af948217..00000000 --- a/src/utils/utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as fs from 'fs'; -import * as zlib from "zlib"; - -export function getEnvVar(envName: string): string { - const value = process.env[envName]; - if (value === undefined || value.length === 0) { - throw new Error(`❌ ${envName} environment variable must be set`); - } - return value; -} - -export function writeToFile(data, path) { - try { - fs.writeFileSync(path, data, "utf-8"); - } catch (err) { - throw (err); - } -} - -export function escapeWindowsPathForActionsOutput(p: string): string { - return p.replace(/\\/g, "\\\\"); -} - -/** - * - * @returns The given file as a gzipped string. - */ -export async function zipFile(file: string): Promise { - const fileContents = await fs.readFileSync(file, "utf-8"); - // ghCore.debug(`Raw upload size: ${utils.convertToHumanFileSize(fileContents.length)}`); - const zippedContents = (await zlib.gzipSync(fileContents)).toString("base64"); - // ghCore.debug(`Zipped file: ${zippedContents}`); - // ghCore.info(`Zipped upload size: ${utils.convertToHumanFileSize(zippedContents.length)}`); - - return zippedContents; -} - - -/** - * Checks if the specified keys are defined within the provided object. - * @param obj - The object to check for key definitions. - * @param keys - The keys to check for within the object. - * @returns A boolean indicating whether all specified keys are defined within the object. - */ -export function isDefined(obj: any, ...keys: string[]): boolean { - for (const key of keys) { - if (!obj || !obj[key]) { - return false; - } - obj = obj[key]; - } - return true; -}