Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions .github/scripts/check_pr_description.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""Check that a pull request's description names its tracking issue.

Rule sim.process.pr-tracking-issue: the first line of every pull request's
description is ``Fixes #ISSUE_NUMBER``, and ISSUE_NUMBER is an open issue, not a
pull request, in this repository. The "PR description" workflow job runs this
script; it prints one error annotation and exits 1 when the rule is broken.

Environment:
GITHUB_EVENT_PATH the pull_request event payload, which holds the
description (set by the GitHub Actions runner)
GITHUB_REPOSITORY owner/name of this repository (set by the runner)
GH_TOKEN read by gh, not by this script; the workflow sets it to
the job's github.token, which needs only issues: read

How the first line is read:

- A line ends at a CRLF, a carriage return or a line feed, as in Markdown, so
descriptions edited in the GitHub web interface (which uses CRLF) pass.
- Trailing spaces and tabs are ignored: they don't show, and GitHub still reads
the closing keyword.
- Leading whitespace fails. Four leading spaces make the line a Markdown code
block, where GitHub doesn't read closing keywords, and any indent is a sign
the line isn't what the author meant.
- An empty or missing (``null``) description fails.
- The number is ASCII digits without a leading zero.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
from pathlib import Path
from urllib.parse import urlsplit

RULE_ID = "sim.process.pr-tracking-issue"
HOW_TO_FIX = (
"Edit the description so its first line is exactly Fixes #ISSUE_NUMBER, "
"where ISSUE_NUMBER is an open issue in this repository; editing the "
"description runs this check again. See docs/engineering/skills/github-prs.md "
f"(rule {RULE_ID})."
)
FIXES_LINE = re.compile(r"Fixes #([1-9][0-9]*)")
# Markdown line endings: CRLF, a carriage return alone, or a line feed.
LINE_ENDING = re.compile(r"\r\n|\r|\n")
# How much of a rejected first line a message quotes.
SHOWN_FIRST_LINE_LENGTH = 80
# gh api's error for a number GitHub has no issue or pull request for (404),
# or one whose issue was deleted (410).
MISSING_ISSUE = re.compile(r"\(HTTP (?:404|410)\)")
# The path of an issue's repository_url: [api/v3/]repos/<owner>/<name>.
REPOSITORY_PATH = re.compile(r"(?:.+/)?repos/([^/]+/[^/]+)")
ANNOTATION_TITLE = "PR description"


class DescriptionError(Exception):
"""The description, or the issue it names, does not satisfy the rule."""


def _shown(line: str) -> str:
"""Return ``line`` quoted for a message, shortened when it is long."""
if len(line) > SHOWN_FIRST_LINE_LENGTH:
line = line[:SHOWN_FIRST_LINE_LENGTH].rstrip() + "..."
return f'"{line}"'


def issue_number_from_description(body: str | None) -> int:
"""Return the issue number on the description's first line.

Raise ``DescriptionError``, with a message that says how to fix the
description, unless the first line is ``Fixes #ISSUE_NUMBER``.
"""
if body is None or not body.strip():
msg = f"The description is empty. {HOW_TO_FIX}"
raise DescriptionError(msg)

lines = [line.rstrip(" \t") for line in LINE_ENDING.split(body)]
first_line = lines[0]
match = FIXES_LINE.fullmatch(first_line)
if match:
return int(match.group(1))

if FIXES_LINE.fullmatch(first_line.lstrip()):
msg = (
f"The first line starts with whitespace: {_shown(first_line)}. {HOW_TO_FIX}"
)
raise DescriptionError(msg)
for line_number, line in enumerate(lines[1:], start=2):
if FIXES_LINE.fullmatch(line.strip()):
msg = (
f"{line.strip()} is on line {line_number}, but it must be the "
f"first line. {HOW_TO_FIX}"
)
raise DescriptionError(msg)
msg = f"The first line is {_shown(first_line)}. {HOW_TO_FIX}"
raise DescriptionError(msg)


def fetch_issue(repository: str, number: int) -> dict[str, object]:
"""Return GitHub's answer for issue ``number`` in ``repository``, from gh api.

The issues API also answers for pull requests, and gh follows the redirect
GitHub sends for an issue transferred to another repository, so the answer
still has to be checked with ``check_issue``.
"""
completed = subprocess.run(
["gh", "api", f"repos/{repository}/issues/{number}"],
capture_output=True,
text=True,
check=False,
)
if completed.returncode != 0:
if MISSING_ISSUE.search(completed.stderr):
msg = f"There is no issue #{number} in {repository}. {HOW_TO_FIX}"
raise DescriptionError(msg)
reported = completed.stderr.strip() or f"exit status {completed.returncode}"
msg = (
f"Could not look up #{number} in {repository}: {reported}. Re-run this "
"job; if it fails again, check the GH_TOKEN the workflow gives it."
)
raise DescriptionError(msg)
try:
issue = json.loads(completed.stdout)
except json.JSONDecodeError:
issue = None
if not isinstance(issue, dict):
msg = f"gh api did not print an issue for #{number}. Re-run this job."
raise DescriptionError(msg)
return issue


