diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c5fc70 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.gitignore +*.test.js +jest.config.js +.docker +.github +.vscode +dummy +.env +.env* diff --git a/.env.sh.sample b/.env.sh.sample new file mode 100644 index 0000000..cbfb25a --- /dev/null +++ b/.env.sh.sample @@ -0,0 +1 @@ +export GITHUB_TOKEN="gh123abc" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 122fe99..7c46c6e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,3 +36,25 @@ jobs: run: npm run test env: GITHUB_TOKEN: ${{ secrets.GH_TKN }} + + # docs: https://github.com/marketplace/actions/build-and-push-docker-images + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # docs: https://docs.docker.com/build/ci/github-actions/test-before-push/ + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + push: false + context: . + load: true + tags: github-graphql-client:latest + + - name: Run Docker tests + env: + GITHUB_TOKEN: ${{ secrets.GH_TKN }} + run: npm run docker:test + continue-on-error: true diff --git a/.vscode/launch.json b/.vscode/launch.json index 10b0968..7ef0e27 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -28,6 +28,54 @@ "--help" ] }, + { + "type": "node", + "request": "launch", + "name": "commit good branch", + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "program": "${workspaceFolder}/github.js", + "args": [ + "commit", + "--owner", + "pirafrank", + "--repo", + "test-repo", + "--branch", + "main", + "-c", + "dummy/file1.txt", + "-m", + "this is a commit msg" + ], + "envFile": "${workspaceFolder}/.env" + }, + { + "type": "node", + "request": "launch", + "name": "commit BAD branch", + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "program": "${workspaceFolder}/github.js", + "args": [ + "commit", + "--owner", + "pirafrank", + "--repo", + "test-repo", + "--branch", + "not-main", + "-c", + "dummy/file1.txt", + "-m", + "this is a commit msg" + ], + "envFile": "${workspaceFolder}/.env" + }, { "type": "node", "request": "launch", @@ -38,9 +86,31 @@ "program": "${workspaceFolder}/github.js", "args": [ "branch", - "--owner", "pirafrank", - "--repo", "test-repo", - "--branch", "main" + "--owner", + "pirafrank", + "--repo", + "test-repo", + "--branch", + "main" + ], + "envFile": "${workspaceFolder}/.env" + }, + { + "type": "node", + "request": "launch", + "name": "branch does NOT exist", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/github.js", + "args": [ + "branch", + "--owner", + "pirafrank", + "--repo", + "test-repo", + "--branch", + "not-main" ], "envFile": "${workspaceFolder}/.env" } diff --git a/Dockerfile b/Dockerfile index 0b3b67f..7ab0f93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,14 @@ ENV DEBIAN_FRONTEND=noninteractive COPY . /app/ COPY ./entrypoint.sh /entrypoint.sh +# IMPORTANT: +# +# GitHub sets the working directory path in the GITHUB_WORKSPACE +# environment variable. It's recommended to not use the WORKDIR +# instruction in your Dockerfile +# +# docs: https://docs.github.com/en/actions/creating-actions/dockerfile-support-for-github-actions#workdir + RUN cd /app && npm install --omit=dev ENTRYPOINT ["/entrypoint.sh"] diff --git a/action.yml b/action.yml index e989518..f0d395e 100644 --- a/action.yml +++ b/action.yml @@ -8,6 +8,10 @@ inputs: description: 'Arguments to pass to the Dockerfile entrypoint.' required: true default: '--help' + debug: + description: 'Whether to enable debug mode.' + required: false + default: 'false' outputs: command: description: 'The command that was executed.' @@ -18,7 +22,8 @@ outputs: runs: using: 'docker' image: 'Dockerfile' - #env: + env: + DEBUG: ${{ inputs.debug }} # GITHUB_TOKEN is already available in the action container context # but be sure that it has 'writer' permissions on the target repository. args: diff --git a/entrypoint.sh b/entrypoint.sh index e38d02d..591e177 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,17 @@ #!/usr/bin/env bash set -e -argv=(node /app/github.js "$@") -cmd=$(printf '%q ' "${argv[@]}") -eval $cmd + +if [ -z "$GITHUB_TOKEN" ]; then + echo "GITHUB_TOKEN is not set. Exiting." + exit 1 +fi + +if [[ "$DEBUG" == "true" ]]; then + echo "first arg" + echo $1 + echo "all args:" + echo $@ +fi + +eval "node /app/github.js $@" diff --git a/github.js b/github.js index 1d01dab..a53c718 100644 --- a/github.js +++ b/github.js @@ -1,6 +1,7 @@ const fs = require("fs"); const yargs = require("yargs"); const CURRENT_VERSION = require("./package.json").version; +const { info, error, debug } = require("./src/log"); const { init, @@ -12,12 +13,13 @@ const { const commitCommand = "commit"; const branchCommand = "branch" +const knownCommands = [commitCommand, branchCommand]; const appendLineToFile = (filename, line) => { try { fs.appendFileSync(filename, `${line}\n`); } catch (e) { - console.error(`Error appending line to file ${filename}: ${e.message}`); + error(`Error appending line to file ${filename}: ${e.message}`); throw e; } }; @@ -95,8 +97,7 @@ yargs commitMessage, commitDescription, } = argv; - - init(); + debug("Passed args:", JSON.stringify(argv, null, 2)); createCommitOnBranch( owner, repo, @@ -107,7 +108,7 @@ yargs commitDescription ) .then((response) => { - console.log(`Commit created: ${response.commitUrl}`); + info(`Commit created: ${response.commitUrl}`); writeResultToGithubOutputFile([ { label: "command", @@ -119,8 +120,9 @@ yargs }, ]); }) - .catch((error) => { - console.error("Failed to create commit:", error.message); + .catch((err) => { + error("Failed to create commit:", err.message); + process.exit(1); }); } ) @@ -150,11 +152,11 @@ yargs }, (argv) => { const { owner, repo, branch } = argv; - init(); + debug("Passed args:", JSON.stringify(argv, null, 2)); checkIfBranchExists(owner, repo, branch) .then((response) => { const n = response ? "a" : "no"; - console.log( + info( `Repository ${owner}/${repo} has ${n} branch named '${branch}'` ); writeResultToGithubOutputFile([ @@ -168,8 +170,9 @@ yargs }, ]); }) - .catch((error) => { - console.error("Failed to check if branch exists:", error.message); + .catch((err) => { + error("Failed to check if branch exists:", err.message); + process.exit(1); }); } ) @@ -177,6 +180,16 @@ yargs .version(CURRENT_VERSION) .alias({ h: "help", - v: "version" + v: "version", + }) + .check((argv) => { + const cmd = argv._[0]; + if (!knownCommands.includes(cmd)) { + throw new Error(`Unknown command: ${cmd}`); + } + return true; + }) + .check(() => { + return init(); }) .help().argv; diff --git a/github.test.js b/github.test.js new file mode 100644 index 0000000..ddd3d05 --- /dev/null +++ b/github.test.js @@ -0,0 +1,63 @@ +const { exec } = require("child_process"); +const { + repoOwner, + repoName, + correctBranch, + wrongBranch, +} = require("./test.common.js"); + +describe("github.js", () => { + describe("commit command", () => { + test("commit command, good branch", (done) => { + exec( + `node github.js commit -o ${repoOwner} -r ${repoName} -b ${correctBranch} -c dummy/file1.txt -m "this is a commit msg"`, + (error, stdout, stderr) => { + expect(error).toBeNull(); + expect(stdout).toContain( + `Commit created: https://github.com/${repoOwner}/${repoName}/commit` + ); + done(); + } + ); + }, 10000); + + test("commit command, BAD branch", (done) => { + exec( + `node github.js commit -o ${repoOwner} -r ${repoName} -b ${wrongBranch} -c dummy/file1.txt -m "this is a commit msg"`, + (error, stdout, stderr) => { + expect(error).not.toBeNull(); + expect(stderr).toMatch(/Failed to create commit:/); + done(); + } + ); + }, 10000); + }); + + describe("branch command", () => { + test("branch command, good branch", (done) => { + exec( + `node github.js branch -o ${repoOwner} -r ${repoName} -b ${correctBranch}`, + (error, stdout, stderr) => { + expect(error).toBeNull(); + expect(stdout).toContain( + `Repository ${repoOwner}/${repoName} has a branch named '${correctBranch}'` + ); + done(); + } + ); + }, 10000); + + test("branch command, BAD branch", (done) => { + exec( + `node github.js branch -o ${repoOwner} -r ${repoName} -b ${wrongBranch}`, + (error, stdout, stderr) => { + expect(error).toBeNull(); + expect(stdout).toContain( + `Repository ${repoOwner}/${repoName} has no branch named '${wrongBranch}'` + ); + done(); + } + ); + }, 10000); + }); +}); diff --git a/index.js b/index.js index 289557b..1647c82 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ const { cacheExchange, fetchExchange } = require("@urql/core"); +const { info, error, debug } = require("./src/log"); let GITHUB_GRAPHQL_URL = null; let githubToken = null; @@ -16,9 +17,9 @@ let client = null; * @param {string} apiUrl GitHub GraphQL API URL */ function init(token, apiUrl) { - githubToken = token || process.env.GITHUB_TOKEN; + const githubToken = token || process.env.GITHUB_TOKEN; if (!githubToken) { - throw new Error("ERROR: GITHUB_TOKEN environment variable not set."); + throw new Error("Error: token argument missing or GITHUB_TOKEN env var not set."); } GITHUB_GRAPHQL_URL = @@ -35,6 +36,8 @@ function init(token, apiUrl) { }, }, }); + + return true; } function arrayIsArray(array) { @@ -83,6 +86,13 @@ function extractChangedOrDeletedFiles(changedFiles, deletedFiles) { }; } +function checkErrorResponse(response) { + if (!!response.errors || !!response.error || !response.data) { + error("Error response from API:", JSON.stringify(response, null, 2)); + throw new Error("Received error response from API"); + } +} + async function fetchBranchData(repoOwner, repoName, branchName) { const query = ` query($owner: String!, $repo: String!, $branch: String!) { @@ -105,12 +115,13 @@ async function fetchBranchData(repoOwner, repoName, branchName) { try { const response = await client.query(query, variables); + checkErrorResponse(response); return response; - } catch (error) { - console.error( - `Error while trying to fetching data from API: ${error.message}` + } catch (err) { + error( + `Error while trying to fetch data from API: ${err.message}` ); - throw error; + throw err; } } @@ -166,8 +177,8 @@ async function createCommitOnBranch( changedFiles, deletedFiles )); - console.log("Changed files:", JSON.stringify(changedFiles, null, 2)); - console.log("Deleted files:", JSON.stringify(deletedFiles, null, 2)); + info("Changed files:", JSON.stringify(changedFiles, null, 2)); + info("Deleted files:", JSON.stringify(deletedFiles, null, 2)); if (!commitMessage) { throw new Error("No commit message provided. Aborting."); @@ -198,15 +209,16 @@ async function createCommitOnBranch( const response = await client .mutation(graphqlRequest.query, graphqlRequest.variables) .toPromise(); + checkErrorResponse(response); return { data: response, commitUrl: response?.data?.createCommitOnBranch?.commit?.url || null }; - } catch (error) { - console.error( - `Error while performing commit action via GraphQL API: ${error.message}` + } catch (err) { + error( + `Error while performing commit action via GraphQL API: ${err.message}` ); - throw error; + throw err; } } diff --git a/index.test.js b/index.test.js index 7866988..8a70034 100644 --- a/index.test.js +++ b/index.test.js @@ -1,4 +1,3 @@ -require("dotenv").config(); const { repoOwner, repoName, @@ -26,7 +25,7 @@ describe("createCommitOnBranch", () => { "this is a commit msg", "the description of the commit" ); - console.log(JSON.stringify(result, null, 2)); + //console.log(JSON.stringify(result, null, 2)); const commitUrl = result?.commitUrl || ""; expect(commitUrl).toMatch( new RegExp( diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5fc288b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,2 @@ +// add dot env to all jest tests +require("dotenv").config(); diff --git a/package-lock.json b/package-lock.json index 9993200..d4a3296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pirafrank/github-graphql-client", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pirafrank/github-graphql-client", - "version": "0.1.0", + "version": "0.1.2", "license": "MIT", "dependencies": { "graphql": "^16.8.1", diff --git a/package.json b/package.json index dc2be95..67044fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pirafrank/github-graphql-client", - "version": "0.1.0", + "version": "0.1.2", "description": "Simple GitHub client to commit via their graphql APIs", "main": "index.js", "private": false, @@ -9,7 +9,12 @@ }, "scripts": { "gh": "node github.js", - "test": "jest" + "test": "jest --runInBand", + "test:index": "jest --runInBand --testPathPattern=index.test.js", + "test:github": "jest --runInBand --testPathPattern=github.test.js", + "docker:build": "docker build -t github-graphql-client -f Dockerfile .", + "docker:test": "./test.docker.sh", + "docker:all": "npm run docker:build && npm run docker:test" }, "keywords": [ "git", diff --git a/src/log.js b/src/log.js new file mode 100644 index 0000000..2e01cb0 --- /dev/null +++ b/src/log.js @@ -0,0 +1,58 @@ +// +// Description: This file contains the log utility functions. +// The log utility functions are used to log messages to the console. +// IMPORTANT: to avoid circular dependencies, the log utility functions +// should not import any other files. +// + +const isRunningInCI = () => { + return ( + !!process.env.GITHUB_ACTIONS || + !!process.env.TRAVIS || + !!process.env.CIRCLECI || + !!process.env.GITLAB_CI || + !!process.env.APPVEYOR + ); +}; + +// flag to determine if we are running in CI +const _isCI = isRunningInCI(); + +const pad = (str, length, separator) => { + if (str.length === 0 || str.length >= length) return str; + const d = length - str.length; + const m = d % 2; + const p = (d - m) / 2; + return str.padStart(str.length + p, separator).padEnd(length, separator); +}; + +const getPrefix = (level) => { + // only print timestamp if not running in CI, CI have their own timestamps + const timestamp = _isCI ? "" : `[${new Date().toISOString()}] `; + // second argument is max possible length of log level string + level = pad(level, 5, " "); + return `${timestamp}[${level}] :` +}; + +const info = (...args) => { + const prefix = getPrefix("INFO"); + console.log(prefix, ...args); +}; + +const error = (...args) => { + const prefix = getPrefix("ERROR"); + console.error(prefix, ...args); +}; + +const debug = (...args) => { + if (!!process.env.DEBUG && process.env.DEBUG === "true") { + const prefix = getPrefix("DEBUG"); + console.log(prefix, ...args); + } +}; + +module.exports = { + info, + error, + debug, +}; diff --git a/test.docker.sh b/test.docker.sh new file mode 100755 index 0000000..d47ef8b --- /dev/null +++ b/test.docker.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +source .env.sh + +echo "************** help *****************" + +# test handling of single parameter +docker run --rm \ +-v ./dummy:/app/dummy \ +-w /app \ +-e GITHUB_TOKEN \ +-e DEBUG \ +github-graphql-client:latest "--help" + +echo "************ branch arg missing *******************" + +# test handling of multiple parameters +docker run --rm \ +-v ./dummy:/app/dummy \ +-w /app \ +-e GITHUB_TOKEN \ +-e DEBUG \ +github-graphql-client:latest commit -o pirafrank -r 'test-repo' -c dummy/file1.txt -m 'this is a commit msg' + +echo "************** all separated *****************" + +# test handling of multiple parameters +docker run --rm \ +-v ./dummy:/app/dummy \ +-w /app \ +-e GITHUB_TOKEN \ +-e DEBUG \ +github-graphql-client:latest commit -o pirafrank -r 'test-repo' -b main -c dummy/file1.txt -m onewordcommitmsg + +echo "************** all as one arg *****************" + +# test multiple parameters surrounded by double quotes +# this is to test if the entrypoint.sh script can handle double quotes, +# because GitHub Actions will pass arguments as a single string. +docker run --rm \ +-v ./dummy:/app/dummy \ +-w /app \ +-e GITHUB_TOKEN \ +-e DEBUG \ +github-graphql-client:latest "commit -o pirafrank -r "test-repo" -b main -c dummy/file1.txt -m 'this is a commit msg'"