diff --git a/README.md b/README.md index 66ff6df..fd6944d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ This action supports three tag levels for flexible versioning: reviewer: octocat assignee: octocat label: enhancement + create_missing_labels: false milestone: My milestone project: Engineering Roadmap draft: true @@ -82,29 +83,30 @@ This action supports three tag levels for flexible versioning: ### 🔧 Input Parameters -| Input Variable | Required | Default | Description | -|-------------------|----------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------| -| `github_token` | **Yes** | `""` | GitHub token `${{ secrets.GITHUB_TOKEN }}` | -| `repository` | No | `${{ github.repository }}` | Target repository in `owner/name` format used for API calls and git remote auth | -| `repository_path` | No | `.` | Relative path under `GITHUB_WORKSPACE` to the checked-out repository | -| `source_branch` | No | *current branch* | Name of the source branch | -| `target_branch` | No | `master` | Name of the target branch. Change it if you use `main` | -| `title` | No | *subject of the first commit* | Pull request title | -| `template` | No | `""` | Template file location | -| `body` | No | *list of commits* | Pull request body | -| `reviewer` | No | `""` | Reviewer's username | -| `assignee` | No | `""` | Assignee's usernames | -| `label` | No | `""` | Labels to apply, comma separated | -| `milestone` | No | `""` | Milestone | -| `project` | No | `""` | GitHub Project title to add the pull request to | -| `draft` | No | `false` | Whether to mark it as a draft | -| `old_string` | No | `""` | Old string for the replacement in the template | -| `new_string` | No | `""` | New string for the replacement in the template. If not specified, but `old_string` was, it will gather commits subjects | -| `get_diff` | No | `false` | Whether to replace predefined comments with differences between branches - see details below | -| `ignore_users` | No | `"dependabot"` | List of users to ignore, comma separated | -| `allow_no_diff` | No | `false` | Allows to continue on merge commits with no diffs | -| `max_body_bytes` | No | `65000` | Maximum PR body size in bytes before overflow is posted as managed PR comments | -| `max_diff_lines` | No | `0` | Maximum lines per generated diff section (`0` means unlimited) | +| Input Variable | Required | Default | Description | +|-------------------------|----------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------| +| `github_token` | **Yes** | `""` | GitHub token `${{ secrets.GITHUB_TOKEN }}` | +| `repository` | No | `${{ github.repository }}` | Target repository in `owner/name` format used for API calls and git remote auth | +| `repository_path` | No | `.` | Relative path under `GITHUB_WORKSPACE` to the checked-out repository | +| `source_branch` | No | *current branch* | Name of the source branch | +| `target_branch` | No | `master` | Name of the target branch. Change it if you use `main` | +| `title` | No | *subject of the first commit* | Pull request title | +| `template` | No | `""` | Template file location | +| `body` | No | *list of commits* | Pull request body | +| `reviewer` | No | `""` | Reviewer's username | +| `assignee` | No | `""` | Assignee's usernames | +| `label` | No | `""` | Labels to apply, comma separated. GitHub-supported special characters such as `/` work; commas remain the separator. | +| `create_missing_labels` | No | `false` | Create labels that do not exist yet before PR creation. Existing labels are reused without being refreshed. | +| `milestone` | No | `""` | Milestone | +| `project` | No | `""` | GitHub Project title to add the pull request to | +| `draft` | No | `false` | Whether to mark it as a draft | +| `old_string` | No | `""` | Old string for the replacement in the template | +| `new_string` | No | `""` | New string for the replacement in the template. If not specified, but `old_string` was, it will gather commits subjects | +| `get_diff` | No | `false` | Whether to replace predefined comments with differences between branches - see details below | +| `ignore_users` | No | `"dependabot"` | List of users to ignore, comma separated | +| `allow_no_diff` | No | `false` | Allows to continue on merge commits with no diffs | +| `max_body_bytes` | No | `65000` | Maximum PR body size in bytes before overflow is posted as managed PR comments | +| `max_diff_lines` | No | `0` | Maximum lines per generated diff section (`0` means unlimited) | ### 🔐 Required Workflow Permissions @@ -121,6 +123,7 @@ permissions: - `contents: read` is required to read repository state. - `pull-requests: write` is required to create and update pull requests. - `issues: write` is required when managed overflow comments are created, updated, or deleted (including cleanup on later runs). +- `issues: write` is also required when `create_missing_labels=true`, because label creation uses the repository labels API. - Project assignment via `project` requires a token/auth context that `gh` can use with project access. @@ -219,7 +222,8 @@ jobs: repository_path: repo title: ${{ github.event.commits[0].message }} assignee: ${{ github.actor }} - label: automatic,feature + label: automatic,team/platform + create_missing_labels: true project: Engineering Roadmap template: .github/PULL_REQUEST_TEMPLATE/FEATURE.md old_string: "**Write your description here**" diff --git a/action.yml b/action.yml index a21939d..62826c0 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,10 @@ inputs: description: Labels to apply, coma separated required: false default: "" + create_missing_labels: + description: Whether to create labels that do not already exist before creating the PR + required: false + default: "false" milestone: description: Milestone required: false diff --git a/entrypoint.sh b/entrypoint.sh index 33b069f..0537149 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -24,6 +24,12 @@ REPOSITORY_PATH="" WORKSPACE_DIR="" REPO_DIR="" PROJECT_VALUE="" +ALLOW_NO_DIFF_VALUE="" +GET_DIFF_VALUE="" +DRAFT_VALUE="" +CREATE_MISSING_LABELS_VALUE="" +REPOSITORY_LABELS_JSON="" +DEFAULT_CREATED_LABEL_COLOR="0366d6" REPLACE_TEMPLATE_SCRIPT="/scripts/replace-template-diff.sh" if [[ ! -x "${REPLACE_TEMPLATE_SCRIPT}" ]]; then @@ -88,6 +94,19 @@ validate_number_input() { fi } +validate_boolean_input() { + local value="$1" + local input_name="$2" + + case "${value}" in + true|false) ;; + *) + echo -e "\n[ERROR] Input '${input_name}' must be 'true' or 'false'. Got: ${value}" >&2 + exit 1 + ;; + esac +} + apply_line_cap() { local file_path="$1" local max_lines="$2" @@ -184,24 +203,134 @@ trim_whitespace() { : "${INPUT_ALLOW_NO_DIFF:=false}" : "${INPUT_MAX_BODY_BYTES:=65000}" : "${INPUT_MAX_DIFF_LINES:=0}" +: "${INPUT_CREATE_MISSING_LABELS:=false}" + +parse_csv_values() { + local csv_value="$1" + local -n values_ref="$2" + local raw_value trimmed_value + local value + local -a raw_values=() + local -A seen_values=() + + values_ref=() + + if [[ -z "${csv_value}" ]]; then + return 0 + fi + + IFS=',' read -r -a raw_values <<< "${csv_value}" + for raw_value in "${raw_values[@]}"; do + trimmed_value="$(trim_whitespace "${raw_value}")" + if [[ -z "${trimmed_value}" || -n "${seen_values["${trimmed_value}"]+x}" ]]; then + continue + fi + values_ref+=("${trimmed_value}") + seen_values["${trimmed_value}"]=1 + done +} append_csv_arg() { local flag="$1" local csv_value="$2" local -n args_ref="$3" - local raw_value trimmed_value + local value local -a parsed_values - if [[ -z "${csv_value}" ]]; then + parse_csv_values "${csv_value}" parsed_values + for value in "${parsed_values[@]}"; do + if [[ -n "${value}" ]]; then + args_ref+=("${flag}" "${value}") + fi + done +} + +load_repository_labels_json() { + if [[ -n "${REPOSITORY_LABELS_JSON}" ]]; 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 + REPOSITORY_LABELS_JSON="$( + gh api "repos/${TARGET_REPOSITORY}/labels?per_page=100" --paginate | jq -sc ' + [ + .[] + | if type == "array" then .[] else . end + ] + ' + )" +} + +label_exists() { + local label_name="$1" + + load_repository_labels_json + jq -e --arg name "${label_name}" 'any(.[]?; (.name // "") == $name)' <<< "${REPOSITORY_LABELS_JSON}" >/dev/null +} + +append_label_to_cache() { + local label_name="$1" + + if [[ -z "${REPOSITORY_LABELS_JSON}" ]]; then + return 0 + fi + + REPOSITORY_LABELS_JSON="$( + jq -c --arg name "${label_name}" '. + [{"name": $name}]' <<< "${REPOSITORY_LABELS_JSON}" + )" +} + +create_label_if_missing() { + local label_name="$1" + local create_output="" + local create_status=0 + + if label_exists "${label_name}"; then + echo -e "\n[INFO] Label already exists: ${label_name}" + return 0 + fi + + echo -e "\n[INFO] Creating missing label: ${label_name}" + set +e + create_output="$( + gh api --method POST "repos/${TARGET_REPOSITORY}/labels" \ + --raw-field "name=${label_name}" \ + --raw-field "color=${DEFAULT_CREATED_LABEL_COLOR}" 2>&1 + )" + create_status="$?" + set -e + + if [[ "${create_status}" == "0" ]]; then + append_label_to_cache "${label_name}" + return 0 + fi + + REPOSITORY_LABELS_JSON="" + if label_exists "${label_name}"; then + echo -e "\n[INFO] Label became available during creation attempt: ${label_name}" + return 0 + fi + + echo -e "\n[ERROR] Failed to create label '${label_name}'." >&2 + echo "${create_output}" >&2 + return "${create_status}" +} + +ensure_labels_exist() { + local csv_labels="$1" + local label_name + local -a label_values=() + + if [[ "${CREATE_MISSING_LABELS_VALUE}" != "true" ]]; then + return 0 + fi + + parse_csv_values "${csv_labels}" label_values + if [[ "${#label_values[@]}" == "0" ]]; then + return 0 + fi + + for label_name in "${label_values[@]}"; do + create_label_if_missing "${label_name}" done } @@ -351,12 +480,21 @@ echo " ignore_users: ${INPUT_IGNORE_USERS}" echo " allow_no_diff: ${INPUT_ALLOW_NO_DIFF}" echo " max_body_bytes: ${INPUT_MAX_BODY_BYTES}" echo " max_diff_lines: ${INPUT_MAX_DIFF_LINES}" +echo " create_missing_labels: ${INPUT_CREATE_MISSING_LABELS}" MAX_BODY_BYTES="${INPUT_MAX_BODY_BYTES:-65000}" MAX_DIFF_LINES="${INPUT_MAX_DIFF_LINES:-0}" PROJECT_VALUE="$(trim_whitespace "${INPUT_PROJECT}")" +ALLOW_NO_DIFF_VALUE="$(trim_whitespace "${INPUT_ALLOW_NO_DIFF}")" +GET_DIFF_VALUE="$(trim_whitespace "${INPUT_GET_DIFF}")" +DRAFT_VALUE="$(trim_whitespace "${INPUT_DRAFT}")" +CREATE_MISSING_LABELS_VALUE="$(trim_whitespace "${INPUT_CREATE_MISSING_LABELS}")" validate_number_input "${MAX_BODY_BYTES}" "max_body_bytes" validate_number_input "${MAX_DIFF_LINES}" "max_diff_lines" +validate_boolean_input "${ALLOW_NO_DIFF_VALUE}" "allow_no_diff" +validate_boolean_input "${GET_DIFF_VALUE}" "get_diff" +validate_boolean_input "${DRAFT_VALUE}" "draft" +validate_boolean_input "${CREATE_MISSING_LABELS_VALUE}" "create_missing_labels" if (( MAX_BODY_BYTES < 2048 )); then echo -e "\n[ERROR] Input 'max_body_bytes' must be at least 2048. Got: ${MAX_BODY_BYTES}" >&2 @@ -470,7 +608,7 @@ fi echo -e "\nComparing branches by diff..." if git diff --quiet "${TARGET_COMPARE_REF}...${SOURCE_COMPARE_REF}"; then - if [[ "${INPUT_ALLOW_NO_DIFF}" == "true" ]]; then + if [[ "${ALLOW_NO_DIFF_VALUE}" == "true" ]]; then echo -e "\n[INFO] Both branches are the same. Continuing." else echo -e "\n[INFO] Both branches are the same. No action needed." @@ -522,7 +660,7 @@ if [[ -n "${INPUT_OLD_STRING}" ]]; then fi fi -if [[ "${INPUT_GET_DIFF}" == "true" ]]; then +if [[ "${GET_DIFF_VALUE}" == "true" ]]; then echo -e "\nReplacing predefined fields with git information..." REPLACE_SUMMARY="false" REPLACE_COMMITS="false" @@ -607,6 +745,7 @@ if [[ -z "${PR_NUMBER}" ]]; then --title "${TITLE}" --body-file /tmp/template ) + ensure_labels_exist "${INPUT_LABEL}" 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 @@ -617,7 +756,7 @@ if [[ -z "${PR_NUMBER}" ]]; then if [[ -n "${PROJECT_VALUE}" ]]; then GH_CREATE_ARGS+=(--project "${PROJECT_VALUE}") fi - if [[ "${INPUT_DRAFT}" == "true" ]]; then + if [[ "${DRAFT_VALUE}" == "true" ]]; then GH_CREATE_ARGS+=(--draft) fi diff --git a/tests/unit/test_input_limits_validation.sh b/tests/unit/test_input_limits_validation.sh index 91560f6..4747968 100755 --- a/tests/unit/test_input_limits_validation.sh +++ b/tests/unit/test_input_limits_validation.sh @@ -20,40 +20,56 @@ assert_contains() { LOG_FILE="${TMP_DIR}/run.log" -set +e -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_SOURCE_BRANCH="develop" \ -INPUT_TARGET_BRANCH="main" \ -INPUT_TITLE="" \ -INPUT_TEMPLATE="" \ -INPUT_BODY="" \ -INPUT_REVIEWER="" \ -INPUT_ASSIGNEE="" \ -INPUT_LABEL="" \ -INPUT_MILESTONE="" \ -INPUT_DRAFT="false" \ -INPUT_GET_DIFF="false" \ -INPUT_OLD_STRING="" \ -INPUT_NEW_STRING="" \ -INPUT_IGNORE_USERS="dependabot" \ -INPUT_ALLOW_NO_DIFF="false" \ -INPUT_MAX_BODY_BYTES="invalid" \ -INPUT_MAX_DIFF_LINES="0" \ -bash "${SCRIPT_PATH}" >"${LOG_FILE}" 2>&1 -STATUS="$?" -set -e - -if [[ "${STATUS}" == "0" ]]; then - echo "Expected non-zero exit code for invalid max_body_bytes" >&2 - cat "${LOG_FILE}" >&2 - exit 1 -fi - -assert_contains "${LOG_FILE}" "Input 'max_body_bytes' must be a non-negative integer" +run_invalid_case() { + local draft_value="$1" + local get_diff_value="$2" + local allow_no_diff_value="$3" + local create_missing_labels_value="$4" + local max_body_bytes_value="$5" + local expected_message="$6" + + set +e + 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_SOURCE_BRANCH="develop" \ + INPUT_TARGET_BRANCH="main" \ + INPUT_TITLE="" \ + INPUT_TEMPLATE="" \ + INPUT_BODY="" \ + INPUT_REVIEWER="" \ + INPUT_ASSIGNEE="" \ + INPUT_LABEL="" \ + INPUT_MILESTONE="" \ + INPUT_DRAFT="${draft_value}" \ + INPUT_GET_DIFF="${get_diff_value}" \ + INPUT_OLD_STRING="" \ + INPUT_NEW_STRING="" \ + INPUT_IGNORE_USERS="dependabot" \ + INPUT_ALLOW_NO_DIFF="${allow_no_diff_value}" \ + INPUT_MAX_BODY_BYTES="${max_body_bytes_value}" \ + INPUT_MAX_DIFF_LINES="0" \ + INPUT_CREATE_MISSING_LABELS="${create_missing_labels_value}" \ + bash "${SCRIPT_PATH}" >"${LOG_FILE}" 2>&1 + STATUS="$?" + set -e + + if [[ "${STATUS}" == "0" ]]; then + echo "Expected non-zero exit code for invalid input" >&2 + cat "${LOG_FILE}" >&2 + exit 1 + fi + + assert_contains "${LOG_FILE}" "${expected_message}" +} + +run_invalid_case "false" "false" "false" "false" "invalid" "Input 'max_body_bytes' must be a non-negative integer" +run_invalid_case "maybe" "false" "false" "false" "65000" "Input 'draft' must be 'true' or 'false'. Got: maybe" +run_invalid_case "false" "sometimes" "false" "false" "65000" "Input 'get_diff' must be 'true' or 'false'. Got: sometimes" +run_invalid_case "false" "false" "1" "false" "65000" "Input 'allow_no_diff' must be 'true' or 'false'. Got: 1" +run_invalid_case "false" "false" "false" "sure" "65000" "Input 'create_missing_labels' must be 'true' or 'false'. Got: sure" echo "Input limits validation test passed." diff --git a/tests/unit/test_label_creation.sh b/tests/unit/test_label_creation.sh new file mode 100755 index 0000000..2760822 --- /dev/null +++ b/tests/unit/test_label_creation.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +SCRIPT_PATH="${SCRIPT_DIR}/../../entrypoint.sh" + +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 +} + +assert_not_contains() { + local file_path="$1" + local unexpected="$2" + if grep -Fq -- "${unexpected}" "${file_path}"; then + echo "Assertion failed. Expected not to find: ${unexpected}" >&2 + echo "----- FILE CONTENT -----" >&2 + cat "${file_path}" >&2 + exit 1 + fi +} + +setup_case() { + local case_dir="$1" + + mkdir -p "${case_dir}/bin" + mkdir -p "${case_dir}/repo" + + cat > "${case_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 > "${case_dir}/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail + +printf '%s\n' "$*" >> "${GH_CALLS_LOG}" + +if [[ "$#" -ge 3 && "$1" == "api" && "$2" == "--method" && "$3" == "GET" && "$4" == "repos/owner/repo/pulls?state=open&base=release/MAPL-v3" ]]; then + echo "[]" + exit 0 +fi + +if [[ "$#" -ge 2 && "$1" == "api" && "$2" == "repos/owner/repo/labels?per_page=100" ]]; then + if [[ "${GH_FAIL_ON_LABEL_LOOKUP:-false}" == "true" ]]; then + echo "Label lookup should not happen in this scenario" >&2 + exit 1 + fi + + if [[ -n "${GH_DYNAMIC_LABEL_STATE:-}" && -f "${GH_DYNAMIC_LABEL_STATE}" ]]; then + cat "${GH_DYNAMIC_LABEL_STATE}" + else + printf '%s\n' "${GH_LABELS_RESPONSE:-[]}" + fi + exit 0 +fi + +if [[ "$#" -ge 3 && "$1" == "api" && "$2" == "--method" && "$3" == "POST" && "$4" == "repos/owner/repo/labels" ]]; then + label_name="" + color_value="" + previous="" + for arg in "$@"; do + if [[ "${previous}" == "--raw-field" ]]; then + case "${arg}" in + name=*) + label_name="${arg#name=}" + ;; + color=*) + color_value="${arg#color=}" + ;; + esac + fi + previous="${arg}" + done + + printf '%s\n' "${label_name}" >> "${GH_CREATED_LABELS_LOG}" + if [[ "${color_value}" != "0366d6" ]]; then + echo "Unexpected label color: ${color_value}" >&2 + exit 1 + fi + + if [[ "${label_name}" == "zzz-race/label" ]]; then + printf '%s\n' '[{"name":"existing label"},{"name":"zzz-new/feature"},{"name":"zzz-race/label"}]' > "${GH_DYNAMIC_LABEL_STATE}" + echo "HTTP 422: Validation Failed" >&2 + exit 1 + fi + + exit 0 +fi + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "create" ]]; then + label_values="" + previous="" + for arg in "$@"; do + if [[ "${previous}" == "--label" ]]; then + label_values+="${arg}"$'\n' + fi + previous="${arg}" + done + printf '%s' "${label_values}" > "${GH_PR_LABELS_LOG}" + echo "https://example.test/pr/789" + exit 0 +fi + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "view" ]]; then + echo "789" + exit 0 +fi + +echo "Unsupported gh call: $*" >&2 +exit 1 +EOF + + cat > "${case_dir}/template.md" <<'EOF' +## Template body from file +EOF + + chmod +x "${case_dir}/bin/git" "${case_dir}/bin/gh" +} + +run_case() { + local case_dir="$1" + local label_value="$2" + local create_missing_labels="$3" + local log_file="$4" + + set +e + PATH="${case_dir}/bin:${PATH}" \ + GH_CALLS_LOG="${case_dir}/gh-calls.log" \ + GH_CREATED_LABELS_LOG="${case_dir}/gh-created-labels.log" \ + GH_PR_LABELS_LOG="${case_dir}/gh-pr-labels.log" \ + GH_DYNAMIC_LABEL_STATE="${case_dir}/labels-state.json" \ + GITHUB_ACTOR="ci-user" \ + GITHUB_TOKEN="token" \ + GITHUB_REPOSITORY="owner/repo" \ + GITHUB_WORKSPACE="${case_dir}" \ + GITHUB_OUTPUT="${case_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="${case_dir}/template.md" \ + INPUT_BODY="" \ + INPUT_REVIEWER="" \ + INPUT_ASSIGNEE="" \ + INPUT_LABEL="${label_value}" \ + INPUT_CREATE_MISSING_LABELS="${create_missing_labels}" \ + INPUT_MILESTONE="" \ + INPUT_PROJECT="" \ + INPUT_DRAFT="false" \ + 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" >&2 + cat "${log_file}" >&2 + exit 1 + fi +} + +CASE_ONE_DIR="$(mktemp -d)" +CASE_TWO_DIR="$(mktemp -d)" +trap 'rm -rf "${CASE_ONE_DIR}" "${CASE_TWO_DIR}"' EXIT + +setup_case "${CASE_ONE_DIR}" +setup_case "${CASE_TWO_DIR}" + +: > "${CASE_ONE_DIR}/gh-calls.log" +: > "${CASE_ONE_DIR}/gh-created-labels.log" +: > "${CASE_ONE_DIR}/gh-pr-labels.log" +CASE_ONE_LOG="${CASE_ONE_DIR}/run.log" +run_case "${CASE_ONE_DIR}" "bug,zzz-disabled/label" "false" "${CASE_ONE_LOG}" +assert_not_contains "${CASE_ONE_DIR}/gh-calls.log" "repos/owner/repo/labels?per_page=100" +assert_not_contains "${CASE_ONE_DIR}/gh-calls.log" "repos/owner/repo/labels --raw-field" +assert_contains "${CASE_ONE_DIR}/gh-pr-labels.log" "bug" +assert_contains "${CASE_ONE_DIR}/gh-pr-labels.log" "zzz-disabled/label" + +: > "${CASE_TWO_DIR}/gh-calls.log" +: > "${CASE_TWO_DIR}/gh-created-labels.log" +: > "${CASE_TWO_DIR}/gh-pr-labels.log" +printf '%s\n' '[{"name":"existing label"}]' > "${CASE_TWO_DIR}/labels-state.json" +CASE_TWO_LOG="${CASE_TWO_DIR}/run.log" +run_case "${CASE_TWO_DIR}" " existing label , zzz-new/feature , zzz-new/feature , zzz-race/label " "true" "${CASE_TWO_LOG}" + +assert_contains "${CASE_TWO_DIR}/gh-calls.log" "api repos/owner/repo/labels?per_page=100 --paginate" +assert_contains "${CASE_TWO_DIR}/gh-calls.log" "api --method POST repos/owner/repo/labels --raw-field name=zzz-new/feature --raw-field color=0366d6" +assert_contains "${CASE_TWO_DIR}/gh-calls.log" "api --method POST repos/owner/repo/labels --raw-field name=zzz-race/label --raw-field color=0366d6" +assert_contains "${CASE_TWO_DIR}/gh-created-labels.log" "zzz-new/feature" +assert_contains "${CASE_TWO_DIR}/gh-created-labels.log" "zzz-race/label" +assert_not_contains "${CASE_TWO_DIR}/gh-created-labels.log" "existing label" +if [[ "$(grep -Fc "zzz-new/feature" "${CASE_TWO_DIR}/gh-created-labels.log")" != "1" ]]; then + echo "Expected zzz-new/feature to be created once" >&2 + cat "${CASE_TWO_DIR}/gh-created-labels.log" >&2 + exit 1 +fi +if [[ "$(grep -Fc "zzz-race/label" "${CASE_TWO_DIR}/gh-created-labels.log")" != "1" ]]; then + echo "Expected zzz-race/label to be attempted once" >&2 + cat "${CASE_TWO_DIR}/gh-created-labels.log" >&2 + exit 1 +fi + +assert_contains "${CASE_TWO_LOG}" "[INFO] Label already exists: existing label" +assert_contains "${CASE_TWO_LOG}" "[INFO] Creating missing label: zzz-new/feature" +assert_contains "${CASE_TWO_LOG}" "[INFO] Label became available during creation attempt: zzz-race/label" +assert_contains "${CASE_TWO_DIR}/gh-pr-labels.log" "existing label" +assert_contains "${CASE_TWO_DIR}/gh-pr-labels.log" "zzz-new/feature" +assert_contains "${CASE_TWO_DIR}/gh-pr-labels.log" "zzz-race/label" + +create_line="$(grep -n "api --method POST repos/owner/repo/labels --raw-field name=zzz-new/feature --raw-field color=0366d6" "${CASE_TWO_DIR}/gh-calls.log" | head -1 | cut -d: -f1)" +pr_create_line="$(grep -n "^pr create" "${CASE_TWO_DIR}/gh-calls.log" | head -1 | cut -d: -f1)" +if [[ -z "${create_line}" || -z "${pr_create_line}" || "${create_line}" -ge "${pr_create_line}" ]]; then + echo "Expected label creation to happen before pr create" >&2 + cat "${CASE_TWO_DIR}/gh-calls.log" >&2 + exit 1 +fi + +echo "Label creation flow test passed." diff --git a/tests/unit/test_pr_create_with_gh.sh b/tests/unit/test_pr_create_with_gh.sh index 69d43d2..a8a50f5 100755 --- a/tests/unit/test_pr_create_with_gh.sh +++ b/tests/unit/test_pr_create_with_gh.sh @@ -98,54 +98,75 @@ cat > "${TMP_DIR}/bin/gh" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail -cmd="$*" - -if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "list" ]]; then +if [[ "$#" -ge 3 && "$1" == "api" && "$2" == "--method" && "$3" == "GET" && "$4" == "repos/owner/repo/pulls?state=open&base=release/MAPL-v3" ]]; then + echo "[]" 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 + expect_repo="false" + expect_base="false" + expect_head="false" + expect_title="false" + expect_body_file="false" + expect_milestone="false" + expect_project="false" + expect_draft="false" + reviewer_alice="false" + reviewer_bob="false" + assignee_one="false" + assignee_two="false" + label_bug="false" + label_chore="false" + previous="" + + for arg in "$@"; do + case "${previous}:${arg}" in + --repo:owner/repo) expect_repo="true" ;; + --base:release/MAPL-v3) expect_base="true" ;; + --head:owner:develop) expect_head="true" ;; + --title:"My PR title") expect_title="true" ;; + --body-file:/tmp/template) expect_body_file="true" ;; + --reviewer:alice) reviewer_alice="true" ;; + --reviewer:bob) reviewer_bob="true" ;; + --assignee:assignee1) assignee_one="true" ;; + --assignee:assignee2) assignee_two="true" ;; + --label:bug) label_bug="true" ;; + --label:chore) label_chore="true" ;; + --milestone:Milestone-1) expect_milestone="true" ;; + --project:Roadmap) expect_project="true" ;; + esac + if [[ "${arg}" == "--draft" ]]; then + expect_draft="true" + fi + previous="${arg}" + done + + if [[ "${expect_repo}" != "true" || "${expect_base}" != "true" || "${expect_head}" != "true" || "${expect_title}" != "true" || "${expect_body_file}" != "true" ]]; then + echo "Missing required PR create arguments" >&2 exit 1 fi - if [[ "${cmd}" != *"--reviewer alice"* || "${cmd}" != *"--reviewer bob"* ]]; then + if [[ "${reviewer_alice}" != "true" || "${reviewer_bob}" != "true" ]]; then echo "Missing reviewers" >&2 exit 1 fi - if [[ "${cmd}" != *"--assignee assignee1"* || "${cmd}" != *"--assignee assignee2"* ]]; then + if [[ "${assignee_one}" != "true" || "${assignee_two}" != "true" ]]; then echo "Missing assignees" >&2 exit 1 fi - if [[ "${cmd}" != *"--label bug"* || "${cmd}" != *"--label chore"* ]]; then + if [[ "${label_bug}" != "true" || "${label_chore}" != "true" ]]; then echo "Missing labels" >&2 exit 1 fi - if [[ "${cmd}" != *"--milestone Milestone-1"* ]]; then + if [[ "${expect_milestone}" != "true" ]]; then echo "Missing milestone" >&2 exit 1 fi - if [[ "${cmd}" != *"--project Roadmap"* ]]; then + if [[ "${expect_project}" != "true" ]]; then echo "Missing project" >&2 exit 1 fi - if [[ "${cmd}" != *"--draft"* ]]; then + if [[ "${expect_draft}" != "true" ]]; then echo "Missing draft flag" >&2 exit 1 fi