def _repository_of(repository_url: object) -> str | None:
"""Return ``owner/name`` from an issue's ``repository_url``, if it has one."""
if not isinstance(repository_url, str):
return None
match = REPOSITORY_PATH.fullmatch(urlsplit(repository_url).path.strip("/"))
return match.group(1) if match else None


def check_issue(repository: str, number: int, issue: dict[str, object]) -> None:
"""Raise ``DescriptionError`` unless ``issue`` is open issue ``number`` here."""
issue_repository = _repository_of(issue.get("repository_url"))
if issue_repository is None:
msg = (
f"GitHub's answer for #{number} names no repository, so it can't be "
f"confirmed as an issue in {repository}. Re-run this job."
)
raise DescriptionError(msg)
if issue_repository.lower() != repository.lower() or issue.get("number") != number:
msg = (
f"#{number} is not an issue in {repository}: it moved to "
f"{issue_repository}#{issue.get('number')}. {HOW_TO_FIX}"
)
raise DescriptionError(msg)
if issue.get("pull_request") is not None:
msg = f"#{number} is a pull request, not an issue. {HOW_TO_FIX}"
raise DescriptionError(msg)
if issue.get("state") != "open":
msg = (
f"Issue #{number} is {issue.get('state')}. If this pull request still "
"fixes it, reopen it and re-run this job; otherwise open an issue for "
f"this work and edit the first line to name it (rule {RULE_ID})."
)
raise DescriptionError(msg)


def _required_environment(name: str) -> str:
value = os.environ.get(name)
if not value:
msg = f"{name} is required"
raise SystemExit(msg)
return value


def _description_from_event(event_path: str) -> str | None:
"""Return the pull request description in the event payload at ``event_path``."""
event = json.loads(Path(event_path).read_text(encoding="utf-8"))
pull_request = event.get("pull_request") if isinstance(event, dict) else None
if not isinstance(pull_request, dict):
msg = (
f"The event payload at {event_path} has no pull_request; run this "
"check on pull_request events."
)
raise SystemExit(msg)
body = pull_request.get("body")
return body if isinstance(body, str) else None


def _annotation_text(message: str) -> str:
"""Escape ``message`` so the runner reads it as one annotation message."""
return message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")


def main() -> int:
"""Check the pull request in this run's event; return the exit status."""
repository = _required_environment("GITHUB_REPOSITORY")
description = _description_from_event(_required_environment("GITHUB_EVENT_PATH"))
try:
number = issue_number_from_description(description)
check_issue(repository, number, fetch_issue(repository, number))
except DescriptionError as error:
print(f"::error title={ANNOTATION_TITLE}::{_annotation_text(str(error))}")
return 1
print(
f"The first line is Fixes #{number}, and #{number} is an open issue in "
f"{repository}."
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
119 changes: 113 additions & 6 deletions .github/scripts/dependency-update-create-pr.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,121 @@
#!/usr/bin/env bash

# Open a dependency-update PR after a new update branch has been pushed.
# After an update branch has been pushed, make sure an open dependency-update
# PR exists and that the first line of its description names its tracking
# issue, as rule sim.process.pr-tracking-issue requires of every PR.
#
# The tracking issue is the oldest open issue titled "Update dependencies", or
# a new one when none is open. "Fixes #N" closes it when the PR merges, so the
# next update PR gets a new issue.
#
# - Without an open update PR, open one whose description starts with
# "Fixes #N".
# - With one, make "Fixes #N" its first line: replace an earlier "Fixes #"
# line, or add the line above a description that has none (such as a PR
# opened before the rule).
#
# Environment:
# GITHUB_REPOSITORY owner/name of this repository (set by the runner)
# GH_TOKEN read by gh: a token that can read and write issues and
# pull requests

set -euo pipefail

: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"

gh pr create \
ISSUE_TITLE="Update dependencies"
ISSUE_BODY="Tracks the automated daily dependency updates from update-dependencies.yml. The update pull request names this issue on the first line of its description and closes it when it merges."
PR_SUMMARY="Automated daily dependency updates. Subsequent runs append commits to this branch while the pull request remains open."

# Set ISSUE_NUMBER to the oldest open "Update dependencies" issue, opening one
# when none is open. The issues API lists pull requests too; they have a
# pull_request field. This sets a variable rather than printing, because bash
# turns off set -e inside $(...), and a failed lookup must not open an issue.
find_or_open_tracking_issue() {
local open_issues
local issue_number
local issue_url

open_issues="$(
gh api --paginate \
"repos/${GITHUB_REPOSITORY}/issues?state=open&per_page=100" \
--jq ".[] | select(.pull_request == null and .title == \"${ISSUE_TITLE}\") | .number"
)"
if [[ -n "${open_issues}" ]]; then
issue_number="$(sort -n <<<"${open_issues}")"
issue_number="${issue_number%%$'\n'*}"
else
issue_url="$(
gh issue create \
--repo "${GITHUB_REPOSITORY}" \
--title "${ISSUE_TITLE}" \
--body "${ISSUE_BODY}"
)"
issue_number="${issue_url##*/}"
fi

