From 4daad1e22af049aeb9ceeadaa4733f7eda719fb0 Mon Sep 17 00:00:00 2001 From: Hugh Saunders Date: Thu, 6 Feb 2020 11:30:47 +0000 Subject: [PATCH] Add github issue functions with 'hub' cli This commit adds functions for creating and commenting on github issues via the hub cli. Related: conjurinc/ops#492 --- README.md | 19 +++ filehandling/lib | 3 + git/lib | 35 +++++ github/lib | 121 +++++++++++++++++ helpers/lib | 34 +++-- init | 2 +- logging/lib | 14 +- tests-for-this-repo/git.bats | 74 ++++++++++- tests-for-this-repo/github.bats | 197 ++++++++++++++++++++++++++++ tests-for-this-repo/helpers.bats | 6 + tests-for-this-repo/run-bats-tests | 4 + tests-for-this-repo/test-utils.bats | 2 +- 12 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 github/lib create mode 100644 tests-for-this-repo/github.bats diff --git a/README.md b/README.md index 90ce81e..8e94a28 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,9 @@ files within it's directory. Git helpers
    +
  1. bl_git_available: True if git binary or function is available
  2. +
  3. bl_in_git_repo: True if current directory is a git working directory
  4. +
  5. bl_github_owner_repo: returns $owner/$repo extracted from the url of the origin remote
  6. bl_repo_root: Find the root of the current git repo.
  7. bl_all_files_in_repo: List files tracked by git.
  8. bl_remote_latest_tag: Returns the symbolic name of the latest tag from a remote.
  9. @@ -133,12 +136,28 @@ files within it's directory.
  10. bl_cat_gittrees: Returns the contents of .gittrees from the top level of the repo, excluding any comments. Fails if .gittrees is not present.
+ + + git + Github Related Functions + +
    +
  1. bl_hub_available: True if hub binary or function is available
  2. +
  3. bl_hub_creds_available: True if hub creds are available (file or env vars)
  4. +
  5. bl_hub_check: Preflight check for hub, true if git installed, in git repo, hub installed and hub creds are available
  6. +
  7. bl_hub_download_latest: Download latest hub binary from github and install to ~/bin or specified path
  8. +
  9. bl_hub_issue_number_for_title: Find the issue number for an issue from its title, searches open issues in the current repo. (current repo = workding directory, repo is found by origin remote)
  10. +
  11. bl_hub_add_issue_comment: Add a comment to an issue
  12. +
  13. bl_hub_comment_or_create_issue: Create issue if an issue matching the title doesn't exist. If a match is found, add a comment to it
  14. +
