diff --git a/README.md b/README.md index c108e0a..66ff6df 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ This action supports three tag levels for flexible versioning: assignee: octocat label: enhancement milestone: My milestone + project: Engineering Roadmap draft: true old_string: "" new_string: "** Automatic pull request**" @@ -95,6 +96,7 @@ This action supports three tag levels for flexible versioning: | `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 | @@ -119,6 +121,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). +- Project assignment via `project` requires a token/auth context that `gh` can use with project access. ### 📤 Output Parameters @@ -217,6 +220,7 @@ jobs: title: ${{ github.event.commits[0].message }} assignee: ${{ github.actor }} label: automatic,feature + project: Engineering Roadmap template: .github/PULL_REQUEST_TEMPLATE/FEATURE.md old_string: "**Write your description here**" new_string: ${{ github.event.commits[0].message }} diff --git a/action.yml b/action.yml index 94d2898..a21939d 100644 --- a/action.yml +++ b/action.yml @@ -49,6 +49,10 @@ inputs: description: Milestone required: false default: "" + project: + description: GitHub Project title to add the pull request to + required: false + default: "" draft: description: Whether to mark it as a draft required: false diff --git a/entrypoint.sh b/entrypoint.sh index 1e95954..33b069f 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -23,6 +23,7 @@ TARGET_OWNER="" REPOSITORY_PATH="" WORKSPACE_DIR="" REPO_DIR="" +PROJECT_VALUE="" REPLACE_TEMPLATE_SCRIPT="/scripts/replace-template-diff.sh" if [[ ! -x "${REPLACE_TEMPLATE_SCRIPT}" ]]; then @@ -174,6 +175,7 @@ trim_whitespace() { : "${INPUT_ASSIGNEE:=}" : "${INPUT_LABEL:=}" : "${INPUT_MILESTONE:=}" +: "${INPUT_PROJECT:=}" : "${INPUT_DRAFT:=false}" : "${INPUT_OLD_STRING:=}" : "${INPUT_NEW_STRING:=}" @@ -203,6 +205,42 @@ append_csv_arg() { done } +pr_has_project() { + local pr_number="$1" + local project_title="$2" + local project_titles="" + + project_titles="$( + gh pr view "${pr_number}" --repo "${TARGET_REPOSITORY}" --json projectItems,projectCards --jq \ + '[.projectItems[]?.project.title?, .projectCards[]?.project.name?, .projectCards[]?.project.title?] + | map(select(. != null)) + | .[]' + )" + + if grep -Fxq -- "${project_title}" <<< "${project_titles}"; then + return 0 + fi + + return 1 +} + +add_pr_to_project_if_needed() { + local pr_number="$1" + local project_title="$2" + + if [[ -z "${project_title}" ]]; then + return 0 + fi + + if pr_has_project "${pr_number}" "${project_title}"; then + echo -e "\n[INFO] Pull request #${pr_number} is already assigned to project '${project_title}'." + return 0 + fi + + echo -e "\nAdding pull request #${pr_number} to project '${project_title}'" + gh pr edit "${pr_number}" --repo "${TARGET_REPOSITORY}" --add-project "${project_title}" >/dev/null +} + get_managed_comment_ids() { local pr_number="$1" local output_file="$2" @@ -304,6 +342,7 @@ echo " reviewer: ${INPUT_REVIEWER}" echo " assignee: ${INPUT_ASSIGNEE}" echo " label: ${INPUT_LABEL}" echo " milestone: ${INPUT_MILESTONE}" +echo " project: ${INPUT_PROJECT}" echo " draft: ${INPUT_DRAFT}" echo " get_diff: ${INPUT_GET_DIFF}" echo " old_string: ${INPUT_OLD_STRING}" @@ -315,6 +354,7 @@ echo " max_diff_lines: ${INPUT_MAX_DIFF_LINES}" MAX_BODY_BYTES="${INPUT_MAX_BODY_BYTES:-65000}" MAX_DIFF_LINES="${INPUT_MAX_DIFF_LINES:-0}" +PROJECT_VALUE="$(trim_whitespace "${INPUT_PROJECT}")" validate_number_input "${MAX_BODY_BYTES}" "max_body_bytes" validate_number_input "${MAX_DIFF_LINES}" "max_diff_lines" @@ -574,6 +614,9 @@ if [[ -z "${PR_NUMBER}" ]]; then if [[ -n "${milestone_value}" ]]; then GH_CREATE_ARGS+=(--milestone "${milestone_value}") fi + if [[ -n "${PROJECT_VALUE}" ]]; then + GH_CREATE_ARGS+=(--project "${PROJECT_VALUE}") + fi if [[ "${INPUT_DRAFT}" == "true" ]]; then GH_CREATE_ARGS+=(--draft) fi @@ -595,6 +638,7 @@ else 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 + add_pr_to_project_if_needed "${PR_NUMBER}" "${PROJECT_VALUE}" if (( CHUNK_COUNT > 0 )); then reconcile_managed_comments "${PR_NUMBER}" "${CHUNK_COUNT}" else diff --git a/tests/unit/test_existing_pr_lookup.sh b/tests/unit/test_existing_pr_lookup.sh index 87b5a2f..7d74379 100755 --- a/tests/unit/test_existing_pr_lookup.sh +++ b/tests/unit/test_existing_pr_lookup.sh @@ -124,6 +124,20 @@ if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "create" ]]; then exit 1 fi +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "view" ]]; then + if [[ "${cmd}" == *"--json projectItems,projectCards"* ]]; then + exit 0 + fi +fi + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "edit" ]]; then + if [[ "${cmd}" != *"--repo owner/repo"* || "${cmd}" != *"--add-project Roadmap"* || "${cmd}" != *"123"* ]]; then + echo "Missing project add call in update mode" >&2 + exit 1 + fi + exit 0 +fi + echo "Unsupported gh call: $*" >&2 exit 1 EOF @@ -153,6 +167,7 @@ INPUT_REVIEWER="" \ INPUT_ASSIGNEE="" \ INPUT_LABEL="" \ INPUT_MILESTONE="" \ +INPUT_PROJECT="Roadmap" \ INPUT_DRAFT="false" \ INPUT_GET_DIFF="false" \ INPUT_OLD_STRING="" \ @@ -172,6 +187,7 @@ if [[ "${STATUS}" != "0" ]]; then fi assert_contains "${LOG_FILE}" "Updating pull request" +assert_contains "${LOG_FILE}" "Adding pull request #123 to project 'Roadmap'" assert_contains "${TMP_DIR}/output.txt" "url=https://example.test/pr/123" assert_contains "${TMP_DIR}/output.txt" "pr_number=123" diff --git a/tests/unit/test_existing_pr_project_skip.sh b/tests/unit/test_existing_pr_project_skip.sh new file mode 100755 index 0000000..9741cb0 --- /dev/null +++ b/tests/unit/test_existing_pr_project_skip.sh @@ -0,0 +1,198 @@ +#!/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 +} + +assert_not_contains() { + local file_path="$1" + local expected="$2" + if grep -Fq -- "${expected}" "${file_path}"; then + echo "Assertion failed. Expected not 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/main" ]]; 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/main" ]]; 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 1 && "$1" == "api" ]]; then + if [[ "${cmd}" == *"repos/owner/repo/pulls?state=open&base=main"* ]]; then + printf '%s\n' '[{"number":123,"head":{"ref":"develop","repo":{"full_name":"owner/repo"}}}]' + exit 0 + fi + if [[ "${cmd}" == *"repos/owner/repo/pulls/123"* && "${cmd}" == *"--method GET"* ]]; then + echo "OLD BODY" + exit 0 + fi + if [[ "${cmd}" == *"repos/owner/repo/pulls/123"* && "${cmd}" == *"--method PATCH"* ]]; then + echo "https://example.test/pr/123" + exit 0 + fi + if [[ "${cmd}" == *"repos/owner/repo/issues/123/comments"* ]]; then + echo "[]" + exit 0 + fi +fi + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "view" ]]; then + if [[ "${cmd}" == *"--json projectItems,projectCards"* ]]; then + printf '%s\n' 'Roadmap' + exit 0 + fi +fi + +if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "edit" ]]; then + echo "gh pr edit should not be called when project is already assigned" >&2 + exit 1 +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="main" \ +INPUT_TITLE="" \ +INPUT_TEMPLATE="${TMP_DIR}/template.md" \ +INPUT_BODY="" \ +INPUT_REVIEWER="" \ +INPUT_ASSIGNEE="" \ +INPUT_LABEL="" \ +INPUT_MILESTONE="" \ +INPUT_PROJECT="Roadmap" \ +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 in update mode when project already exists" >&2 + cat "${LOG_FILE}" >&2 + exit 1 +fi + +assert_contains "${LOG_FILE}" "Pull request #123 is already assigned to project 'Roadmap'." +assert_not_contains "${LOG_FILE}" "Adding pull request #123 to project 'Roadmap'" +assert_contains "${TMP_DIR}/output.txt" "url=https://example.test/pr/123" +assert_contains "${TMP_DIR}/output.txt" "pr_number=123" + +echo "Existing PR project skip test passed." diff --git a/tests/unit/test_optional_inputs_defaults.sh b/tests/unit/test_optional_inputs_defaults.sh index 9f231cf..e40623a 100755 --- a/tests/unit/test_optional_inputs_defaults.sh +++ b/tests/unit/test_optional_inputs_defaults.sh @@ -160,6 +160,7 @@ if [[ "${STATUS}" != "0" ]]; then fi assert_contains "${LOG_FILE}" "template: " +assert_contains "${LOG_FILE}" "project: " assert_contains "${LOG_FILE}" "Creating pull request" assert_contains "${TMP_DIR}/output.txt" "pr_number=123" diff --git a/tests/unit/test_pr_create_with_gh.sh b/tests/unit/test_pr_create_with_gh.sh index 6af46ab..69d43d2 100755 --- a/tests/unit/test_pr_create_with_gh.sh +++ b/tests/unit/test_pr_create_with_gh.sh @@ -141,6 +141,10 @@ if [[ "$#" -ge 2 && "$1" == "pr" && "$2" == "create" ]]; then echo "Missing milestone" >&2 exit 1 fi + if [[ "${cmd}" != *"--project Roadmap"* ]]; then + echo "Missing project" >&2 + exit 1 + fi if [[ "${cmd}" != *"--draft"* ]]; then echo "Missing draft flag" >&2 exit 1 @@ -184,6 +188,7 @@ INPUT_REVIEWER="alice,bob" \ INPUT_ASSIGNEE="assignee1,assignee2" \ INPUT_LABEL="bug,chore" \ INPUT_MILESTONE="Milestone-1" \ +INPUT_PROJECT="Roadmap" \ INPUT_DRAFT="true" \ INPUT_GET_DIFF="false" \ INPUT_OLD_STRING="" \