if [[ ! "${issue_number}" =~ ^[0-9]+$ ]]; then
echo "Could not resolve the dependency-update tracking issue: ${issue_number}" >&2
exit 1
fi
ISSUE_NUMBER="${issue_number}"
}

# Print the description with "Fixes #<issue>" as its first line.
description_naming_issue() {
local description="$1"
local issue_number="$2"
local first_line="${description%%$'\n'*}"
local fixes_line="Fixes #${issue_number}"

if [[ "${first_line}" =~ ^Fixes\ \#[0-9]+[[:space:]]*$ ]]; then
if [[ "${description}" == *$'\n'* ]]; then
printf '%s\n%s' "${fixes_line}" "${description#*$'\n'}"
else
printf '%s' "${fixes_line}"
fi
else
printf '%s\n\n%s' "${fixes_line}" "${description}"
fi
}

open_pr="$(
gh pr list \
--repo "${GITHUB_REPOSITORY}" \
--base main \
--head update-dependencies \
--state open \
--json number \
--jq '.[0].number // empty'
)"
ISSUE_NUMBER=""
find_or_open_tracking_issue

if [[ -z "${open_pr}" ]]; then
gh pr create \
--repo "${GITHUB_REPOSITORY}" \
--title "Update dependencies" \
--body-file - \
--base main \
--head update-dependencies \
<<<"Fixes #${ISSUE_NUMBER}"$'\n\n'"${PR_SUMMARY}"
exit 0
fi

description="$(
gh pr view "${open_pr}" \
--repo "${GITHUB_REPOSITORY}" \
--json body \
--jq '.body'
)"
linked_description="$(description_naming_issue "${description}" "${ISSUE_NUMBER}")"
if [[ "${linked_description}" == "${description}" ]]; then
echo "Pull request #${open_pr} already names tracking issue #${ISSUE_NUMBER}."
exit 0
fi

gh pr edit "${open_pr}" \
--repo "${GITHUB_REPOSITORY}" \
--title "Update dependencies" \
--body "Automated daily dependency updates. Subsequent runs append commits to this branch while the pull request remains open." \
--base main \
--head update-dependencies
--body-file - \
<<<"${linked_description}"
echo "Pull request #${open_pr} now names tracking issue #${ISSUE_NUMBER}."
52 changes: 52 additions & 0 deletions .github/workflows/pr-description.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: PR description

# Rule sim.process.pr-tracking-issue: the first line of every pull request's
# description is "Fixes #ISSUE_NUMBER", naming an open issue in this
# repository. See docs/engineering/skills/github-prs.md.
#
# A separate workflow from pr.yml, so that editing a description reruns only
# this check, not the whole of CI. It has no branches or paths filter and no
# stack condition: it is cheap, so every open pull request runs it, including
# each one in a stack.

on:
pull_request:
types: [opened, edited, reopened, synchronize]

# The check reads the description from the event payload and looks the issue
# up with the job's token, so it needs only read access. Pull requests from
# forks get a read-only token, which is enough.
permissions:
contents: read
issues: read
pull-requests: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
pr-description:
name: PR description
# GitHub also sends "edited" for closed pull requests. Merging closed the
# issue a merged pull request names, so checking one would fail.
if: ${{ github.event.pull_request.state == 'open' }}
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout the check script
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts/check_pr_description.py
sparse-checkout-cone-mode: false

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'

- name: Check the first line and the tracking issue
env:
GH_TOKEN: ${{ github.token }}
run: python .github/scripts/check_pr_description.py
Loading
Loading