+ helpers Bash scripting helpers
  1. bl_die: print message and exit 1
  2. +
  3. bl_fail: print message and return 1
  4. bl_spushd/bl_spopd: Safe verisons of pushd & popd that call die if the push/pop fails, they also drop stdout.
  5. bl_is_num: Check if a value is a number via regex
  6. bl_retry: Retry a command until it succeeds up to a user specified maximum number of attempts. Escalating delay between attempts.
  7. diff --git a/filehandling/lib b/filehandling/lib index 6804404..7cbad2a 100644 --- a/filehandling/lib +++ b/filehandling/lib @@ -7,6 +7,9 @@ function bl_abs_path() { # generate absolute path from relative path # path : relative filename # return : absolute path + + local path + if [[ -z "${1:-}" ]]; then path="." else diff --git a/git/lib b/git/lib index 38aad12..e382cec 100644 --- a/git/lib +++ b/git/lib @@ -2,19 +2,43 @@ : "${BASH_LIB_DIR:?BASH_LIB_DIR must be set. Please source bash-lib/init before other scripts from bash-lib.}" + +function bl_git_available(){ + type git &>/dev/null || bl_fail "Git binary not found in ${PATH}" +} + +function bl_in_git_repo(){ + bl_git_available + git status >/dev/null || bl_die "$(pwd) is not within a git repo." +} + +function bl_github_owner_repo(){ + bl_in_git_repo + remote="${1:-origin}" + + git remote -v | grep -q "${remote}" || bl_die "Remote ${remote} doesn't exist for repo ${PWD}" + git remote -v | grep -q "${remote}.*github" || bl_die "Remote ${remote} is not a github remote in repo ${PWD}" + [[ "$(git remote -v |grep "${remote}")" =~ github.com[:/]([^ .]*) ]] + echo "${BASH_REMATCH[1]}" +} + # Get the top level of a git repo function bl_repo_root(){ + bl_in_git_repo git rev-parse --show-toplevel } # List files tracked by git function bl_all_files_in_repo(){ + bl_in_git_repo git ls-tree -r HEAD --name-only } # Find the latest tag available at a repo url # Returns tag name, not sha function bl_remote_latest_tag(){ + bl_in_git_repo + local -r remote_url="${1}" # In ls-remote the ^{} suffix refers to a peeled/dereferenced object. # eg refs/tags/v0.0.1^{} shows the SHA of the commit that was tagged, @@ -29,14 +53,19 @@ function bl_remote_latest_tag(){ # Find the SHA of the latests commit to be tagged in a remote repo function bl_remote_latest_tagged_commit(){ + bl_in_git_repo + local -r remote="${1}" local -r tag="$(bl_remote_latest_tag "${remote}")" git ls-remote "${remote}" | awk "/refs\/tags\/${tag}\^/{print \$1}" } function bl_remote_sha_for_ref(){ + bl_in_git_repo + local -r remote="${1}" local -r ref="${2}" + local peeled_ref # First try adding ^{} to the ref, incase it's a tag # and needs peeling. If nothing is found for that, @@ -55,6 +84,8 @@ function bl_remote_sha_for_ref(){ } function bl_remote_tag_for_sha(){ + bl_in_git_repo + local -r remote="${1}" local -r sha="${2}" git ls-remote "${remote}" \ @@ -65,11 +96,13 @@ function bl_remote_tag_for_sha(){ ## Minimal git subtree functionality required for tests to pass # full subtree functionality is not ready for merge. function bl_gittrees_present(){ + bl_in_git_repo local -r git_trees="$(bl_repo_root)/.gittrees" [[ -e "${git_trees}" ]] } function bl_cat_gittrees(){ + bl_in_git_repo local -r git_trees="$(bl_repo_root)/.gittrees" local -r subtrees_file_format=".gittrees should contain one subtree per line,\ space seperated with three fields: subtree_path renmote_url remote_name" @@ -78,6 +111,8 @@ space seperated with three fields: subtree_path renmote_url remote_name" } function bl_tracked_files_excluding_subtrees(){ + bl_in_git_repo + local subtrees if bl_gittrees_present; then subtrees="$(bl_cat_gittrees | awk '{print $1}' | paste -sd '|' -)" bl_all_files_in_repo | grep -E -v "${subtrees}" diff --git a/github/lib b/github/lib new file mode 100644 index 0000000..47da933 --- /dev/null +++ b/github/lib @@ -0,0 +1,121 @@ +#!/bin/bash + +: "${BASH_LIB_DIR:?BASH_LIB_DIR must be set. Please source bash-lib/init before other scripts from bash-lib.}" + +function bl_hub_available(){ + # type instead of which, so it can be stubbed in tests + type hub &>/dev/null || bl_fail "hub (github cli) binary not found, please install it via your package manager or use bl_hub_download_latest." +} + +function bl_hub_creds_available(){ + config_file="${HUB_CONFIG:-${HOME}/.config/hub}" + [[ -n "${GITHUB_USER:-}" ]] && [[ -n "${GITHUB_TOKEN:-}" ]] && return + [[ -e "${config_file}" ]] && return + bl_fail "No credentials found for (git)hub please set GITHUB_USER and GITHUB_TOKEN or create ~/.config/hub" +} + +function bl_hub_check(){ + bl_in_git_repo \ + && bl_hub_available \ + && bl_hub_creds_available +} + +function bl_hub_download_latest(){ + local install_dir="${1:-${HOME}/bin}" + local os_arch="${2:-}" + local tmpdir=".hubdl" + local path + local download_url + local bin_path + + if [[ -z "${os_arch}" ]]; then + if [[ "${OSTYPE}" =~ "darwin" ]]; then + os_arch="darwin-amd64" + else + os_arch="linux-amd64" + fi + bl_debug "Hub Download detected arch: ${os_arch}" + fi + + path="$(curl -s -L https://github.com/github/hub/releases/latest |grep -o '[^"]*hub-'${os_arch}'[^"]*')" + download_url="https://github.com/${path}" + + bin_path="${install_dir}/hub" + mkdir -p "${install_dir}" + + mkdir -p "${tmpdir}" + bl_spushd "${tmpdir}" + curl -s -L "${download_url}" > hub.tgz + tar xf hub.tgz + bl_spopd + mv "${tmpdir}"/*/bin/hub "${bin_path}" + rm -rf "${tmpdir}" + + bl_info "${download_url}/bin/hub --> ${bin_path}" +} + +function bl_hub_issue_number_for_title(){ + local title="${1}" + bl_hub_check + hub issue \ + |grep "${title}" \ + |awk -F'[ #]+' '{print $2}' +} + +function bl_hub_add_issue_comment(){ + local issue_number="${1}" + local comment="${2}" + + bl_hub_check + + [[ -n "${comment}" ]] || bl_die "bl_hub_add_issue_comment: Comment must not be empty" + hub issue show "${issue_number}" >/dev/null || bl_die "Github Issue number ${issue_number} isn't valid for repo $(pwd)" + + owner_repo="$(bl_github_owner_repo)" + if hub api "repos/${owner_repo}/issues/${issue_number}/comments" --field body="${comment}" >/dev/null; then + bl_debug "Added comment: \"${comment}\" to https://github.com/${owner_repo}/issues/${issue_number}" + else + bl_fail "Failed to add comment: ${comment} to issue: ${owner_repo}#${issue_number}" + fi +} + + +function bl_hub_comment_or_create_issue(){ + local title="${1}" + local message="${2}" + local issue_number + local issue_url + local action + local owner_repo + bl_hub_check + + owner_repo="$(bl_github_owner_repo)" + issue_number="$(bl_hub_issue_number_for_title "${title}" ||:)" + + if [[ -z "${issue_number}" ]]; then + action="created" + # issue doesn't exist create it + issue_url="$(hub issue create -m "${title} + +${message}")" + + # Example issue url: https://github.com/{owner}/{repo}/issues/{issue number}" + # To find the issue number, split on / and take the last field + issue_number="$(awk -F'/' '{print $NF}' <<<"${issue_url}" )" + + bl_debug "Created issue: ${issue_url} with title \"${title}\"" + else + issue_url="https://github.com/${owner_repo}/issues/${issue_number}" + action="commented" + bl_debug "Found existing issue for title \"${title}\": ${issue_url}" + bl_hub_add_issue_comment "${issue_number}" "${message}" + fi + cat </dev/null; then @@ -36,24 +41,28 @@ function bl_retry { # Maxiumum amount of fixed delay between attempts # a random value will still be added. local -r MAX_BACKOFF=30 + local rc + local count + local retries + local backoff if [[ ${#} -lt 2 ]]; then bl_die "retry usage: retry " fi - local retries=$1 + retries=$1 shift if ! bl_is_num "${retries}"; then bl_die "Invalid number of retries: ${retries} for command '${*}'". fi - local count=0 + count=0 until eval "$@"; do # Command failed, otherwise until would have skipped the loop # Store return code so it can be reported to the user - exit=$? + rc=$? count=$((count + 1)) if [ "${count}" -lt "${retries}" ]; then # There are still retries left, calculate delay and notify user. @@ -65,12 +74,12 @@ function bl_retry { # Add a random amount to the delay to prevent competing processes # from re-colliding. wait=$(( backoff + (RANDOM % count) )) - bl_info "'${*}' Retry $count/$retries exited $exit, retrying in $wait seconds..." + bl_info "'${*}' Retry $count/$retries exited $rc, retrying in $wait seconds..." sleep $wait else # Out of retries :( - bl_error "Retry $count/$retries exited $exit, no more retries left." - return $exit + bl_error "Retry $count/$retries exited $rc, no more retries left." + return $rc fi done return 0 @@ -84,6 +93,9 @@ function bl_retry_constant { local retries=$1; shift local interval=$1; shift + local count + local rc + local interval if ! bl_is_num "${retries}"; then bl_die "Invalid number of retries: ${retries} for command '${*}'" @@ -93,20 +105,20 @@ function bl_retry_constant { bl_die "Invalid interval in seconds: ${retries} for command '${*}'". fi - local count=0 + count=0 until eval "$@"; do # Command failed, otherwise until would have skipped the loop # Store return code so it can be reported to the user - exit=$? + rc=$? count=$((count + 1)) if [ "${count}" -lt "${retries}" ]; then - bl_info "'${*}' Retry $count/$retries exited $exit, retrying in $interval seconds..." + bl_info "'${*}' Retry $count/$retries exited $rc, retrying in $interval seconds..." sleep "${interval}" else # Out of retries :( - bl_error "Retry $count/$retries exited $exit, no more retries left." - return $exit + bl_error "Retry $count/$retries exited $rc, no more retries left." + return $rc fi done return 0 diff --git a/init b/init index 6205265..3c627c3 100644 --- a/init +++ b/init @@ -26,7 +26,7 @@ BASH_LIB_DIR="${BASH_LIB_DIR_RELATIVE}" # Load the filehandling module for the abspath # function -for lib in helpers logging filehandling git k8s test-utils; do +for lib in helpers logging filehandling git github k8s test-utils; do . "${BASH_LIB_DIR_RELATIVE}/${lib}/lib" done diff --git a/logging/lib b/logging/lib index 5f3f14e..fe29b8b 100644 --- a/logging/lib +++ b/logging/lib @@ -14,7 +14,7 @@ function bl_announce() { } function bl_check_log_level(){ - level="${1}" + local level="${1}" if [[ ${level} =~ debug|info|warn|error|fatal ]]; then return 0 @@ -27,16 +27,16 @@ function bl_check_log_level(){ function bl_log { declare -A BASH_LIB_LOG_LEVELS=( [debug]=1 [info]=2 [warn]=3 [error]=4 [fatal]=5 ) declare -A BASH_LIB_LOG_COLOURS=( [debug]="0;37;40" [info]="0;36;40" [warn]="0;33;40" [error]="1;31;40" [fatal]="1;37;41" ) - runtime_log_level="${BASH_LIB_LOG_LEVEL}" - write_log_level="${1}" - msg="${2}" - out="${3:-stdout}" + local runtime_log_level="${BASH_LIB_LOG_LEVEL}" + local write_log_level="${1}" + local msg="${2}" + local out="${3:-stdout}" bl_check_log_level "${runtime_log_level}" bl_check_log_level "${write_log_level}" - runtime_level_num="${BASH_LIB_LOG_LEVELS[${runtime_log_level}]}" - write_level_num="${BASH_LIB_LOG_LEVELS[${write_log_level}]}" + local runtime_level_num="${BASH_LIB_LOG_LEVELS[${runtime_log_level}]}" + local write_level_num="${BASH_LIB_LOG_LEVELS[${write_log_level}]}" if (( write_level_num < runtime_level_num )); then return diff --git a/tests-for-this-repo/git.bats b/tests-for-this-repo/git.bats index bc84184..9a020ac 100644 --- a/tests-for-this-repo/git.bats +++ b/tests-for-this-repo/git.bats @@ -12,9 +12,9 @@ setup(){ pushd ${repo_dir} git init - git config user.email "ci@cyberark.com" + git config user.email "conj_ops_ci@cyberark.com" git config user.name "Jenkins" - git commit --allow-empty -m "initial" + SKIP_GITLEAKS=YES git commit --allow-empty -m "initial" echo "some content" > a_file git add a_file git commit -a -m "some operations fail on empty repos" @@ -25,6 +25,76 @@ teardown(){ rm -rf "${temp_dir}" } +@test "bl_git_available fails when git is not available" { + real_path="${PATH}" + PATH="" + run bl_git_available + PATH="${real_path}" + assert_failure + assert_output --partial "binary not found" +} + +@test "bl_git_available succeeds when git is available" { + git(){ :; } + run bl_git_available + assert_success + assert_output "" +} + +@test "bl_in_git_repo fails when not in a git repo" { + rm -rf .git + run bl_in_git_repo + assert_failure + assert_output --partial "not within a git repo" +} + +@test "bl_in_git_repo succeeds when in a git repo" { + run bl_in_git_repo + assert_success + assert_output "" +} + +@test "bl_github_owner_repo extracts owner and repo from origin remote" { + git remote add origin git@github.com:owner/repo + run bl_github_owner_repo + assert_success + assert_output "owner/repo" +} + +@test "bl_github_owner_repo fails when origin doesn't exist" { + run bl_github_owner_repo + assert_failure + assert_output --partial "doesn't exist" +} + +@test "bl_github_owner_repo fails when origin doesn't point to github" { + git remote add origin foo@foo.com:owner/repo + run bl_github_owner_repo + assert_failure + assert_output --partial "not a github remote" +} + +@test "bl_github_owner_repo succeeds with https remote" { + git remote add origin "https://github.com/owner/repo" + run bl_github_owner_repo + assert_success + assert_output "owner/repo" +} + +@test "bl_github_owner_repo succeeds with git remote" { + git remote add origin "git@github.com:owner/repo" + run bl_github_owner_repo + assert_success + assert_output "owner/repo" +} + +@test "bl_github_owner_repo succeeds with .git suffix" { + git remote add origin "https://github.com/owner/repo.git" + run bl_github_owner_repo + assert_success + assert_output "owner/repo" +} + @test "bl_repo_root returns root of current repo" { pushd ${BASH_LIB_DIR} run bl_repo_root diff --git a/tests-for-this-repo/github.bats b/tests-for-this-repo/github.bats new file mode 100644 index 0000000..66a6c51 --- /dev/null +++ b/tests-for-this-repo/github.bats @@ -0,0 +1,197 @@ +. "${BASH_LIB_DIR}/test-utils/bats-support/load.bash" +. "${BASH_LIB_DIR}/test-utils/bats-assert-1/load.bash" + +# run before every test +setup(){ + . "${BASH_LIB_DIR}/init" + local -r temp_dir="${BATS_TMPDIR}/testtemp" + local -r repo_dir="${temp_dir/}/repo" + rm -rf "${temp_dir}" + mkdir -p "${repo_dir}" + pushd ${repo_dir} + + git init + git config user.email "conj_ops_ci@cyberark.com" + git config user.name "Jenkins" + SKIP_GITLEAKS=YES git commit --allow-empty -m "initial" + echo "some content" > a_file + git add a_file + git commit -a -m "some operations fail on empty repos" + git remote add origin git@github.com:owner/repo +} + +teardown(){ + local -r temp_dir="${BATS_TMPDIR}/testtemp" + rm -rf "${temp_dir}" + unset GITHUB_TOKEN + unset GITHUB_USER + unset hub +} + +@test "bl_hub_available fails when hub isn't available" { + REAL_PATH="${PATH}" + PATH="${PWD}" + run bl_hub_available + PATH="${REAL_PATH}" + assert_output --partial "github cli" + assert_failure +} + +@test "bl_hub_available succeeds when hub is available" { + hub(){ :; } + run bl_hub_available + assert_success +} + +@test "bl_hub_check fails when not in a git repo" { + rm -rf .git + run bl_hub_check + assert_failure + assert_output --partial "not within a git repo" +} + +@test "bl_hub_check fails when hub not availble" { + bl_in_git_repo(){ :; } + REAL_PATH="${PATH}" + PATH="${PWD}" + run bl_hub_check + PATH="${REAL_PATH}" + assert_output --partial "github cli" + assert_failure +} + +@test "bl_hub_creds_available fails when creds are not available" { + export HUB_CONFIG="./hub_config" + run bl_hub_creds_available + assert_failure + assert_output --partial "No credentials found" +} + +@test "bl_hub_creds_available succeeds when env vars are set" { + export GITHUB_USER=user + export GITHUB_TOKEN=token + export HUB_CONFIG="./hub_config" + run bl_hub_creds_available + unset GITHUB_USER + unset GITHUB_TOKEN + assert_success + assert_output "" +} + +@test "bl_hub_creds_available succeeds when hub config file is present" { + unset GITHUB_USER + unset GITHUB_TOKEN + export HUB_CONFIG="./hub_config" + touch hub_config + run bl_hub_creds_available + assert_success + assert_output "" +} + +@test "bl_hub_check succeeds when in a git repo, hub is available and creds supplied" { + touch hub_config + hub(){ :; } + run bl_hub_check ./hub_config +} + +@test "bl_hub_download_latest downloads hub binary to specified location, with the correct arch" { + run bl_hub_download_latest "${PWD}" + assert_success + assert test -e hub + + run ./hub --version + assert_success + assert_output --partial "hub version" +} + +@test "bl_hub_issue_number_for_title returns only the issue number" { + bl_hub_check(){ :; } + hub(){ + [[ "${1}" == "issue" ]] || bl_die "issue subcommand not specified" + cat </dev/null; then bl_die "Docker must be installed and configured in order to run tests" fi +if ! docker ps >/dev/null; then + bl_die "Docker Daemon must be accessible in order to run tests" +fi + # could be tap, junit or pretty readonly BATS_OUTPUT_FORMAT="${BATS_OUTPUT_FORMAT:-pretty}" readonly BATS_SUITE="${BATS_SUITE:-BATS}" diff --git a/tests-for-this-repo/test-utils.bats b/tests-for-this-repo/test-utils.bats index f63fb19..f0dcd3d 100644 --- a/tests-for-this-repo/test-utils.bats +++ b/tests-for-this-repo/test-utils.bats @@ -56,7 +56,7 @@ bl_docker_safe_tmp(){ date > d git add a c - git commit -a -m "initial" + SKIP_GITLEAKS=YES git commit -a -m "initial" run bl_find_scripts assert_output "a"