Skip to content

docs: rename WSProxy to App Proxy across the manual #1712

docs: rename WSProxy to App Proxy across the manual

docs: rename WSProxy to App Proxy across the manual #1712

name: "Sync PR Project Status"
# Mirrors a PR's review lifecycle into the GitHub Project V2 "Status" column of
# its linked issues. This repo's convention is that issues — not PRs — are
# attached to the project, so we walk PR -> linked issues -> each issue's
# project item.
#
# Event matrix:
# pull_request_target.ready_for_review -> "In review"
# pull_request_target.review_requested -> "In review" (re-review included)
# pull_request_target.converted_to_draft -> "In progress"
# pull_request_review.submitted (state=changes_requested) -> "Requested changes"
# pull_request_review.submitted (approved / commented) -> no-op
#
# Link detection combines:
# 1. GitHub's canonical `closingIssuesReferences` (covers manual "Development"
# links and closing keywords auto-detected by GitHub).
# 2. A regex fallback over the PR body that matches `(close|fix|resolve)
# [sd|es|ed]? #<num>`. GitHub only registers closing keywords for PRs
# targeting the repo's default branch, so stacked PRs (common here via
# Graphite) never populate (1). The fallback is load-bearing — without
# it this workflow would be a near-total no-op for stacked PRs.
#
# Configuration (repository or organization variables):
# vars.PROJECT_ORGANIZATION — organization login that owns the Project V2.
# User-owned projects are not supported; the
# metadata query only resolves `organization(login:)`.
# vars.PROJECT_NUMBER — project number (integer). If unset, the
# workflow is a no-op.
#
# Secret:
# secrets.PROJECT_TOKEN — PAT or GitHub App token with `project` scope.
# The default GITHUB_TOKEN cannot write to
# Projects V2. If unset, the workflow is a no-op.
#
# Notes:
# * Uses `pull_request_target` and `pull_request_review` so the workflow has
# access to secrets for PRs opened from forks. No PR code is checked out
# or executed — only metadata is used via the GraphQL API.
# * Cross-repo references (e.g. `Resolves owner/repo#123`) are not resolved.
# Only same-repo issue numbers are followed.
# * If a linked issue is not attached to the configured project, that issue
# is skipped with a notice. No project items are created.
on:
pull_request_target:
types:
- ready_for_review
- converted_to_draft
- review_requested
pull_request_review:
types:
- submitted
permissions:
pull-requests: read
jobs:
sync-project-status:
runs-on: ubuntu-latest
env:
ORGANIZATION: ${{ vars.PROJECT_ORGANIZATION }}
PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
STATUS_FIELD_NAME: "Status"
IN_REVIEW_STATUS_NAME: "In review"
IN_PROGRESS_STATUS_NAME: "In progress"
CHANGES_REQUESTED_STATUS_NAME: "Requested changes"
steps:
- name: Resolve target status from event
id: target
env:
EVENT_ACTION: ${{ github.event.action }}
REVIEW_STATE: ${{ github.event.review.state }}
run: |
case "${GITHUB_EVENT_NAME}|${EVENT_ACTION}" in
"pull_request_target|ready_for_review" | "pull_request_target|review_requested")
target="${IN_REVIEW_STATUS_NAME}"
;;
"pull_request_target|converted_to_draft")
target="${IN_PROGRESS_STATUS_NAME}"
;;
"pull_request_review|submitted")
if [ "${REVIEW_STATE}" = "changes_requested" ]; then
target="${CHANGES_REQUESTED_STATUS_NAME}"
else
echo "::notice::Review submitted with state='${REVIEW_STATE}'. No status change."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
;;
*)
echo "::notice::Unsupported event '${GITHUB_EVENT_NAME}' action='${EVENT_ACTION}'. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "status_name=${target}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Fetch Project V2 metadata
id: project
if: steps.target.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
TARGET_STATUS: ${{ steps.target.outputs.status_name }}
run: |
if [ -z "$ORGANIZATION" ] || [ -z "$PROJECT_NUMBER" ]; then
echo "::warning::vars.PROJECT_ORGANIZATION or vars.PROJECT_NUMBER is not configured. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$GH_TOKEN" ]; then
echo "::warning::secrets.PROJECT_TOKEN is not configured. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
gh api graphql \
-f query='
query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
id
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options { id name }
}
}
}
}
}
}' \
-f org="$ORGANIZATION" \
-F number="$PROJECT_NUMBER" \
> project.json
project_id=$(jq -r '.data.organization.projectV2.id // empty' project.json)
if [ -z "$project_id" ]; then
echo "::warning::Project V2 '${ORGANIZATION}/#${PROJECT_NUMBER}' not found or inaccessible. Check PROJECT_TOKEN permissions. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
status_field_id=$(jq -r \
--arg name "$STATUS_FIELD_NAME" \
'.data.organization.projectV2.fields.nodes[]? | select(.name == $name) | .id // empty' \
project.json)
option_id=$(jq -r \
--arg name "$STATUS_FIELD_NAME" \
--arg option "$TARGET_STATUS" \
'.data.organization.projectV2.fields.nodes[]? | select(.name == $name) | .options[]? | select(.name == $option) | .id // empty' \
project.json)
if [ -z "$status_field_id" ] || [ -z "$option_id" ]; then
echo "::warning::Status field '$STATUS_FIELD_NAME' or option '$TARGET_STATUS' not found in project. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "project_id=$project_id" >> "$GITHUB_OUTPUT"
echo "status_field_id=$status_field_id" >> "$GITHUB_OUTPUT"
echo "option_id=$option_id" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Collect linked issue numbers
id: issues
if: steps.target.outputs.skip != 'true' && steps.project.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Source 1: GitHub's canonical closingIssuesReferences.
gh api graphql \
-f query='
query($url: URI!) {
resource(url: $url) {
... on PullRequest {
closingIssuesReferences(first: 20) {
nodes { number }
}
}
}
}' \
-f url="$PR_URL" \
> closing.json
api_numbers=$(jq -r '.data.resource.closingIssuesReferences.nodes[]?.number // empty' closing.json)
# Source 2: regex fallback over PR body. Many PRs in this repo use
# `Resolves #123 (FR-123)` without a GitHub-detected link; without
# this fallback the workflow would be a near-total no-op.
body_numbers=$(
printf '%s' "$PR_BODY" \
| grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?)[[:space:]]+#[0-9]+' \
| grep -oE '#[0-9]+' \
| tr -d '#' \
|| true
)
numbers=$(printf '%s\n%s\n' "$api_numbers" "$body_numbers" \
| grep -E '^[0-9]+$' \
| sort -u \
|| true)
if [ -z "$numbers" ]; then
echo "::notice::No linked issues detected for PR #${{ github.event.pull_request.number }}. Nothing to update."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Detected linked issues:"
echo "$numbers" | sed 's/^/ - #/'
{
echo "numbers<<__EOF__"
echo "$numbers"
echo "__EOF__"
} >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Update project status for each linked issue
if: steps.target.outputs.skip != 'true' && steps.project.outputs.skip != 'true' && steps.issues.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_TOKEN }}
REPO_FULL_NAME: ${{ github.event.pull_request.base.repo.full_name }}
PROJECT_ID: ${{ steps.project.outputs.project_id }}
STATUS_FIELD_ID: ${{ steps.project.outputs.status_field_id }}
OPTION_ID: ${{ steps.project.outputs.option_id }}
TARGET_STATUS: ${{ steps.target.outputs.status_name }}
NUMBERS: ${{ steps.issues.outputs.numbers }}
run: |
owner_name="${REPO_FULL_NAME%/*}"
repo_name="${REPO_FULL_NAME#*/}"
updated=0
skipped=0
while IFS= read -r issue_number; do
[ -z "$issue_number" ] && continue
gh api graphql \
-f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
projectItems(first: 20) {
nodes { id project { id } }
}
}
}
}' \
-f owner="$owner_name" \
-f name="$repo_name" \
-F number="$issue_number" \
> "issue_${issue_number}.json"
item_id=$(jq -r \
--arg pid "$PROJECT_ID" \
'[.data.repository.issue.projectItems.nodes[]? | select(.project.id == $pid) | .id] | first // empty' \
"issue_${issue_number}.json")
if [ -z "$item_id" ]; then
echo "::notice::Issue #${issue_number} is not attached to '${ORGANIZATION}/#${PROJECT_NUMBER}'. Skipping."
skipped=$((skipped + 1))
continue
fi
gh api graphql \
-f query='
mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $field
value: { singleSelectOptionId: $option }
}) {
projectV2Item { id }
}
}' \
-f project="$PROJECT_ID" \
-f item="$item_id" \
-f field="$STATUS_FIELD_ID" \
-f option="$OPTION_ID" \
> /dev/null
echo "::notice::Issue #${issue_number} status set to \"${TARGET_STATUS}\"."
updated=$((updated + 1))
done <<< "$NUMBERS"
echo "::notice::PR #${{ github.event.pull_request.number }} sync summary: updated=${updated}, skipped=${skipped}."