Files
wehub-resource-sync 9201ef759e
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

422 lines
23 KiB
YAML

name: PR Guard
on:
# zizmor: ignore[dangerous-triggers] -- pull_request_target is needed to close duplicate/unlinked PRs and manage issues; no fork code is checked out
pull_request_target:
types: [opened, reopened, ready_for_review, edited]
permissions: {}
concurrency:
group: pr-guard-${{ github.event.pull_request.number }}
jobs:
guard:
name: Guard
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check linked issues and guard against duplicates
run: |
set -euo pipefail
PR_NUMBER="${GITHUB_EVENT_PULL_REQUEST_NUMBER}"
PR_AUTHOR="${GITHUB_EVENT_PULL_REQUEST_USER_LOGIN}"
AUTHOR_ASSOCIATION="${GITHUB_EVENT_PULL_REQUEST_AUTHOR_ASSOCIATION}"
echo "PR #${PR_NUMBER} by ${PR_AUTHOR} (${AUTHOR_ASSOCIATION})"
# `edited` also fires on closed and merged PRs (e.g. an author updating
# the description before reopening, as the close comment suggests, or a
# bot rewriting the description). Nothing to check, and `gh pr close`
# on an already-closed PR would fail the job.
if [ "${GITHUB_EVENT_PULL_REQUEST_STATE}" != "open" ]; then
echo "PR is ${GITHUB_EVENT_PULL_REQUEST_STATE}, skipping checks."
exit 0
fi
# Skip draft PRs — authors may create drafts to save progress
if [ "${GITHUB_EVENT_PULL_REQUEST_DRAFT}" = "true" ]; then
echo "PR is a draft, skipping checks."
exit 0
fi
# Only check PRs targeting the default branch. GitHub interprets closing
# keywords ("Fixes #123") *only* on default-branch PRs; on any other base
# branch they're ignored and closingIssuesReferences is always empty, so
# enforcing the issue-link policy there would close validly-linked PRs (e.g.
# a fix targeting a maintenance branch). Fail open if either value is empty.
if [ -z "${DEFAULT_BRANCH}" ] || [ -z "${GITHUB_EVENT_PULL_REQUEST_BASE_REF}" ] || [ "${GITHUB_EVENT_PULL_REQUEST_BASE_REF}" != "${DEFAULT_BRANCH}" ]; then
echo "PR base '${GITHUB_EVENT_PULL_REQUEST_BASE_REF}' is not the default branch '${DEFAULT_BRANCH}'; skipping checks."
exit 0
fi
# Maintainer bypass
if [[ "$AUTHOR_ASSOCIATION" == "MEMBER" || "$AUTHOR_ASSOCIATION" == "OWNER" || "$AUTHOR_ASSOCIATION" == "COLLABORATOR" ]]; then
echo "Author is a maintainer/collaborator, skipping checks."
exit 0
fi
# Author repo-role bypass. The webhook's `author_association` is unreliable — it
# can report CONTRIBUTOR for a genuine org member/collaborator (observed on #6359:
# `dsfaccini`, a public org member with `maintain` role, came through as
# CONTRIBUTOR, so the bypass above missed and the PR was wrongly closed). Confirm
# the author's actual role on THIS repo via the API — the same `role_name` signal
# the trusted-actor bypass below uses for the sender — and skip for triage-or-higher.
# Fails open toward *checking* (an unreadable/unknown role continues the checks), so
# an external contributor can never gain a bypass this way.
AUTHOR_ROLE=$(gh api "repos/${REPO}/collaborators/${PR_AUTHOR}/permission" --jq '.role_name' 2>/dev/null || echo "unknown")
case "$AUTHOR_ROLE" in
triage | write | maintain | admin)
echo "Author ${PR_AUTHOR} has ${AUTHOR_ROLE} access to this repo; skipping checks."
exit 0
;;
*)
echo "Author ${PR_AUTHOR} repo role: ${AUTHOR_ROLE}; continuing checks."
;;
esac
# Private-membership bypass. The `author_association` in the webhook only
# reflects *public* org membership, so a private member of the org that
# owns this repo is reported as CONTRIBUTOR and misses the bypass above.
# Confirm real membership via the API, which sees private members when the
# token has `read:org` — the default `github.token` cannot, so this uses
# `ORG_READ_TOKEN` (an existing org-scoped secret). Fails open: if that
# token is unset or lacks the scope the API call errors and the existing
# policy applies unchanged, so no unlinked PR is ever wrongly kept open.
if [ -n "${ORG_READ_TOKEN:-}" ] && GH_TOKEN="$ORG_READ_TOKEN" gh api "orgs/${REPO%/*}/members/${PR_AUTHOR}" >/dev/null 2>&1; then
echo "Author ${PR_AUTHOR} is an org member (confirmed via API, incl. private membership); skipping checks."
exit 0
fi
# Trusted-actor bypass. AUTHOR_ASSOCIATION above is the PR *author's*
# relationship to the repo, so without this the guard re-closes a
# contributor PR every time a maintainer acts on it: reopening it, or
# editing its title/body (`reopened` and `edited` both re-run this
# workflow, so a reopen-only bypass gets undone by the next edit). When
# the person who triggered this run isn't the author, confirm they have
# triage-or-higher access on *this* repo and, if so, treat their action
# as a deliberate decision to keep the PR open and skip the checks.
# - We read `role_name`, not the legacy `permission` (which collapses
# triage into read), so a triager — who can legitimately reopen —
# still counts.
# - We require `sender != author` and a base-repo role because GitHub
# also lets a collaborator on the contributor's *fork* reopen the PR;
# `sender != author` alone isn't proof of base-repo trust.
# - If the role can't be determined, err toward respecting the action:
# this is a courtesy gate (trivially satisfied by linking any open
# issue), not a security boundary, and a deliberate maintainer action
# shouldn't be undone by a transient API hiccup.
# The author acting on their own PR (e.g. the `opened` event, where the
# sender is always the author) still gets checked, so the issue-link
# policy can't be bypassed this way.
if [ "${GITHUB_EVENT_SENDER_LOGIN}" != "$PR_AUTHOR" ]; then
SENDER_ROLE=$(gh api "repos/${REPO}/collaborators/${GITHUB_EVENT_SENDER_LOGIN}/permission" --jq '.role_name' 2>/dev/null || echo "unknown")
case "$SENDER_ROLE" in
read | none)
echo "Event triggered by ${GITHUB_EVENT_SENDER_LOGIN} (role: ${SENDER_ROLE}, no write access); continuing checks."
;;
*)
echo "Event triggered by ${GITHUB_EVENT_SENDER_LOGIN} (role: ${SENDER_ROLE}); a trusted collaborator acted on this PR, skipping checks."
exit 0
;;
esac
fi
# pydanty is the autonomous agent that opens PRs from issues. Its PRs are
# never auto-closed for duplication; instead a pydanty PR supersedes a
# competing PR opened within WINDOW_SECONDS before it (see the loop below).
PYDANTY_LOGIN="pydanty[bot]"
WINDOW_SECONDS=600
IS_PYDANTY_PR="false"
if [ "$PR_AUTHOR" = "$PYDANTY_LOGIN" ]; then
IS_PYDANTY_PR="true"
fi
PR_CREATED_EPOCH=$(date -u -d "$GITHUB_EVENT_PULL_REQUEST_CREATED_AT" +%s)
# Helper: close a contributor PR that doesn't link to any existing issue.
# Only external contributors reach this point (maintainers/collaborators
# bypass above). Exemptions:
# - bot authors (pydanty, dependabot, etc.) — they never file issues first
# - docs-only changes — small doc fixes are welcome without an issue
close_for_missing_issue() {
case "$PR_AUTHOR" in
*"[bot]")
echo "Author ${PR_AUTHOR} is a bot; not closing for missing issue link."
return 0
;;
esac
# If the changed files can't be determined, err on the side of leaving
# the PR open: closing is destructive and the docs-only exemption
# can't be ruled out.
if ! CHANGED_FILES=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" --paginate --jq '.[].filename') || [ -z "$CHANGED_FILES" ]; then
echo "Could not determine changed files; not closing for missing issue link."
return 0
fi
DOCS_ONLY="true"
while IFS= read -r CHANGED_FILE; do
case "$CHANGED_FILE" in
docs/*|*.md|mkdocs.yml) ;;
*) DOCS_ONLY="false"; break ;;
esac
done <<< "$CHANGED_FILES"
if [ "$DOCS_ONLY" = "true" ]; then
echo "PR only touches documentation; not closing for missing issue link."
return 0
fi
echo "Closing PR #${PR_NUMBER} — no linked issue."
COMMENT=$(printf '%s\n\n%s\n\n%s\n\n%s' \
"Thanks for the contribution! To make sure changes are discussed before code is written, we ask that every PR references an existing issue with a closing keyword (e.g. \`Fixes #1234\`) in its description, so this PR has been closed automatically." \
"If there's no issue for this yet, please open one first (e.g. a bug report with a reproducible example), wait for a maintainer to confirm it, then update this PR's description to reference it and reopen the PR." \
"Documentation-only fixes are exempt and don't need an issue." \
"Feel free to ping our team if you think this was closed in error.")
# Comment before closing so the PR can never end up closed without an
# explanation; if commenting fails, set -e stops us and the PR stays open.
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$COMMENT"
gh pr close "$PR_NUMBER" --repo "$REPO"
}
# Ask GitHub which issues this PR would close, rather than parsing the
# body ourselves: GitHub's own parser is the authority on closing-keyword
# syntax ("Fixes #123", "Fixes: #123", "Fixes owner/repo#123", full issue
# URLs) and only counts references that resolve to real issues. Closed
# issues are included (with state CLOSED), so the checks below still see
# them. References to issues in other repos don't count: the policy
# requires an issue in this repo. If the query fails, set -e fails the
# job and the PR is left open.
fetch_linked_issues() {
gh api graphql \
-f owner="${REPO%/*}" -f name="${REPO#*/}" -F number="$PR_NUMBER" \
-f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
closingIssuesReferences(first: 100) {
nodes { number repository { nameWithOwner } }
}
}
}
}' \
--jq ".data.repository.pullRequest.closingIssuesReferences.nodes[] | select(.repository.nameWithOwner == \"${REPO}\") | .number"
}
ISSUE_NUMBERS=$(fetch_linked_issues)
if [ -z "$ISSUE_NUMBERS" ]; then
# GitHub resolves closing references asynchronously, so a query right
# after a PR is opened or edited can transiently come back empty.
# Closing is destructive — wait and confirm before acting on it.
echo "No linked issues found; re-checking in 30s in case GitHub hasn't resolved references yet."
sleep 30
ISSUE_NUMBERS=$(fetch_linked_issues)
fi
if [ -z "$ISSUE_NUMBERS" ]; then
echo "No linked issues found in PR body."
close_for_missing_issue
exit 0
fi
echo "Found linked issues: $ISSUE_NUMBERS"
# Helper: attempt to assign a user to an issue.
# GitHub silently ignores assignees who lack push access (returns 201 but doesn't add them).
# We check assignability first via GET /repos/{owner}/{repo}/assignees/{user} (204 = yes, 404 = no).
try_assign() {
local issue_num="$1"
local user="$2"
if gh api "repos/${REPO}/assignees/${user}" > /dev/null 2>&1; then
gh api "repos/${REPO}/issues/${issue_num}/assignees" --method POST -f "assignees[]=${user}" > /dev/null
echo "Assigned ${user} to issue #${issue_num}."
return 0
else
echo "Cannot assign ${user} to issue #${issue_num} (user is not assignable to this repo)."
return 1
fi
}
FOUND_OPEN_ISSUE="false"
FOUND_CLOSED_ISSUE="false"
for ISSUE_NUM in $ISSUE_NUMBERS; do
echo ""
echo "--- Checking issue #${ISSUE_NUM} ---"
# Issues come from closingIssuesReferences, so they are known to exist;
# a fetch failure here is transient. Let set -e fail the job (leaving
# the PR open) rather than treating the reference as unresolved, which
# could get a validly-linked PR closed.
ISSUE_JSON=$(gh api "repos/${REPO}/issues/${ISSUE_NUM}")
# Skip if this is actually a pull request
IS_PR=$(echo "$ISSUE_JSON" | jq 'has("pull_request")')
if [ "$IS_PR" = "true" ]; then
echo "#${ISSUE_NUM} is a pull request, not an issue. Skipping."
continue
fi
# Skip closed issues — duplicate check only applies to open issues
ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.state')
if [ "$ISSUE_STATE" != "open" ]; then
echo "Issue #${ISSUE_NUM} is ${ISSUE_STATE}, skipping."
FOUND_CLOSED_ISSUE="true"
continue
fi
FOUND_OPEN_ISSUE="true"
ISSUE_AUTHOR=$(echo "$ISSUE_JSON" | jq -r '.user.login')
# Check for duplicate/blocking PRs first — this must run regardless of assignment outcome.
# As with the current PR's linked issues, ask GitHub which open PRs
# would close this issue instead of regex-matching PR bodies, so all
# closing syntaxes (and manually linked PRs) are recognized. Bot
# logins get their "[bot]" suffix restored to match the REST-style
# logins used elsewhere (PYDANTY_LOGIN, ISSUE_AUTHOR).
# Only PRs created before this one can block it: two PRs racing each
# other's guard runs would otherwise both see the other as open and
# both get closed, and a newer PR (e.g. pydanty's, deliberately left
# alongside an older human PR) must not close the older one when an
# edit re-triggers the guard. The pydanty supersede logic below is
# unaffected: it only ever targets PRs opened before pydanty's.
echo "Checking for existing PRs targeting issue #${ISSUE_NUM}..."
BLOCKING_PRS=""
FOUND_STALE_PR="false"
MATCHING_PRS=$(gh api graphql \
-f owner="${REPO%/*}" -f name="${REPO#*/}" -F number="$ISSUE_NUM" \
-f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
closedByPullRequestsReferences(first: 100) {
nodes {
number
state
createdAt
repository { nameWithOwner }
author { __typename login }
labels(first: 100) { nodes { name } }
}
}
}
}
}' \
--jq ".data.repository.issue.closedByPullRequestsReferences.nodes[]
| select(.state == \"OPEN\" and .repository.nameWithOwner == \"${REPO}\" and .number != ${PR_NUMBER} and .createdAt < \"${GITHUB_EVENT_PULL_REQUEST_CREATED_AT}\")
| {number, created_at: .createdAt, user: {login: (if .author.__typename == \"Bot\" then .author.login + \"[bot]\" else .author.login end)}, labels: [.labels.nodes[]]}")
while IFS= read -r PR_JSON; do
[ -z "$PR_JSON" ] && continue
EXISTING_PR_NUM=$(echo "$PR_JSON" | jq -r '.number')
EXISTING_PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.user.login')
echo "Found PR #${EXISTING_PR_NUM} by ${EXISTING_PR_AUTHOR} that references issue #${ISSUE_NUM}."
# Check if the existing PR has the Stale label
HAS_STALE=$(echo "$PR_JSON" | jq '[.labels[].name] | any(. == "Stale")')
if [ "$HAS_STALE" = "true" ]; then
echo "PR #${EXISTING_PR_NUM} is stale. Allowing new PR to supersede."
FOUND_STALE_PR="true"
continue
fi
# pydanty's own PR is never closed here. Instead it supersedes a
# competing PR that opened within WINDOW_SECONDS before it, unless
# that PR belongs to the issue author (who keeps priority).
if [ "$IS_PYDANTY_PR" = "true" ]; then
if [ "$EXISTING_PR_AUTHOR" = "$PYDANTY_LOGIN" ]; then
echo "PR #${EXISTING_PR_NUM} is also pydanty's; leaving it."
continue
fi
if [ "$EXISTING_PR_AUTHOR" = "$ISSUE_AUTHOR" ]; then
echo "PR #${EXISTING_PR_NUM} is by the issue author (${ISSUE_AUTHOR}); leaving both open for a maintainer."
continue
fi
EXISTING_EPOCH=$(date -u -d "$(echo "$PR_JSON" | jq -r '.created_at')" +%s)
DELTA=$(( PR_CREATED_EPOCH - EXISTING_EPOCH ))
if [ "$DELTA" -ge 0 ] && [ "$DELTA" -le "$WINDOW_SECONDS" ]; then
echo "pydanty PR #${PR_NUMBER} opened ${DELTA}s after PR #${EXISTING_PR_NUM} (<= ${WINDOW_SECONDS}s); superseding it."
SUPERSEDE_COMMENT=$(printf '%s\n\n%s' \
"An automated pull request from @${PYDANTY_LOGIN} addressing issue #${ISSUE_NUM} opened within ~10 minutes of this one, so it is taking precedence and this PR has been closed to avoid duplicate effort." \
"Thanks for contributing — if you'd like to keep working on this, please comment on [issue #${ISSUE_NUM}](https://github.com/${REPO}/issues/${ISSUE_NUM}) and a maintainer can help coordinate.")
gh pr comment "$EXISTING_PR_NUM" --repo "$REPO" --body "$SUPERSEDE_COMMENT"
gh pr close "$EXISTING_PR_NUM" --repo "$REPO"
else
echo "PR #${EXISTING_PR_NUM} opened more than ${WINDOW_SECONDS}s before pydanty's; leaving both open for a maintainer."
fi
continue
fi
# Collect all non-stale blocking PRs
if [ -z "$BLOCKING_PRS" ]; then
BLOCKING_PRS="#${EXISTING_PR_NUM}"
else
BLOCKING_PRS="${BLOCKING_PRS}, #${EXISTING_PR_NUM}"
fi
done <<< "$MATCHING_PRS"
if [ "$IS_PYDANTY_PR" != "true" ] && [ -n "$BLOCKING_PRS" ]; then
echo "Closing PR #${PR_NUMBER} — issue #${ISSUE_NUM} already has active PRs: ${BLOCKING_PRS}"
COMMENT=$(printf '%s\n\n%s\n\n%s' \
"Thanks for your interest in this issue! However, there are already open PRs addressing issue #${ISSUE_NUM}: ${BLOCKING_PRS}." \
"To avoid duplicate efforts, this PR has been closed. If you'd like to contribute, you can review the existing PRs or share your thoughts on [issue #${ISSUE_NUM}](https://github.com/${REPO}/issues/${ISSUE_NUM})." \
"If you believe the existing PRs are inactive, please comment on the issue and a maintainer can reassess.")
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$COMMENT"
gh pr close "$PR_NUMBER" --repo "$REPO"
exit 0
fi
if [ "$FOUND_STALE_PR" = "true" ]; then
echo "All existing PRs for issue #${ISSUE_NUM} are stale. Allowing new PR."
fi
# Now handle assignment (best-effort, does not gate duplicate check above)
ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '[.assignees[].login] | join(",")')
echo "Current assignees: ${ASSIGNEES:-none}"
if [ -z "$ASSIGNEES" ]; then
# No assignee — attempt to assign PR author (may fail if user lacks push access)
try_assign "$ISSUE_NUM" "$PR_AUTHOR" || true
elif echo ",$ASSIGNEES," | grep -qF ",${PR_AUTHOR},"; then
echo "Issue #${ISSUE_NUM} is already assigned to ${PR_AUTHOR}."
else
echo "Issue #${ISSUE_NUM} is assigned to someone else. Leaving assignment as-is for maintainers to review."
fi
done
# If none of the referenced numbers resolved to an actual issue (open or
# closed), the PR is effectively unlinked — same policy as no keywords at all.
if [ "$FOUND_OPEN_ISSUE" = "false" ] && [ "$FOUND_CLOSED_ISSUE" = "false" ]; then
echo "No referenced numbers resolved to actual issues."
close_for_missing_issue
exit 0
fi
# If we found closed issues but no open ones, close the PR (pydanty PRs are exempt).
if [ "$IS_PYDANTY_PR" != "true" ] && [ "$FOUND_CLOSED_ISSUE" = "true" ] && [ "$FOUND_OPEN_ISSUE" = "false" ]; then
echo "All referenced issues are closed. Closing PR."
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "All issues referenced by this PR are already closed. If you believe an issue should be reopened, please comment on it first."
gh pr close "$PR_NUMBER" --repo "$REPO"
fi
env:
GH_TOKEN: ${{ github.token }}
# Read-only org-scoped token, used solely to confirm private org
# membership (see the private-membership bypass). Falls back to empty
# when unset, which the bypass handles by failing open.
ORG_READ_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
REPO: ${{ github.repository }}
GITHUB_EVENT_SENDER_LOGIN: ${{ github.event.sender.login }}
GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_EVENT_PULL_REQUEST_USER_LOGIN: ${{ github.event.pull_request.user.login }}
GITHUB_EVENT_PULL_REQUEST_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
GITHUB_EVENT_PULL_REQUEST_CREATED_AT: ${{ github.event.pull_request.created_at }}
GITHUB_EVENT_PULL_REQUEST_DRAFT: ${{ github.event.pull_request.draft }}
GITHUB_EVENT_PULL_REQUEST_STATE: ${{ github.event.pull_request.state }}
GITHUB_EVENT_PULL_REQUEST_BASE_REF: ${{ github.event.pull_request.base.ref }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}