Provide a reusable GitHub Actions workflow for basectl check --ci #896
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Project Intake | |
| 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: Sync issue Project fields | |
| 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_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_is_retryable_api_failure "$diagnostic_output"; then | |
| retry_delay="$(project_intake_retry_delay_seconds "$diagnostic_output")" | |
| echo "::warning::GitHub API pressure during Project Intake: $operation" >&2 | |
| 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 | |
| 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" | |
| } | |
| issue_json="$(project_intake_gh "view issue" gh issue view "$issue_number" --repo "$GITHUB_REPOSITORY" --json state,url)" | |
| issue_state="$(jq -r '.state' <<<"$issue_json")" | |
| issue_url="$(jq -r '.url' <<<"$issue_json")" | |
| project_number="$( | |
| project_intake_gh "list Projects" gh project list --owner "$BASE_PROJECT_OWNER" --format json --limit 100 | | |
| jq -r --arg title "$BASE_PROJECT_TITLE" \ | |
| '.projects[] | select(.title == $title) | .number' | | |
| head -n 1 | |
| )" | |
| if [[ -z "$project_number" ]]; then | |
| echo "::error::GitHub Project '$BASE_PROJECT_TITLE' was not found for owner '$BASE_PROJECT_OWNER'." | |
| echo "::error::If this Project exists, set BASE_PROJECT_TOKEN with user Project read/write access." | |
| echo "::error::Fix: gh auth token | gh secret set BASE_PROJECT_TOKEN --repo $GITHUB_REPOSITORY" | |
| exit 1 | |
| fi | |
| project_id="$(project_intake_gh "view Project" gh project view "$project_number" --owner "$BASE_PROJECT_OWNER" --format json --jq '.id')" | |
| item_id="$(project_intake_gh "add Project item" gh project item-add "$project_number" --owner "$BASE_PROJECT_OWNER" --url "$issue_url" --format json --jq '.id')" | |
| item_json="$( | |
| project_intake_gh "list Project items" gh project item-list "$project_number" --owner "$BASE_PROJECT_OWNER" --format json --limit 1000 | | |
| jq --arg id "$item_id" '.items[] | select(.id == $id)' | |
| )" | |
| fields_json="$(project_intake_gh "list Project fields" gh project field-list "$project_number" --owner "$BASE_PROJECT_OWNER" --format json)" | |
| field_id_for() { | |
| local field_name="$1" | |
| jq -r --arg name "$field_name" \ | |
| '.fields[] | select(.name == $name) | .id' <<<"$fields_json" | | |
| head -n 1 | |
| } | |
| 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 | |
| } | |
| set_single_select() { | |
| local field_name="$1" | |
| local option_name="$2" | |
| local field_id | |
| local option_id | |
| [[ -n "$option_name" ]] || return 0 | |
| field_id="$(field_id_for "$field_name")" | |
| option_id="$(option_id_for "$field_name" "$option_name")" | |
| if [[ -z "$field_id" || -z "$option_id" ]]; then | |
| echo "::error::Project field '$field_name' option '$option_name' was not found." | |
| exit 1 | |
| fi | |
| 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 | |
| } | |
| set_single_select_if_missing() { | |
| local field_name="$1" | |
| local item_key="$2" | |
| local option_name="$3" | |
| local current_value | |
| current_value="$(jq -r --arg key "$item_key" '.[$key] // ""' <<<"$item_json")" | |
| if [[ -n "$current_value" ]]; then | |
| return 0 | |
| fi | |
| set_single_select "$field_name" "$option_name" | |
| } | |
| status_value="$BASE_PROJECT_DEFAULT_OPEN_STATUS" | |
| if [[ "$issue_state" == "CLOSED" ]]; then | |
| status_value="$BASE_PROJECT_DEFAULT_CLOSED_STATUS" | |
| fi | |
| set_single_select Status "$status_value" | |
| set_single_select_if_missing Priority priority "$BASE_PROJECT_DEFAULT_PRIORITY" | |
| set_single_select_if_missing Size size "$BASE_PROJECT_DEFAULT_SIZE" | |
| set_single_select_if_missing Area area "$BASE_PROJECT_DEFAULT_AREA" | |
| set_single_select_if_missing Initiative initiative "$BASE_PROJECT_DEFAULT_INITIATIVE" | |
| printf 'Synced issue #%s into Project %s.\n' "$issue_number" "$BASE_PROJECT_TITLE" |