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 }}