diff --git a/.github/base-project.yml b/.github/base-project.yml new file mode 100644 index 0000000..258e4d4 --- /dev/null +++ b/.github/base-project.yml @@ -0,0 +1,9 @@ +project: + areas: [] + initiatives: [] + issue_defaults: + status: Backlog + priority: P2 + area: Product + initiative: Adoption Polish + size: S diff --git a/.github/workflows/project-intake.yml b/.github/workflows/project-intake.yml new file mode 100644 index 0000000..d876ff4 --- /dev/null +++ b/.github/workflows/project-intake.yml @@ -0,0 +1,747 @@ +name: Project Intake metadata (advisory) + +on: + issues: + types: [opened, reopened, closed] + workflow_dispatch: + inputs: + issue_number: + description: Issue number to reconcile into the repo Project. + required: true + type: string + +permissions: + contents: read + issues: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }} + cancel-in-progress: true + +jobs: + sync: + name: Project metadata sync (advisory) + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + BASE_PROJECT_OWNER: ${{ github.repository_owner }} + BASE_PROJECT_TITLE: ${{ github.event.repository.name }} + BASE_PROJECT_ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} + BASE_PROJECT_DEFAULT_OPEN_STATUS: Backlog + BASE_PROJECT_DEFAULT_CLOSED_STATUS: Done + BASE_PROJECT_DEFAULT_PRIORITY: P2 + BASE_PROJECT_DEFAULT_SIZE: S + BASE_PROJECT_DEFAULT_AREA: Product + BASE_PROJECT_DEFAULT_INITIATIVE: Adoption Polish + GH_TOKEN: ${{ secrets.BASE_PROJECT_TOKEN }} + steps: + - name: Reconcile Project item + shell: bash + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::error::BASE_PROJECT_TOKEN secret is required for Project Intake." + echo "::error::Fix: gh auth token | gh secret set BASE_PROJECT_TOKEN --repo $GITHUB_REPOSITORY" + exit 1 + fi + + issue_number="${BASE_PROJECT_ISSUE_NUMBER:-}" + if [[ -z "$issue_number" ]]; then + echo "::error::Issue number was not provided by the event or workflow_dispatch input." + exit 1 + fi + + project_intake_is_auth_failure() { + local output="$1" + + printf '%s\n' "$output" | grep -Eiq 'Bad credentials|401 Unauthorized|HTTP 401|authentication failed' + } + + project_intake_is_retryable_api_failure() { + local output="$1" + + printf '%s\n' "$output" | grep -Eiq 'rate limit|secondary rate limit|abuse detection|Retry-After|x-ratelimit-reset' + } + + project_intake_is_transport_failure() { + local output="$1" + + printf '%s\n' "$output" | grep -Eiq \ + 'Could not resolve host|connection (reset|refused)|timed out|HTTP 5(02|03|04)|502 Bad Gateway|503 Service Unavailable|504 Gateway Timeout' + } + + project_intake_is_project_transport_failure() { + local output="$1" + + printf '%s\n' "$output" | grep -Eiq \ + 'unknown owner type|GraphQL.*rate limit|rate limit.*GraphQL|API rate limit already exceeded' + } + + project_intake_is_duplicate_item_failure() { + local output="$1" + + printf '%s\n' "$output" | grep -Eiq 'Content already exists in this project' + } + + project_intake_record_advisory() { + local category="$1" + local operation="$2" + local detail="${3:-}" + + echo "::warning::Project metadata synchronization unavailable (advisory, non-product condition): category=$category operation=$operation" >&2 + [[ -z "$detail" ]] || printf '%s\n' "$detail" >&2 + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + printf '## Project metadata synchronization (advisory)\n\n' + printf -- '- Condition: `%s`\n' "$category" + printf -- '- Operation: `%s`\n' "$operation" + printf '%s\n' '- Core product validation is unaffected; retry the Project Intake workflow after the GitHub service condition clears.' + } >>"$GITHUB_STEP_SUMMARY" + fi + } + + project_intake_retry_delay_seconds() { + local output="$1" + local reset_epoch + local now + local delay + + if [[ "$output" =~ Retry-After:?[[:space:]]*([0-9]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + + if [[ "$output" =~ (x-ratelimit-reset|X-RateLimit-Reset):?[[:space:]]*([0-9]+) ]]; then + reset_epoch="${BASH_REMATCH[2]}" + now="$(date +%s)" + delay=$(( reset_epoch - now + 5 )) + if (( delay < 5 )); then + delay=5 + fi + if (( delay > 300 )); then + delay=300 + fi + printf '%s\n' "$delay" + return 0 + fi + + printf '60\n' + } + + project_intake_gh() { + local operation="$1" + shift + local output + local error_output + local diagnostic_output + local stderr_file + local status + local retry_delay + + stderr_file="$(mktemp)" + if output="$("$@" 2>"$stderr_file")"; then + error_output="$(cat "$stderr_file")" + rm -f "$stderr_file" + [[ -z "$error_output" ]] || printf '%s\n' "$error_output" >&2 + [[ -z "$output" ]] || printf '%s\n' "$output" + return 0 + else + status=$? + error_output="$(cat "$stderr_file")" + rm -f "$stderr_file" + fi + + diagnostic_output="$output" + if [[ -n "$error_output" ]]; then + [[ -z "$diagnostic_output" ]] || diagnostic_output+=$'\n' + diagnostic_output+="$error_output" + fi + + if project_intake_is_auth_failure "$diagnostic_output"; then + echo "::error::GitHub authentication failed during Project Intake: $operation" >&2 + echo "::error::Rotate BASE_PROJECT_TOKEN and rerun this workflow_dispatch for issue #${issue_number:-unknown}." >&2 + echo "::error::Fix: gh auth token | gh secret set BASE_PROJECT_TOKEN --repo $GITHUB_REPOSITORY" >&2 + printf '%s\n' "$diagnostic_output" >&2 + return "$status" + fi + + if [[ "${PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK:-0}" == "1" ]] && + project_intake_is_project_transport_failure "$diagnostic_output"; then + echo "::warning::GraphQL Project transport failed during Project Intake: $operation" >&2 + echo "::warning::Falling back to the REST Projects API." >&2 + printf '%s\n' "$diagnostic_output" >&2 + return 75 + fi + + if project_intake_is_duplicate_item_failure "$diagnostic_output"; then + echo "::warning::Project item already exists during Project Intake: $operation" >&2 + printf '%s\n' "$diagnostic_output" >&2 + return 76 + fi + + if project_intake_is_retryable_api_failure "$diagnostic_output" || + project_intake_is_transport_failure "$diagnostic_output"; then + retry_delay="$(project_intake_retry_delay_seconds "$diagnostic_output")" + if project_intake_is_retryable_api_failure "$diagnostic_output"; then + echo "::warning::GitHub API pressure during Project Intake: $operation" >&2 + else + echo "::warning::GitHub transport pressure during Project Intake: $operation" >&2 + fi + echo "::warning::Waiting ${retry_delay}s and retrying once." >&2 + sleep "$retry_delay" + stderr_file="$(mktemp)" + if output="$("$@" 2>"$stderr_file")"; then + error_output="$(cat "$stderr_file")" + rm -f "$stderr_file" + [[ -z "$error_output" ]] || printf '%s\n' "$error_output" >&2 + [[ -z "$output" ]] || printf '%s\n' "$output" + return 0 + else + status=$? + error_output="$(cat "$stderr_file")" + rm -f "$stderr_file" + fi + diagnostic_output="$output" + if [[ -n "$error_output" ]]; then + [[ -z "$diagnostic_output" ]] || diagnostic_output+=$'\n' + diagnostic_output+="$error_output" + fi + if project_intake_is_auth_failure "$diagnostic_output"; then + echo "::error::GitHub authentication failed during Project Intake: $operation" >&2 + echo "::error::Rotate BASE_PROJECT_TOKEN and rerun this workflow_dispatch for issue #${issue_number:-unknown}." >&2 + echo "::error::Fix: gh auth token | gh secret set BASE_PROJECT_TOKEN --repo $GITHUB_REPOSITORY" >&2 + printf '%s\n' "$diagnostic_output" >&2 + return "$status" + fi + if project_intake_is_transport_failure "$diagnostic_output"; then + project_intake_record_advisory "transport" "$operation" "$diagnostic_output" + return 77 + fi + if project_intake_is_retryable_api_failure "$diagnostic_output"; then + project_intake_record_advisory "quota" "$operation" "$diagnostic_output" + return 77 + fi + echo "::error::GitHub API retry failed during Project Intake: $operation" >&2 + printf '%s\n' "$diagnostic_output" >&2 + return "$status" + fi + + echo "::error::GitHub API command failed during Project Intake: $operation" >&2 + printf '%s\n' "$diagnostic_output" >&2 + return "$status" + } + + project_intake_graphql_item_after_add() { + local project_number="$1" + local item_id="$2" + local item_list_json + local item_json + local command_status + local attempt + + for attempt in 1 2 3; do + if item_list_json="$(PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK=1 project_intake_gh \ + "list Project items" gh project item-list "$project_number" --owner "$BASE_PROJECT_OWNER" --format json --limit 1000)"; then + item_json="$(jq --arg id "$item_id" '.items[] | select(.id == $id)' <<<"$item_list_json")" + if [[ -n "$item_json" ]]; then + printf '%s\n' "$item_json" + return 0 + fi + else + command_status=$? + return "$command_status" + fi + + if (( attempt < 3 )); then + echo "::warning::Project item '$item_id' is not visible after it was added (attempt $attempt/3); retrying in ${attempt}s." >&2 + sleep "$attempt" + fi + done + + project_intake_record_advisory "visibility" "list Project items" \ + "Project item '$item_id' was not returned after the bounded visibility retry." + return 77 + } + + project_intake_rest_item_after_add() { + local owner_api_prefix="$1" + local project_number="$2" + local item_id="$3" + local field_ids_csv="$4" + local escaped_issue_title + local item_search_json + local item_json + local command_status + local attempt + local display_item_id + + escaped_issue_title="${issue_title//\"/\\\"}" + display_item_id="${item_id:-for issue $issue_number}" + for attempt in 1 2 3; do + if [[ -n "$item_id" ]] && + item_json="$(project_intake_gh "read REST Project item after add" gh api --method GET \ + "$owner_api_prefix/projectsV2/$project_number/items/$item_id" \ + -f fields="$field_ids_csv")"; then + if jq -e --arg item_id "$item_id" '(.id | tostring) == $item_id' <<<"$item_json" >/dev/null; then + printf '%s\n' "$item_json" + return 0 + fi + if (( attempt < 3 )); then + echo "::warning::REST Project item '$display_item_id' is not visible after it was added (attempt $attempt/3); retrying in ${attempt}s." >&2 + sleep "$attempt" + continue + fi + fi + + if item_search_json="$(project_intake_gh "find REST Project item after add" gh api --method GET \ + "$owner_api_prefix/projectsV2/$project_number/items" \ + -f per_page=100 \ + -f q="repo:$GITHUB_REPOSITORY is:issue title:\"$escaped_issue_title\"" \ + -f fields="$field_ids_csv")"; then + item_json="$(jq --argjson issue_id "$issue_id" --arg item_id "$item_id" \ + '.[] | select(.content.id == $issue_id and ($item_id == "" or (.id | tostring) == $item_id))' \ + <<<"$item_search_json")" + if [[ -n "$item_json" ]]; then + printf '%s\n' "$item_json" + return 0 + fi + else + command_status=$? + return "$command_status" + fi + + if (( attempt < 3 )); then + echo "::warning::REST Project item '$display_item_id' is not visible after it was added (attempt $attempt/3); retrying in ${attempt}s." >&2 + sleep "$attempt" + fi + done + + project_intake_record_advisory "visibility" "find REST Project item after add" \ + "REST Project item '$display_item_id' was not returned after the bounded visibility retry." + return 77 + } + + if issue_json="$(project_intake_gh "view issue" gh api "repos/$GITHUB_REPOSITORY/issues/$issue_number")"; then + : + else + issue_status=$? + if [[ "$issue_status" == "77" ]]; then + exit 0 + fi + exit "$issue_status" + fi + issue_state="$(jq -r '.state | ascii_upcase' <<<"$issue_json")" + issue_url="$(jq -r '.html_url' <<<"$issue_json")" + issue_id="$(jq -r '.id' <<<"$issue_json")" + issue_title="$(jq -r '.title' <<<"$issue_json")" + + status_value="$BASE_PROJECT_DEFAULT_OPEN_STATUS" + if [[ "$issue_state" == "CLOSED" ]]; then + status_value="$BASE_PROJECT_DEFAULT_CLOSED_STATUS" + fi + + project_intake_graphql_reconcile() { + local project_list_json + local project_number + local project_id + local item_id + local item_json + local fields_json + local expected_priority + local expected_size + local expected_area + local expected_initiative + local refreshed_item_json + local command_status + + if project_list_json="$(PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK=1 project_intake_gh \ + "list Projects" gh project list --owner "$BASE_PROJECT_OWNER" --format json --limit 100)"; then + : + else + command_status=$? + return "$command_status" + fi + project_number="$( + jq -r --arg title "$BASE_PROJECT_TITLE" \ + '.projects[] | select(.title == $title) | .number' <<<"$project_list_json" | + head -n 1 + )" + if [[ -z "$project_number" ]]; then + echo "::error::Project metadata validation failed: GitHub Project '$BASE_PROJECT_TITLE' was not found for owner '$BASE_PROJECT_OWNER'." >&2 + echo "::error::If this Project exists, set BASE_PROJECT_TOKEN with user Project read/write access." >&2 + return 1 + fi + + if project_id="$(PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK=1 project_intake_gh \ + "view Project" gh project view "$project_number" --owner "$BASE_PROJECT_OWNER" --format json --jq '.id')"; then + : + else + command_status=$? + return "$command_status" + fi + if item_id="$(PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK=1 project_intake_gh \ + "add Project item" gh project item-add "$project_number" --owner "$BASE_PROJECT_OWNER" --url "$issue_url" --format json --jq '.id')"; then + : + else + command_status=$? + return "$command_status" + fi + if item_json="$(project_intake_graphql_item_after_add "$project_number" "$item_id")"; then + : + else + command_status=$? + return "$command_status" + fi + if fields_json="$(PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK=1 project_intake_gh \ + "list Project fields" gh project field-list "$project_number" --owner "$BASE_PROJECT_OWNER" --format json)"; then + : + else + command_status=$? + return "$command_status" + fi + + graphql_field_id_for() { + local field_name="$1" + + jq -r --arg name "$field_name" \ + '.fields[] | select(.name == $name) | .id' <<<"$fields_json" | + head -n 1 + } + + graphql_option_id_for() { + local field_name="$1" + local option_name="$2" + + jq -r --arg name "$field_name" --arg option "$option_name" \ + '.fields[] | select(.name == $name) | .options[]? | select(.name == $option) | .id' \ + <<<"$fields_json" | + head -n 1 + } + + graphql_set_single_select() { + local field_name="$1" + local option_name="$2" + local field_id + local option_id + local set_status + + [[ -n "$option_name" ]] || return 0 + + field_id="$(graphql_field_id_for "$field_name")" + option_id="$(graphql_option_id_for "$field_name" "$option_name")" + if [[ -z "$field_id" || -z "$option_id" ]]; then + echo "::error::Project field validation failed during Project Intake: field '$field_name' option '$option_name' was not found." >&2 + return 1 + fi + + if PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK=1 project_intake_gh \ + "set Project field $field_name" gh project item-edit \ + --id "$item_id" \ + --project-id "$project_id" \ + --field-id "$field_id" \ + --single-select-option-id "$option_id" \ + >/dev/null; then + : + else + set_status=$? + return "$set_status" + fi + } + + expected_priority="$(jq -r '.priority // ""' <<<"$item_json")" + expected_size="$(jq -r '.size // ""' <<<"$item_json")" + expected_area="$(jq -r '.area // ""' <<<"$item_json")" + expected_initiative="$(jq -r '.initiative // ""' <<<"$item_json")" + [[ -n "$expected_priority" ]] || expected_priority="$BASE_PROJECT_DEFAULT_PRIORITY" + [[ -n "$expected_size" ]] || expected_size="$BASE_PROJECT_DEFAULT_SIZE" + [[ -n "$expected_area" ]] || expected_area="$BASE_PROJECT_DEFAULT_AREA" + [[ -n "$expected_initiative" ]] || expected_initiative="$BASE_PROJECT_DEFAULT_INITIATIVE" + + if [[ "$(jq -r '.status // ""' <<<"$item_json")" != "$status_value" ]]; then + graphql_set_single_select Status "$status_value" || return $? + fi + [[ -n "$(jq -r '.priority // ""' <<<"$item_json")" ]] || + graphql_set_single_select Priority "$expected_priority" || return $? + [[ -n "$(jq -r '.size // ""' <<<"$item_json")" ]] || + graphql_set_single_select Size "$expected_size" || return $? + [[ -n "$(jq -r '.area // ""' <<<"$item_json")" ]] || + graphql_set_single_select Area "$expected_area" || return $? + [[ -n "$(jq -r '.initiative // ""' <<<"$item_json")" ]] || + graphql_set_single_select Initiative "$expected_initiative" || return $? + + if refreshed_item_json="$(PROJECT_INTAKE_ALLOW_PROJECT_FALLBACK=1 project_intake_gh \ + "verify Project fields" gh project item-list "$project_number" --owner "$BASE_PROJECT_OWNER" --format json --limit 1000)"; then + refreshed_item_json="$(jq --arg id "$item_id" '.items[] | select(.id == $id)' <<<"$refreshed_item_json")" + else + command_status=$? + return "$command_status" + fi + if ! jq -e \ + --arg status "$status_value" \ + --arg priority "$expected_priority" \ + --arg size "$expected_size" \ + --arg area "$expected_area" \ + --arg initiative "$expected_initiative" \ + '.status == $status and .priority == $priority and .size == $size and + .area == $area and .initiative == $initiative' \ + <<<"$refreshed_item_json" >/dev/null; then + echo "::error::Project field validation failed after the GraphQL update." >&2 + return 1 + fi + } + + project_intake_rest_reconcile() { + local owner_json + local owner_type + local owner_api_prefix + local projects_json + local project_number + local fields_json + local field_ids_csv + local escaped_issue_title + local item_search_json + local item_json + local item_id + local added_item_json + local current_item_json + local updates_json='[]' + local update_payload + local expected_priority + local expected_size + local expected_area + local expected_initiative + local verified_item_json + local command_status + + if owner_json="$(project_intake_gh "view REST Project owner" gh api "users/$BASE_PROJECT_OWNER")"; then + : + else + command_status=$? + return "$command_status" + fi + owner_type="$(jq -r '.type' <<<"$owner_json")" + case "$owner_type" in + Organization) + owner_api_prefix="orgs/$BASE_PROJECT_OWNER" + ;; + User) + owner_api_prefix="users/$BASE_PROJECT_OWNER" + ;; + *) + echo "::error::REST Project owner '$BASE_PROJECT_OWNER' has unsupported type '$owner_type'." >&2 + return 1 + ;; + esac + + if projects_json="$(project_intake_gh "list REST Projects" gh api "$owner_api_prefix/projectsV2?per_page=100")"; then + : + else + command_status=$? + return "$command_status" + fi + project_number="$( + jq -r --arg title "$BASE_PROJECT_TITLE" \ + '.[] | select(.title == $title) | .number' <<<"$projects_json" | + head -n 1 + )" + if [[ -z "$project_number" ]]; then + echo "::error::Project metadata validation failed during the REST fallback: Project '$BASE_PROJECT_TITLE' was not found for owner '$BASE_PROJECT_OWNER'." >&2 + return 1 + fi + + if fields_json="$(project_intake_gh "list REST Project fields" gh api \ + "$owner_api_prefix/projectsV2/$project_number/fields?per_page=100")"; then + : + else + command_status=$? + return "$command_status" + fi + field_ids_csv="$( + jq -r \ + '[.[] | select(.name == "Status" or .name == "Priority" or .name == "Size" or + .name == "Area" or .name == "Initiative") | .id] | join(",")' \ + <<<"$fields_json" + )" + if [[ "$(awk -F, '{print NF}' <<<"$field_ids_csv")" != "5" ]]; then + echo "::error::Project field validation failed during the REST fallback: lookup did not resolve all five managed fields." >&2 + return 1 + fi + + escaped_issue_title="${issue_title//\"/\\\"}" + item_search_json="$(project_intake_gh "find REST Project item" gh api --method GET \ + "$owner_api_prefix/projectsV2/$project_number/items" \ + -f per_page=100 \ + -f q="repo:$GITHUB_REPOSITORY is:issue title:\"$escaped_issue_title\"" \ + -f fields="$field_ids_csv")" + item_json="$(jq --argjson issue_id "$issue_id" '.[] | select(.content.id == $issue_id)' <<<"$item_search_json")" + item_id="$(jq -r '.id // ""' <<<"$item_json")" + if [[ -z "$item_id" ]]; then + if added_item_json="$(project_intake_gh "add REST Project item" gh api --method POST \ + "$owner_api_prefix/projectsV2/$project_number/items" \ + -f type=Issue -F id="$issue_id")"; then + item_id="$(jq -r '.id // .value.id // ""' <<<"$added_item_json")" + if [[ -z "$item_id" ]]; then + echo "::error::REST Project item add did not return an item id." >&2 + return 1 + fi + if item_json="$(project_intake_rest_item_after_add \ + "$owner_api_prefix" "$project_number" "$item_id" "$field_ids_csv")"; then + : + else + command_status=$? + return "$command_status" + fi + else + command_status=$? + if [[ "$command_status" != "76" ]]; then + return "$command_status" + fi + if item_json="$(project_intake_rest_item_after_add \ + "$owner_api_prefix" "$project_number" "" "$field_ids_csv")"; then + item_id="$(jq -r '.id // ""' <<<"$item_json")" + if [[ -z "$item_id" ]]; then + echo "::error::Existing REST Project item did not return an item id." >&2 + return 1 + fi + else + command_status=$? + return "$command_status" + fi + fi + fi + + if current_item_json="$(project_intake_gh "read REST Project fields" gh api --method GET \ + "$owner_api_prefix/projectsV2/$project_number/items/$item_id" \ + -f fields="$field_ids_csv")"; then + : + else + command_status=$? + return "$command_status" + fi + + rest_field_id_for() { + local field_name="$1" + + jq -r --arg name "$field_name" '.[] | select(.name == $name) | .id' <<<"$fields_json" | + head -n 1 + } + + rest_option_id_for() { + local field_name="$1" + local option_name="$2" + + jq -r --arg name "$field_name" --arg option "$option_name" \ + '.[] | select(.name == $name) | .options[]? | + select((.name.raw // .name) == $option) | .id' <<<"$fields_json" | + head -n 1 + } + + rest_field_value_for() { + local field_name="$1" + + jq -r --arg name "$field_name" \ + '.fields[]? | select(.name == $name) | (.value.name.raw // .value.name // "")' \ + <<<"$current_item_json" | + head -n 1 + } + + rest_queue_single_select() { + local field_name="$1" + local option_name="$2" + local field_id + local option_id + + field_id="$(rest_field_id_for "$field_name")" + option_id="$(rest_option_id_for "$field_name" "$option_name")" + if [[ -z "$field_id" || -z "$option_id" ]]; then + echo "::error::Project field validation failed during the REST fallback: field '$field_name' option '$option_name' was not found." >&2 + return 1 + fi + updates_json="$(jq -c --argjson id "$field_id" --arg value "$option_id" \ + '. + [{id: $id, value: $value}]' <<<"$updates_json")" + } + + expected_priority="$(rest_field_value_for Priority)" + expected_size="$(rest_field_value_for Size)" + expected_area="$(rest_field_value_for Area)" + expected_initiative="$(rest_field_value_for Initiative)" + + if [[ "$(rest_field_value_for Status)" != "$status_value" ]]; then + rest_queue_single_select Status "$status_value" + fi + if [[ -z "$expected_priority" ]]; then + expected_priority="$BASE_PROJECT_DEFAULT_PRIORITY" + rest_queue_single_select Priority "$expected_priority" + fi + if [[ -z "$expected_size" ]]; then + expected_size="$BASE_PROJECT_DEFAULT_SIZE" + rest_queue_single_select Size "$expected_size" + fi + if [[ -z "$expected_area" ]]; then + expected_area="$BASE_PROJECT_DEFAULT_AREA" + rest_queue_single_select Area "$expected_area" + fi + if [[ -z "$expected_initiative" ]]; then + expected_initiative="$BASE_PROJECT_DEFAULT_INITIATIVE" + rest_queue_single_select Initiative "$expected_initiative" + fi + + if [[ "$(jq 'length' <<<"$updates_json")" != "0" ]]; then + update_payload="$(jq -nc --argjson fields "$updates_json" '{fields: $fields}')" + project_intake_gh "update REST Project fields" gh api --method PATCH \ + "$owner_api_prefix/projectsV2/$project_number/items/$item_id" \ + --input - <<<"$update_payload" >/dev/null + fi + + if verified_item_json="$(project_intake_gh "verify REST Project fields" gh api --method GET \ + "$owner_api_prefix/projectsV2/$project_number/items/$item_id" \ + -f fields="$field_ids_csv")"; then + : + else + command_status=$? + return "$command_status" + fi + if ! jq -e \ + --arg status "$status_value" \ + --arg priority "$expected_priority" \ + --arg size "$expected_size" \ + --arg area "$expected_area" \ + --arg initiative "$expected_initiative" \ + 'def value_for($name): + [.fields[]? | select(.name == $name) | + (.value.name.raw // .value.name // "")][0] // ""; + value_for("Status") == $status and + value_for("Priority") == $priority and + value_for("Size") == $size and + value_for("Area") == $area and + value_for("Initiative") == $initiative' \ + <<<"$verified_item_json" >/dev/null; then + echo "::error::Project field validation failed after the REST fallback update." >&2 + return 1 + fi + } + + if project_intake_graphql_reconcile; then + transport="GraphQL" + else + graphql_status=$? + if [[ "$graphql_status" == "77" ]]; then + exit 0 + fi + if [[ "$graphql_status" != "75" ]]; then + exit "$graphql_status" + fi + if project_intake_rest_reconcile; then + transport="REST fallback" + else + rest_status=$? + if [[ "$rest_status" == "77" ]]; then + exit 0 + fi + exit "$rest_status" + fi + fi + + printf 'Synced issue #%s into Project %s via %s.\n' \ + "$issue_number" "$BASE_PROJECT_TITLE" "$transport" diff --git a/tests/validate.sh b/tests/validate.sh index 3a5ff72..85dca00 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -58,6 +58,8 @@ main() { require_file cli/bash/commands/sort-in-place/sort-in-place.sh || failed=1 require_file cli/bash/commands/sort-in-place/tests/sort-in-place.bats || failed=1 require_file cli/python/README.md || failed=1 + require_file .github/base-project.yml || failed=1 + require_file .github/workflows/project-intake.yml || failed=1 require_file cli/python/base_platform_tools/__init__.py || failed=1 require_file docs/cli-layout.md || failed=1 require_file docs/tooling-boundary.md || failed=1