From 4220a95de52062a6ddb1aad5f513e4491ca80da4 Mon Sep 17 00:00:00 2001 From: ChristophShyper <45788587+ChristophShyper@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:29:47 +0200 Subject: [PATCH 1/2] feat: migrate from hub to gh CLI for pull request management --- Dockerfile | 18 -- entrypoint.sh | 81 ++++--- tests/docker/local-image.yml | 2 +- tests/unit/test_pr_create_with_gh.sh | 210 +++++++++++++++++++ tests/unit/test_template_source_selection.sh | 34 +-- 5 files changed, 276 insertions(+), 69 deletions(-) create mode 100755 tests/unit/test_pr_create_with_gh.sh diff --git a/Dockerfile b/Dockerfile index caf6251..de139a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,5 @@ FROM alpine:3.23.4 -ARG TARGETARCH -ARG HUB_VERSION=2.14.2 - # Copy all needed files COPY entrypoint.sh / COPY scripts/ /scripts/ @@ -14,22 +11,7 @@ SHELL ["/bin/sh", "-euxo", "pipefail", "-c"] RUN set -eux; \ xargs -r apk add --no-cache < /tmp/alpine-packages.txt; \ chmod +x /entrypoint.sh /scripts/replace-template-diff.sh /scripts/split_content_bytes.py; \ - targetarch="${TARGETARCH:-}"; \ - if [ -z "${targetarch}" ]; then \ - case "$(uname -m)" in \ - x86_64) targetarch="amd64" ;; \ - aarch64|arm64) targetarch="arm64" ;; \ - *) echo "Unsupported host architecture: $(uname -m)"; exit 1 ;; \ - esac; \ - fi; \ - case "${targetarch}" in amd64|arm64) ;; *) echo "Unsupported TARGETARCH: ${targetarch}"; exit 1 ;; esac; \ - hub_archive="hub-linux-${targetarch}-${HUB_VERSION}.tgz"; \ - hub_url="https://github.com/mislav/hub/releases/download/v${HUB_VERSION}/${hub_archive}"; \ - curl -fsSL "${hub_url}" -o /tmp/hub.tgz; \ - tar -xzf /tmp/hub.tgz -C /tmp; \ - install -m 0755 "/tmp/hub-linux-${targetarch}-${HUB_VERSION}/bin/hub" /usr/bin/hub; \ gh --version; \ - test -x /usr/bin/hub; \ git --version; \ jq --version; \ python3 --version; \ diff --git a/entrypoint.sh b/entrypoint.sh index 6a1a7c7..2f4247e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -19,6 +19,7 @@ CHUNK_COUNT=0 MANAGED_COMMENT_START="" MANAGED_COMMENT_END="" TARGET_REPOSITORY="" +TARGET_OWNER="" REPOSITORY_PATH="" WORKSPACE_DIR="" REPO_DIR="" @@ -152,6 +153,33 @@ print(pathlib.Path(sys.argv[1]).resolve(strict=False)) PY } +trim_whitespace() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "${value}" +} + +append_csv_arg() { + local flag="$1" + local csv_value="$2" + local -n args_ref="$3" + local raw_value trimmed_value + local -a parsed_values + + if [[ -z "${csv_value}" ]]; then + return 0 + fi + + IFS=',' read -r -a parsed_values <<< "${csv_value}" + for raw_value in "${parsed_values[@]}"; do + trimmed_value="$(trim_whitespace "${raw_value}")" + if [[ -n "${trimmed_value}" ]]; then + args_ref+=("${flag}" "${trimmed_value}") + fi + done +} + get_managed_comment_ids() { local pr_number="$1" local output_file="$2" @@ -289,6 +317,7 @@ if [[ ! "${TARGET_REPOSITORY}" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})/[A-Za-z0-9._- echo -e "\n[ERROR] Input 'repository' must use owner/name format. Got: ${TARGET_REPOSITORY}" >&2 exit 1 fi +TARGET_OWNER="${TARGET_REPOSITORY%%/*}" REPOSITORY_PATH="${INPUT_REPOSITORY_PATH:-.}" if [[ -z "${REPOSITORY_PATH}" ]]; then @@ -329,8 +358,6 @@ echo -e "\nSetting GitHub credentials..." git -C "${REPO_DIR}" remote set-url origin "https://${GITHUB_ACTOR}:${INPUT_GITHUB_TOKEN}@github.com/${TARGET_REPOSITORY}" git -C "${REPO_DIR}" config user.name "${GITHUB_ACTOR}" git -C "${REPO_DIR}" config user.email "${GITHUB_ACTOR}@users.noreply.github.com" -# Needed for hub binary -export GITHUB_USER="${GITHUB_ACTOR}" echo "Repository: ${TARGET_REPOSITORY}" echo "Repository path: ${REPO_DIR}" @@ -379,7 +406,7 @@ else fi echo -e "\nSetting template..." -PR_NUMBER=$(hub pr list --base "${TARGET_BRANCH}" --head "${SOURCE_BRANCH}" --format '%I') +PR_NUMBER="$(gh pr list --repo "${TARGET_REPOSITORY}" --state open --base "${TARGET_BRANCH}" --head "${TARGET_OWNER}:${SOURCE_BRANCH}" --json number --jq '.[0].number // empty')" if [[ -z "${PR_NUMBER}" ]]; then if [[ -n "${INPUT_TEMPLATE}" ]]; then echo "Template source: input template file" @@ -401,7 +428,7 @@ else TEMPLATE="${INPUT_BODY}" else echo "Template source: existing pull request body" - TEMPLATE=$(hub api --method GET "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" | jq -r '.body') + TEMPLATE="$(gh api --method GET "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.body // ""')" fi fi @@ -480,22 +507,6 @@ if [[ -z "${PR_NUMBER}" ]]; then else TITLE=$(git log -1 --pretty=%s | head -1) fi - ARG_LIST=("-F" "/tmp/template") - if [[ -n "${INPUT_REVIEWER}" ]]; then - ARG_LIST+=("-r" "${INPUT_REVIEWER}") - fi - if [[ -n "${INPUT_ASSIGNEE}" ]]; then - ARG_LIST+=("-a" "${INPUT_ASSIGNEE}") - fi - if [[ -n "${INPUT_LABEL}" ]]; then - ARG_LIST+=("-l" "${INPUT_LABEL}") - fi - if [[ -n "${INPUT_MILESTONE}" ]]; then - ARG_LIST+=("-M" "${INPUT_MILESTONE}") - fi - if [[ "${INPUT_DRAFT}" == "true" ]]; then - ARG_LIST+=("-d") - fi else echo -e "${TEMPLATE}" > /tmp/template fi @@ -510,23 +521,39 @@ echo "Final main body size (bytes): ${FINAL_BODY_BYTES}" echo "Managed overflow chunks: ${CHUNK_COUNT}" if [[ -z "${PR_NUMBER}" ]]; then + GH_CREATE_ARGS=( + --repo "${TARGET_REPOSITORY}" + --base "${TARGET_BRANCH}" + --head "${TARGET_OWNER}:${SOURCE_BRANCH}" + --title "${TITLE}" + --body-file /tmp/template + ) + append_csv_arg --reviewer "${INPUT_REVIEWER}" GH_CREATE_ARGS + append_csv_arg --assignee "${INPUT_ASSIGNEE}" GH_CREATE_ARGS + append_csv_arg --label "${INPUT_LABEL}" GH_CREATE_ARGS + milestone_value="$(trim_whitespace "${INPUT_MILESTONE}")" + if [[ -n "${milestone_value}" ]]; then + GH_CREATE_ARGS+=(--milestone "${milestone_value}") + fi + if [[ "${INPUT_DRAFT}" == "true" ]]; then + GH_CREATE_ARGS+=(--draft) + fi + echo -e "\nCreating pull request" - echo -e "${TITLE}" > /tmp/template - echo -e "\n${TEMPLATE}" >> /tmp/template echo -e "\nTemplate:" cat /tmp/template - echo -e "\nRunning: hub pull-request -b ${TARGET_BRANCH} -h ${SOURCE_BRANCH} --no-edit ..." - URL=$(hub pull-request -b "${TARGET_BRANCH}" -h "${SOURCE_BRANCH}" --no-edit "${ARG_LIST[@]}") + echo -e "\nRunning: gh pr create --repo ${TARGET_REPOSITORY} --base ${TARGET_BRANCH} --head ${TARGET_OWNER}:${SOURCE_BRANCH} ..." + URL="$(gh pr create "${GH_CREATE_ARGS[@]}")" # shellcheck disable=SC2181 if [[ "$?" != "0" ]]; then RET_CODE=1; fi - PR_NUMBER=$(gh pr view --json number -q .number "${URL}") + PR_NUMBER="$(gh pr view "${URL}" --repo "${TARGET_REPOSITORY}" --json number --jq '.number')" if (( CHUNK_COUNT > 0 )); then reconcile_managed_comments "${PR_NUMBER}" "${CHUNK_COUNT}" fi else echo -e "\nUpdating pull request" - echo -e "Running: hub api --method PATCH repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER} --field body=@/tmp/template" - URL=$(hub api --method PATCH "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --field "body=@/tmp/template" | jq -r '.html_url') + echo -e "Running: gh api --method PATCH repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER} --field body=@/tmp/template" + URL="$(gh api --method PATCH "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --field "body=@/tmp/template" --jq '.html_url')" # shellcheck disable=SC2181 if [[ "$?" != "0" ]]; then RET_CODE=1; fi if (( CHUNK_COUNT > 0 )); then diff --git a/tests/docker/local-image.yml b/tests/docker/local-image.yml index 3d750f6..13fde13 100644 --- a/tests/docker/local-image.yml +++ b/tests/docker/local-image.yml @@ -10,7 +10,7 @@ commandTests: command: bash args: - -lc - - command -v bash >/dev/null 2>&1 && command -v git >/dev/null 2>&1 && command -v gh >/dev/null 2>&1 && command -v hub >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 && command -v curl >/dev/null 2>&1 + - command -v bash >/dev/null 2>&1 && command -v git >/dev/null 2>&1 && command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 && command -v curl >/dev/null 2>&1 && ! command -v hub >/dev/null 2>&1 - name: Temporary and APK cache cleaned command: bash diff --git a/tests/unit/test_pr_create_with_gh.sh b/tests/unit/test_pr_create_with_gh.sh new file mode 100755 index 0000000..6af46ab --- /dev/null +++ b/tests/unit/test_pr_create_with_gh.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +SCRIPT_PATH="${SCRIPT_DIR}/../../entrypoint.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +assert_contains() { + local file_path="$1" + local expected="$2" + if ! grep -Fq -- "${expected}" "${file_path}"; then + echo "Assertion failed. Expected to find: ${expected}" >&2 + echo "----- FILE CONTENT -----" >&2 + cat "${file_path}" >&2 + exit 1 + fi +} + +mkdir -p "${TMP_DIR}/bin" +mkdir -p "${TMP_DIR}/repo" + +cat > "${TMP_DIR}/bin/git" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail + +args=("$@") +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "-C" ]]; then + args=("${args[@]:2}") +fi + +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "config" && "${args[1]}" == "--global" ]]; then + exit 0 +fi + +if [[ "${#args[@]}" -ge 1 && "${args[0]}" == "config" ]]; then + exit 0 +fi + +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "remote" && "${args[1]}" == "set-url" ]]; then + exit 0 +fi + +if [[ "${#args[@]}" -ge 1 && "${args[0]}" == "fetch" ]]; then + exit 0 +fi + +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "rev-parse" && "${args[1]}" == "--is-inside-work-tree" ]]; then + echo "true" + exit 0 +fi + +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "show-ref" ]]; then + last_arg="${args[$((${#args[@]} - 1))]}" + if [[ "${last_arg}" == "refs/remotes/origin/develop" || "${last_arg}" == "refs/remotes/origin/release/MAPL-v3" ]]; then + exit 0 + fi + exit 1 +fi + +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "rev-parse" ]]; then + last_arg="${args[$((${#args[@]} - 1))]}" + if [[ "${last_arg}" == "origin/develop" ]]; then + echo "bbb222" + exit 0 + fi + if [[ "${last_arg}" == "origin/release/MAPL-v3" ]]; then + echo "aaa111" + exit 0 + fi +fi + +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "diff" && "${args[1]}" == "--quiet" ]]; then + exit 1 +fi + +if [[ "${#args[@]}" -ge 1 && "${args[0]}" == "diff" ]]; then + echo "M README.md" + exit 0 +fi + +if [[ "${#args[@]}" -ge 1 && "${args[0]}" == "log" ]]; then + echo "stub log" + exit 0 +fi + +if [[ "${#args[@]}" -ge 2 && "${args[0]}" == "symbolic-ref" ]]; then + echo "develop" + exit 0 +fi + +echo "Unsupported git call: $*" >&2 +exit 1 +EOF + +cat > "${TMP_DIR}/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail + +cmd="$*" + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "list" ]]; then + exit 0 +fi + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "create" ]]; then + if [[ "${cmd}" != *"--repo owner/repo"* ]]; then + echo "Missing --repo" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--base release/MAPL-v3"* ]]; then + echo "Missing --base" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--head owner:develop"* ]]; then + echo "Missing --head" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--title My PR title"* ]]; then + echo "Missing --title" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--body-file /tmp/template"* ]]; then + echo "Missing --body-file" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--reviewer alice"* || "${cmd}" != *"--reviewer bob"* ]]; then + echo "Missing reviewers" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--assignee assignee1"* || "${cmd}" != *"--assignee assignee2"* ]]; then + echo "Missing assignees" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--label bug"* || "${cmd}" != *"--label chore"* ]]; then + echo "Missing labels" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--milestone Milestone-1"* ]]; then + echo "Missing milestone" >&2 + exit 1 + fi + if [[ "${cmd}" != *"--draft"* ]]; then + echo "Missing draft flag" >&2 + exit 1 + fi + + echo "https://example.test/pr/456" + exit 0 +fi + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "view" ]]; then + echo "456" + exit 0 +fi + +echo "Unsupported gh call: $*" >&2 +exit 1 +EOF + +cat > "${TMP_DIR}/template.md" <<'EOF' +## Template body from file +EOF + +chmod +x "${TMP_DIR}/bin/git" "${TMP_DIR}/bin/gh" + +LOG_FILE="${TMP_DIR}/run.log" +set +e +PATH="${TMP_DIR}/bin:${PATH}" \ +GITHUB_ACTOR="ci-user" \ +GITHUB_TOKEN="token" \ +GITHUB_REPOSITORY="owner/repo" \ +GITHUB_WORKSPACE="${TMP_DIR}" \ +GITHUB_OUTPUT="${TMP_DIR}/output.txt" \ +INPUT_GITHUB_TOKEN="token" \ +INPUT_REPOSITORY_PATH="repo" \ +INPUT_SOURCE_BRANCH="develop" \ +INPUT_TARGET_BRANCH="release/MAPL-v3" \ +INPUT_TITLE="My PR title" \ +INPUT_TEMPLATE="${TMP_DIR}/template.md" \ +INPUT_BODY="" \ +INPUT_REVIEWER="alice,bob" \ +INPUT_ASSIGNEE="assignee1,assignee2" \ +INPUT_LABEL="bug,chore" \ +INPUT_MILESTONE="Milestone-1" \ +INPUT_DRAFT="true" \ +INPUT_GET_DIFF="false" \ +INPUT_OLD_STRING="" \ +INPUT_NEW_STRING="" \ +INPUT_IGNORE_USERS="dependabot" \ +INPUT_ALLOW_NO_DIFF="false" \ +INPUT_MAX_BODY_BYTES="65000" \ +INPUT_MAX_DIFF_LINES="0" \ +bash "${SCRIPT_PATH}" >"${LOG_FILE}" 2>&1 +STATUS="$?" +set -e + +if [[ "${STATUS}" != "0" ]]; then + echo "Expected successful execution in create mode" >&2 + cat "${LOG_FILE}" >&2 + exit 1 +fi + +assert_contains "${LOG_FILE}" "Creating pull request" +assert_contains "${LOG_FILE}" "Running: gh pr create --repo owner/repo --base release/MAPL-v3 --head owner:develop" +assert_contains "${TMP_DIR}/output.txt" "url=https://example.test/pr/456" +assert_contains "${TMP_DIR}/output.txt" "pr_number=456" + +echo "GH create flow test passed." diff --git a/tests/unit/test_template_source_selection.sh b/tests/unit/test_template_source_selection.sh index 46386c7..1b91628 100755 --- a/tests/unit/test_template_source_selection.sh +++ b/tests/unit/test_template_source_selection.sh @@ -94,7 +94,7 @@ echo "Unsupported git call: $*" >&2 exit 1 EOF -cat > "${TMP_DIR}/bin/hub" <<'EOF' +cat > "${TMP_DIR}/bin/gh" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail @@ -103,30 +103,18 @@ if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "list" ]]; then exit 0 fi -if [[ "$#" -ge 3 && "$1" == "api" && "$2" == "--method" && "$3" == "GET" ]]; then - echo '{"body":"OLD BODY WITHOUT MARKERS"}' - exit 0 -fi - -if [[ "$#" -ge 3 && "$1" == "api" && "$2" == "--method" && "$3" == "PATCH" ]]; then - echo '{"html_url":"https://example.test/pr/123"}' - exit 0 -fi - -echo "Unsupported hub call: $*" >&2 -exit 1 -EOF - -cat > "${TMP_DIR}/bin/gh" <<'EOF' -#!/usr/bin/env bash -set -Eeuo pipefail - -if [[ "$#" -ge 2 && "$1" == "api" ]]; then - if [[ "$2" == "repos/owner/repo/issues/123/comments" ]]; then +if [[ "$#" -ge 1 && "$1" == "api" ]]; then + cmd="$*" + if [[ "${cmd}" == *"repos/owner/repo/issues/123/comments"* ]]; then echo "[]" exit 0 fi - if [[ "$2" == "repos/owner/repo/issues/comments/"* ]]; then + if [[ "${cmd}" == *"repos/owner/repo/pulls/123"* && "${cmd}" == *"--method GET"* ]]; then + echo "OLD BODY WITHOUT MARKERS" + exit 0 + fi + if [[ "${cmd}" == *"repos/owner/repo/pulls/123"* && "${cmd}" == *"--method PATCH"* ]]; then + echo "https://example.test/pr/123" exit 0 fi fi @@ -141,7 +129,7 @@ cat > "${TMP_DIR}/template.md" <<'EOF' EOF -chmod +x "${TMP_DIR}/bin/git" "${TMP_DIR}/bin/hub" "${TMP_DIR}/bin/gh" +chmod +x "${TMP_DIR}/bin/git" "${TMP_DIR}/bin/gh" LOG_FILE="${TMP_DIR}/run.log" set +e From fbcf91fd8369078d153ee88fff6a19ab09b6c1c9 Mon Sep 17 00:00:00 2001 From: ChristophShyper <45788587+ChristophShyper@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:37:10 +0200 Subject: [PATCH 2/2] feat: migrate from hub to gh CLI for pull request management --- tests/docker/local-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docker/local-image.yml b/tests/docker/local-image.yml index 13fde13..e23abf5 100644 --- a/tests/docker/local-image.yml +++ b/tests/docker/local-image.yml @@ -10,7 +10,7 @@ commandTests: command: bash args: - -lc - - command -v bash >/dev/null 2>&1 && command -v git >/dev/null 2>&1 && command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 && command -v curl >/dev/null 2>&1 && ! command -v hub >/dev/null 2>&1 + - command -v bash >/dev/null 2>&1 && command -v git >/dev/null 2>&1 && command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 && command -v curl >/dev/null 2>&1 - name: Temporary and APK cache cleaned command: bash