From 1278c1172e146a89c563609178f04450c4a86b9d Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 1 Nov 2026 21:42:29 -0500 Subject: [PATCH] docs(conventions): cite each number by its kind, and fix heading and Mermaid forms (#2561) - Cite a ruling from a pull request's review by the issue it closes, or by :pr: with no issue holding it, on the convention, pep, workflows and releasing pages and in the vendor/default and ip_pseudo doc text. - Rename finite-verb headings on the warnings, logging, ext and layer placement pages, repoint the protocols graph's click target, re-case lowercase content words in const, vendor and protocol titles, and escape the leading underscores in two Mermaid node labels. - The 1103 test checks each role's kind against a snapshot, skipping numbers above it; util/issue_pr_kinds.py regenerates it, and the new Issue and PR Kinds workflow proposes a refresh as a pull request. Closes #2561. --- .github/workflows/cron-issue-pr-kinds.yml | 112 ++++++++ .../conventions/const-vendor-pairing.rst | 15 +- .../conventions/documentation.rst | 10 + .../conventions/mint-criterion.rst | 4 +- .../contributing/conventions/process.rst | 6 +- .../conventions/protocol-family-grouping.rst | 29 +- .../conventions/protocol-layer-placement.rst | 14 +- .../conventions/reassembly-integration.rst | 4 +- .../conventions/registry-protocol.rst | 2 +- .../conventions/sentinel-convention.rst | 5 +- docs/source/contributing/pep.rst | 2 +- docs/source/contributing/releasing.rst | 14 +- docs/source/contributing/workflows.rst | 52 +++- docs/source/ext.rst | 6 +- docs/source/pcapkit/const/mh.rst | 2 +- docs/source/pcapkit/const/tcp.rst | 2 +- docs/source/pcapkit/const/vlan.rst | 2 +- .../pcapkit/protocols/application/sslv2.rst | 2 +- .../pcapkit/protocols/application/sslv3.rst | 2 +- docs/source/pcapkit/protocols/index.rst | 2 +- .../pcapkit/protocols/internet/igmpv1.rst | 2 +- .../pcapkit/protocols/internet/igmpv2.rst | 2 +- .../pcapkit/protocols/internet/igmpv3.rst | 2 +- .../pcapkit/protocols/internet/ipv4.rst | 2 +- .../pcapkit/protocols/internet/ipv6.rst | 2 +- docs/source/pcapkit/protocols/link/l2tpv2.rst | 2 +- docs/source/pcapkit/utilities/logging.rst | 4 +- docs/source/pcapkit/utilities/warnings.rst | 4 +- docs/source/pcapkit/vendor/mh.rst | 2 +- docs/source/pcapkit/vendor/tcp.rst | 2 +- docs/source/pcapkit/vendor/vlan.rst | 2 +- pcapkit/protocols/misc/checksum/ip_pseudo.py | 2 +- pcapkit/vendor/default.py | 6 +- .../issue_pr_roles_fixtures/kinds.json | 180 +++++++++++++ .../test_docs_issue_pr_roles_1103_unit.py | 146 +++++++++- tests/project/test_issue_pr_kinds_2561.py | 252 ++++++++++++++++++ .../test_release_tags_the_run_sha_1634.py | 8 +- util/issue_pr_kinds.py | 204 ++++++++++++++ 38 files changed, 1021 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/cron-issue-pr-kinds.yml create mode 100644 tests/project/issue_pr_roles_fixtures/kinds.json create mode 100644 tests/project/test_issue_pr_kinds_2561.py create mode 100644 util/issue_pr_kinds.py diff --git a/.github/workflows/cron-issue-pr-kinds.yml b/.github/workflows/cron-issue-pr-kinds.yml new file mode 100644 index 0000000000..0af9235b47 --- /dev/null +++ b/.github/workflows/cron-issue-pr-kinds.yml @@ -0,0 +1,112 @@ +name: Issue and PR Kinds + +# #2561: keeps tests/project/issue_pr_roles_fixtures/kinds.json current, the +# snapshot of issue and pull request numbers the #1103 roles test checks each +# :issue: and :pr: role against. `detect` regenerates it with +# util/issue_pr_kinds.py; `pr` proposes a change on `bot/issue-pr-kinds` for the +# owner to merge, and nothing here pushes `main`. `pr` pushes with the release +# App's token, not GITHUB_TOKEN, whose pushes start nothing, so the pull +# request's own checks run. +on: + schedule: + - cron: '17 6 * * 1' # Mondays at 06:17 UTC + workflow_dispatch: + +permissions: {} + +# One run at a time, so two runs cannot race for the branch. A queued run is +# kept, not cancelled: it regenerates from the tracker and skips what the +# branch already proposes. +concurrency: + group: issue-pr-kinds + cancel-in-progress: false + +jobs: + # No secret and a read-only token: it reads the tracker and writes only this + # checkout. The script runs on the runner's own python3, standard library only. + detect: + name: Regenerate the issue and pull request kinds + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: read + pull-requests: read + outputs: + publish: ${{ steps.regenerate.outputs.publish }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # `publish` is false when nothing changed, or when the open pull request + # from `bot/issue-pr-kinds` already holds this file, so an unmerged + # proposal is not re-pushed, nor its approval asked for, every week. + - name: Regenerate the kinds + id: regenerate + env: + GH_TOKEN: ${{ github.token }} + run: python3 util/issue_pr_kinds.py --github-output + + - name: Upload the kinds + if: ${{ steps.regenerate.outputs.publish == 'true' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: issue-pr-kinds + path: tests/project/issue_pr_roles_fixtures/kinds.json + retention-days: 30 + + - name: Say why no pull request opens + if: ${{ steps.regenerate.outputs.publish == 'true' && vars.RELEASE_APP_CLIENT_ID == '' }} + run: >- + echo "::warning title=No pull request without the release App::vars.RELEASE_APP_CLIENT_ID is unset, + so the pr job skips. Set up the App as docs/source/contributing/releasing.rst describes." + + # The release App's key is a secret of the `github-release` environment, so + # the owner approves only a run with something to propose. The token has no + # `workflows` permission, so the script recreates the branch rather than move + # it past a workflow change on main. + pr: + name: Open or update the pull request + needs: detect + if: >- + ${{ needs.detect.outputs.publish == 'true' && vars.RELEASE_APP_CLIENT_ID != '' && + github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: github-release + # GITHUB_TOKEN only downloads `detect`'s artifact; the App's token does the rest. + permissions: + actions: read + + steps: + - name: Mint the release App's token + id: app + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + # The commit `detect` read, so the branch is cut from it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + token: ${{ steps.app.outputs.token }} + + - name: Download the kinds + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name issue-pr-kinds + --dir "$RUNNER_TEMP/kinds" + + - name: Push the kinds and open or update their pull request + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + APP_SLUG: ${{ steps.app.outputs.app-slug }} + APP_BOT_ID: ${{ vars.RELEASE_APP_BOT_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: python3 util/issue_pr_kinds.py --publish "$RUNNER_TEMP/kinds/kinds.json" --run-url "$RUN_URL" diff --git a/docs/source/contributing/conventions/const-vendor-pairing.rst b/docs/source/contributing/conventions/const-vendor-pairing.rst index a8fa09dfee..0f6a101b72 100644 --- a/docs/source/contributing/conventions/const-vendor-pairing.rst +++ b/docs/source/contributing/conventions/const-vendor-pairing.rst @@ -7,8 +7,9 @@ Const-Vendor Pairing under** :mod:`pcapkit.vendor`, with no exception. A registry copied from an IANA, IETF or similar web-published table is never hand-maintained -- the crawler re-scrapes the source and regenerates the module, so a hand edit to the generated -file is silently discarded on the next run. :issue:`2441`, raised in cross-review -and knowingly deferred rather than fixed. +file is silently discarded on the next run. Raised in cross-review of :pr:`2441`, +which deferred the TLS crawlers; ruled on :issue:`2495`, where the owner allowed no +exception and pointed a hand-maintained registry at the ``DATA`` attribute below. The ``DATA`` Attribute, for a Registry With No Web Source ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -44,8 +45,8 @@ other generated module does. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :class:`~pcapkit.const.tls.content_type.ContentType` and -:class:`~pcapkit.const.tls.handshake_type.HandshakeType` (:pr:`2441`) now have -matching crawlers -- :mod:`pcapkit.vendor.tls.content_type` and +:class:`~pcapkit.const.tls.handshake_type.HandshakeType`, which came with the TLS +record layer, have matching crawlers -- :mod:`pcapkit.vendor.tls.content_type` and :mod:`pcapkit.vendor.tls.handshake_type`, exported as ``TLS_ContentType`` and ``TLS_HandshakeType`` -- closing the gap :issue:`2495` tracked. Both take the normal ``LINK``-and-scrape shape against IANA's ``tls-parameters-5.csv`` and @@ -56,9 +57,9 @@ ARPHRD, TLS's source is a live, web-published table, so neither reads a Running either crawler is a true no-op against the committed ``const`` modules: regenerating both reproduces the tree byte-for-byte, the same guarantee ARPHRD and the ``sslv2`` restack carry. Getting there meant -regenerating the ``const`` modules committed alongside :pr:`2441`, which -predated the crawler and had drifted from it in three ways, caught in -cross-review of :pr:`2503`: +regenerating the ``const`` modules committed with the TLS record layer, which +predated the crawler and had drifted from it in the ways cross-review of +:pr:`2503` (:issue:`2495`) caught: * the committed ``ContentType`` resolved values 32-63 to ``Unassigned`` via a flat ``_missing_`` fallback, while IANA's registry -- and the crawler -- diff --git a/docs/source/contributing/conventions/documentation.rst b/docs/source/contributing/conventions/documentation.rst index 9ad73d93df..83908d3f70 100644 --- a/docs/source/contributing/conventions/documentation.rst +++ b/docs/source/contributing/conventions/documentation.rst @@ -343,6 +343,16 @@ Format and Mechanics :file:`tests/project/test_docs_issue_pr_roles_1103_unit.py` fails on a bare number or a hand-written link in rendered text, and :file:`tests/project/test_issue_refs_in_comments.py` on a role in a plain comment. + The first also fails on a role of the wrong kind, reading each number's kind from + :file:`tests/project/issue_pr_roles_fixtures/kinds.json`, which + ``python util/issue_pr_kinds.py`` regenerates from the tracker, and which the + scheduled Issue and PR Kinds workflow proposes a refresh of + (:doc:`/contributing/workflows`). + It skips a number above the file's highest until a refresh, and fails on a lower + one the file lacks, such as a Discussion's. Its ``ALLOWED`` set exempts the + hand-written links and the bare ``#1962`` on :doc:`/contributing/workflows`, which + other tests pin in that form, so converting one means changing its pin in the same + commit. * **Prose names the real defining module, not a re-export.** One strand of the consistency sweep on :doc:`the roadmap `. The sentinels are the worked example: every one is defined in :mod:`pcapkit.corekit.sentinels`, and diff --git a/docs/source/contributing/conventions/mint-criterion.rst b/docs/source/contributing/conventions/mint-criterion.rst index 5efdb91c13..93b6229a5c 100644 --- a/docs/source/contributing/conventions/mint-criterion.rst +++ b/docs/source/contributing/conventions/mint-criterion.rst @@ -98,8 +98,8 @@ and :file:`pcapkit/vendor/ipx/socket.py`'s ``UNASSIGNED_RANGE_NAMES``. MISSING -->|"out of range, or no block covers it"| RAISE["ValueError"] MISSING -->|"CGAType only"| MINT["extend_enum
permanent member, registry grows"] MISSING -->|"flag registries"| FLAG["super().\_missing\_
pseudo-member cached in the value table only"] - MISSING -->|"block label names a party"| SUFFIXED["_unregistered_member
label + _0x<code>"] - MISSING -->|"block label names a procedure"| BARE["_unregistered_member
bare block label"] + MISSING -->|"block label names a party"| SUFFIXED["\_unregistered\_member
label + \_0x<code>"] + MISSING -->|"block label names a procedure"| BARE["\_unregistered\_member
bare block label"] The Criterion ~~~~~~~~~~~~~ diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index 043a3a5f3f..2f600fe14e 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -444,9 +444,9 @@ The PEP Discussion, Kept Current ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :file:`docs/source/contributing/pep.rst` is the source of truth for what each wave of -the roadmap covers and where it stands. The owner's ruling on :pr:`2483`: upon each -release, amend that page inline, and also post the update -- or edit it inline, either -is fine -- to the roadmap's own GitHub Discussion, +the roadmap covers and where it stands. Ruled in review of :pr:`2483` (:issue:`2449`): +upon each release, amend that page inline, and also post the update -- or edit it +inline, either is fine -- to the roadmap's own GitHub Discussion, `PyPCAPKit Enhancement Proposals `__. The page stays authoritative; the Discussion is kept in step with it, not the other way round, so a contributor who only reads the Discussion is not working from a stale diff --git a/docs/source/contributing/conventions/protocol-family-grouping.rst b/docs/source/contributing/conventions/protocol-family-grouping.rst index 765db71b83..c51c5d0a00 100644 --- a/docs/source/contributing/conventions/protocol-family-grouping.rst +++ b/docs/source/contributing/conventions/protocol-family-grouping.rst @@ -12,7 +12,8 @@ The ``id()`` Criterion ~~~~~~~~~~~~~~~~~~~~~~ **A family base lists its direct concrete subclasses.** An abstract intermediate is -left out, and so is everything beneath it. :pr:`2441`. +left out, and so is everything beneath it. Ruled in review of :pr:`2441`; no issue +holds the ruling. The member list takes either of these forms: @@ -46,7 +47,7 @@ HTTP/2 share :class:`~pcapkit.protocols.application.http.HTTP`, IPv4 and IPv6 sh :class:`~pcapkit.protocols.internet.ip.IP`, AH and ESP share :class:`~pcapkit.protocols.internet.ipsec.IPsec`, and SSLv2 and SSLv3 share :class:`~pcapkit.protocols.application.ssl.SSL` (:ref:`below `). -:pr:`2441`. +Ruled in review of :pr:`2441`; no issue holds the ruling. .. _protocol-family-ssl: @@ -250,20 +251,21 @@ parser's ``error``, a frame the segment ends inside stays Canonical Names ~~~~~~~~~~~~~~~ -**A versioned protocol's display name includes its version.** This is the stated -preference on :pr:`2444`, not a settled rule. +**A versioned protocol's display name includes its version.** This is the preference +stated in review of :pr:`2444`, not a settled rule; no issue holds it. :class:`~pcapkit.protocols.link.l2tpv2.L2TPv2`'s ``name`` is ``Layer 2 Tunnelling Protocol version 2``. The HTTP/1.1 and HTTP/2 classes define no ``name`` of their own, so they report ``Hypertext Transfer Protocol``, which carries no version. -**ICMPv4 is the case where the RFC's own name carries no version.** The ruling on -:pr:`2446`: ``ICMPv4`` keeps its versioned class and module name, but its ``id()`` -drops the version for the RFC canonical name (:rfc:`792` names it plain ``ICMP``), -and its ``name`` carries no "version 4". ``ICMPv6`` keeps its own version, since -:rfc:`4443` names it with one. +**ICMPv4 is the case where the RFC's own name carries no version.** Ruled in review +of :pr:`2446` (:issue:`2436`): ``ICMPv4`` keeps its versioned class and module name, +but its ``id()`` drops the version for the RFC canonical name (:rfc:`792` names it +plain ``ICMP``), and its ``name`` carries no "version 4". ``ICMPv6`` keeps its own +version, since :rfc:`4443` names it with one. -**IGMPv1 follows the same shape, by the same reasoning** (:issue:`2439`, :pr:`2506`). +**IGMPv1 follows the same shape, by the same reasoning**, ruled in review of +:pr:`2506`; no issue holds the ruling. :rfc:`1112` names IGMPv1's protocol plain ``IGMP``, so :meth:`IGMPv1.id() ` is ``('IGMP',)`` and its ``alias`` is ``'IGMP'``, while @@ -292,8 +294,9 @@ Selector Matching ~~~~~~~~~~~~~~~~~ **A family name selects every version, and each version name selects its own.** -:pr:`2443`. On an IPv4 frame, ``'IP' in`` is ``True`` and ``'IPv6' in`` is ``False``. -On an IPv6 frame, ``'IP' in`` is ``True`` and ``'IPv4' in`` is ``False``. +Ruled in review of :pr:`2443` (:issue:`2438`). On an IPv4 frame, ``'IP' in`` is +``True`` and ``'IPv6' in`` is ``False``. On an IPv6 frame, ``'IP' in`` is ``True`` +and ``'IPv4' in`` is ``False``. HTTP follows it too (:issue:`2524`). On an HTTP/1.1 frame, ``'HTTP' in`` and ``'HTTP/1' in`` are ``True`` and ``'HTTP/2' in`` is ``False``, since the layer is @@ -335,7 +338,7 @@ No Invented Suffixes :class:`~pcapkit.protocols.application.http.HTTP`, :class:`~pcapkit.protocols.link.vlan.VLAN` and :class:`~pcapkit.protocols.link.l2tp.L2TP` are the precedent. No ``_Family`` suffix is -added (:pr:`2446`). +added, as ruled in review of :pr:`2446` (:issue:`2436`). **A protocol class takes its module's name**, whatever its canonical name and ``id()``. The family base takes the family's name and a member its module's versioned diff --git a/docs/source/contributing/conventions/protocol-layer-placement.rst b/docs/source/contributing/conventions/protocol-layer-placement.rst index cfa2f78a81..635d6dfaf9 100644 --- a/docs/source/contributing/conventions/protocol-layer-placement.rst +++ b/docs/source/contributing/conventions/protocol-layer-placement.rst @@ -151,8 +151,8 @@ So :class:`~pcapkit.protocols.application.ftp_data.FTP_DATA`, declared Ordering layer-first is correct in both cases, which is the point: a contributor does not have to work out which case they are in. -Things That Will Look Like Mistakes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Apparent Mistakes +~~~~~~~~~~~~~~~~~ Each is deliberate. Do not "fix" any of them. @@ -166,8 +166,8 @@ parser disagree. layer it is built from.** :class:`~pcapkit.protocols.misc.checksum.ip_pseudo.IP_Pseudo` is built from an IPv4 or IPv6 layer but is no layer on the wire, and its ``__layer__`` is :obj:`None`. Pseudo-header protocols share :mod:`pcapkit.protocols.misc.checksum`, -ruled in review of the IP pseudo-header for :issue:`2471`, whose thread does not hold -it. +ruled in review of the IP pseudo-header, :pr:`2479` (:issue:`2471`); the issue's +thread does not hold it. **The dispatch tier is decoupled from the subpackage.** A protocol's subpackage says nothing about which registry reaches it, and no dispatch key depends on it: @@ -213,9 +213,9 @@ those also exists on :class:`~pcapkit.protocols.protocol.ProtocolBase`, so each resolves on :class:`~pcapkit.protocols.application.ospf.OSPF`. The strict difference :class:`~pcapkit.protocols.link.link.Link` minus :class:`~pcapkit.protocols.application.application.Application` minus -:class:`~pcapkit.protocols.protocol.ProtocolBase` is ``__proto_key__`` alone -(:issue:`2343`), which :class:`~pcapkit.protocols.link.link.Link` declares and neither of -the others does, so it does **not** resolve on +:class:`~pcapkit.protocols.protocol.ProtocolBase` is ``__proto_key__`` alone, which +:class:`~pcapkit.protocols.link.link.Link` declares, as asked for on :pr:`2392` (no +issue holds the ruling), and neither of the others does, so it does **not** resolve on :class:`~pcapkit.protocols.application.ospf.OSPF`. Nothing turns on that: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` reads the pairing out of the registry owner's own namespace rather than off the class, and reads its diff --git a/docs/source/contributing/conventions/reassembly-integration.rst b/docs/source/contributing/conventions/reassembly-integration.rst index 1d3f9e2a43..532087079e 100644 --- a/docs/source/contributing/conventions/reassembly-integration.rst +++ b/docs/source/contributing/conventions/reassembly-integration.rst @@ -5,8 +5,8 @@ Reassembly Integration Where a new reassembly engine under :mod:`pcapkit.foundation.reassembly` plugs into the rest of the package. Points 1 to 3 are the owner's ruling on the DTLS reassembly -work of :issue:`2440`, whose pull-request review applies it; the issue itself does not -hold it. Point 4 rests on :pr:`2469`, which settled the DTLS case alone: in review of +work, applied in review of :pr:`2451` (:issue:`2440`); the issue itself does not hold +it. Point 4 rests on :pr:`2469`, which settled the DTLS case alone: in review of how DTLS sizes a handshake message's buffer, the owner had DTLS reassembly follow its RFCs. No issue holds that ruling. diff --git a/docs/source/contributing/conventions/registry-protocol.rst b/docs/source/contributing/conventions/registry-protocol.rst index c705e808b7..804dc503dc 100644 --- a/docs/source/contributing/conventions/registry-protocol.rst +++ b/docs/source/contributing/conventions/registry-protocol.rst @@ -162,7 +162,7 @@ Which branch a key takes, and what each miss ends in: flowchart TD GET["get(key, default)"] -->|"isinstance(key, str)"| NAME["\_member\_map\_ lookup"] - GET -->|"otherwise"| VALUE["_validate_value(key)
then cls(key)"] + GET -->|"otherwise"| VALUE["\_validate\_value(key)
then cls(key)"] NAME -->|"miss, no usable default"| KEYERR["EnumKeyError
quiet, derives KeyError"] NAME -->|"hit,
or miss, key is a registered value,
or miss, default is a registered value"| OK["canonical member"] VALUE -->|"resolves, or \_missing\_ answers,
or ValueError, default is a registered value"| OK diff --git a/docs/source/contributing/conventions/sentinel-convention.rst b/docs/source/contributing/conventions/sentinel-convention.rst index c12512d94b..7530c3fd3b 100644 --- a/docs/source/contributing/conventions/sentinel-convention.rst +++ b/docs/source/contributing/conventions/sentinel-convention.rst @@ -42,8 +42,9 @@ derives from it mechanically -- title-case each underscore-separated word and ap GitHub issue :issue:`911`'s housing ruling -- one module for all of them -- is why the table names a single defining module, and it is the only import path. The modules that *use* a sentinel import it from there but do not re-export it: :issue:`1744` dropped the -per-module re-exports, under the ruling on :issue:`1715` that a moved name keeps no -re-export at its old path. Import the object, and the type for an annotation, from +per-module re-exports, under the ruling that a moved name keeps no re-export at its old +path. It was ruled in review of :pr:`1715` (:issue:`1711`), overturning the re-export +that issue's own plan kept. Import the object, and the type for an annotation, from :mod:`pcapkit.corekit.sentinels`. Where a sentinel name already exists and already follows SCREAMING_SNAKE, keep it; diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index e90a840f47..8d852a8be7 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -992,7 +992,7 @@ constructor's keyword set once, when :func:`~pcapkit.protocols.schema.schema.schema_final` is applied at class-definition time, rather than inferring it afresh at every call -- :attr:`~pcapkit.protocols.protocol.Protocol.__own_make_keywords__` -(:issue:`2358`) settles the same kind of question explicitly rather than +(:issue:`2286`) settles the same kind of question explicitly rather than inferring it, though for a different question: which keywords a ``make()`` override owns, not what the names passed to it resolve against. diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index b118a07488..95df57259f 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -315,18 +315,18 @@ One-time setup: 1. Register a GitHub App with the **Contents** and **Workflows** repository permissions at read and write, and install it on this repository only. Add **Pull requests** at read and write too, and accept it on the installation: - the Python Versions workflow's ``pr`` job opens its pull request with a token - of this App, with contents and pull requests write only (:issue:`1660`, - :doc:`workflows`). + the Python Versions and Issue and PR Kinds workflows' ``pr`` jobs open their + pull requests with a token of this App, with contents and pull requests write + only (:issue:`1660`, :issue:`2561`, :doc:`workflows`). 2. Add repository variables ``RELEASE_APP_CLIENT_ID``, the App's Client ID, and ``RELEASE_APP_BOT_ID``, its bot's user id (``gh api "/users/[bot]" --jq .id``, as the action's README reads it). 3. Store the App's private key as ``RELEASE_APP_PRIVATE_KEY``, a secret of the ``github-release`` environment rather than of the repository: "If the environment requires approval, a job cannot access environment secrets - until one of the required reviewers approves it". ``github`` and Python - Versions' ``pr`` are the only jobs that name the environment or read the - secret. + until one of the required reviewers approves it". ``github`` and the ``pr`` + jobs of Python Versions and Issue and PR Kinds are the only jobs that name the + environment or read the secret. Unlike ``GITHUB_TOKEN``'s, the App's tag starts a tag-push run. It would rerun the release test gate, ask for a second approval and race the first run's @@ -601,7 +601,7 @@ Anaconda files that commit's ``conda`` legs cannot build: 1.5.0b14's ``osx-64`` legs were broken at its tag (:issue:`2140`). While ``main``'s version is still the tagged one, dispatching Conda Update with ``publish`` on ``main`` builds ``main`` and uploads every platform under a bumped build -(:issue:`2060`, :pr:`2150`), and the warning a later commit's run posts for an +(:issue:`2060`), and the warning a later commit's run posts for an unfinished release names that remedy wherever Anaconda files are missing. **A re-run does not need to be verified by hand against the PyPI and Anaconda diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 0c2f0d408c..6bfea8cb50 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -135,6 +135,13 @@ At a Glance - Mon 05:41 - -- - ``workflow_dispatch`` + * - Issue and PR Kinds + - :file:`cron-issue-pr-kinds.yml` + - -- + - -- + - Mon 06:17 + - -- + - ``workflow_dispatch`` * - Engine Benchmark - :file:`benchmark.yml` - -- @@ -187,6 +194,7 @@ than starting a new one. LT["Lint
lint.yml"] VU["Vendor Update
cron-vendor.yml"] PV["Python Versions
cron-python-versions.yml"] + IK["Issue and PR Kinds
cron-issue-pr-kinds.yml"] CT["CI Timing
ci-timing.yml"] BM["Engine Benchmark
benchmark.yml"] PS["Project Status
project-status.yml"] @@ -224,6 +232,9 @@ than starting a new one. MON -->|05:41| PV DISPATCH --> PV + MON -->|06:17| IK + DISPATCH --> IK + MON -->|07:23| CT DISPATCH --> CT @@ -1057,7 +1068,7 @@ Each run starts its workers afresh. Measured on PyPy 8.0.1 at ``-n 4``, one run over the whole ``ALL`` tier took each worker to 5.4-5.9 GB and the workers together past 20 GB, beyond the runner's 16 GB; split, the largest run peaked at 2.6 GB a worker and 8.5 GB in all, and the tier took 383 s rather than 493 s. With -:issue:`2543`'s checksum tests, ``tests/protocols`` run whole outgrew the runner as +:issue:`2470`'s checksum tests, ``tests/protocols`` run whole outgrew the runner as well, and on CI the tests at its tail stalled past ``tests._support.time_limit``'s 30-second deadline, which counts wall-clock time, so it is split (:issue:`2600`). Measured on PyPy 7.3.19 at ``-n 4``, its one run peaked at 17.7 GiB and took 267 s, @@ -1632,7 +1643,8 @@ is to 3.11 for ``tomllib``, is left out, and the tests' differ. The scripts a person runs as well, or alone, sit beside the CI-only ones in -:file:`util/` rather than in a directory of their own (:pr:`2050`). +:file:`util/` rather than in a directory of their own, as ruled in review of +:pr:`2050`; no issue holds the ruling. Every :file:`util/` script not listed here is CI-only. @@ -1675,6 +1687,14 @@ script not listed here is CI-only. - ``python util/pyshark_encap_map.py`` regenerates the tables in :file:`pcapkit/toolkit/pyshark.py`; ``--check`` exits non-zero if they have drifted. + * - :file:`util/issue_pr_kinds.py` + - ``Regenerate the kinds`` in ``cron-issue-pr-kinds.yml``'s ``detect``, as + ``issue_pr_kinds.py --github-output``, and ``Push the kinds and open or + update their pull request`` in its ``pr``, as ``--publish``. + - ``python util/issue_pr_kinds.py``, with :program:`gh` signed in, regenerates + :file:`tests/project/issue_pr_roles_fixtures/kinds.json`, the issue and pull + request kinds :file:`tests/project/test_docs_issue_pr_roles_1103_unit.py` + checks each role against. .. _CI Reports: @@ -1902,7 +1922,7 @@ it. Dependabot does not read this pin, so a bump changes both. The workflows use actionlint runs the image's shellcheck over each ``run:`` block. Unlike Lint's other steps, this one blocks: a finding fails the job, as the owner ruled on :issue:`1967` -once :pr:`2023`, :pr:`2037` and :pr:`2038` had fixed every finding it reported. It runs last and +once every finding it reported had been fixed. It runs last and on ``!cancelled()``, so a finding skips none of the advisory steps and a failed Python setup does not skip it. ``Lint`` is a required check of ruleset 23497679 (:issue:`1967`), so a red Lint @@ -2009,18 +2029,40 @@ experimental entries are hand-maintained: no source it reads rates them, so every run keeps their lines as written, and a hand edit to one changes no proposal. +Issue and PR Kinds (:issue:`2561`) +----------------------------------- + +**Issue and PR Kinds** (:file:`cron-issue-pr-kinds.yml`) keeps +:file:`tests/project/issue_pr_roles_fixtures/kinds.json` current, the snapshot of +issue and pull request numbers :file:`tests/project/test_docs_issue_pr_roles_1103_unit.py` +checks each ``:issue:`` and ``:pr:`` role against (:doc:`conventions/documentation`). +Every Monday, or on dispatch, its ``detect`` job runs +``python3 util/issue_pr_kinds.py --github-output`` with a read-only token and no +secret. It proposes nothing when the file is unchanged, or when the open pull +request from ``bot/issue-pr-kinds`` already holds it, so an unmerged proposal is +not re-pushed every week. Otherwise ``pr`` enters ``github-release``, mints a +release App token with contents and pull requests write, as `Python Versions`_'s +``pr`` does, and runs the script with ``--publish``: it commits the file on top of +the commit ``detect`` read, force-pushes ``bot/issue-pr-kinds``, and opens that +branch's one pull request if none is open. It never pushes ``main``, and a hand +edit to the branch is replaced by the next proposal, so edit ``main`` instead. +Where moving the branch would change a workflow file ``main`` changed since, which +that token cannot push, it deletes the branch and pushes it afresh, which closes +the old pull request and opens a new one. + Deployment ``environment:`` ---------------------------- These jobs declare a deployment ``environment:`` and can therefore pause for an -approval, in ``create-release.yml`` and ``cron-python-versions.yml``: +approval, in ``create-release.yml``, ``cron-python-versions.yml`` and +``cron-issue-pr-kinds.yml``: * ``github`` -- ``environment: github-release`` * ``tag`` -- ``environment: conda-tag`` * ``pypi`` -- ``environment: pypi`` * ``conda`` -- ``environment: anaconda`` * ``pr`` -- ``environment: github-release``, for the release App's key (see - `Python Versions`_) + `Python Versions`_ and `Issue and PR Kinds (#2561)`_) ``ANACONDA_TOKEN`` is a secret of the ``anaconda`` environment, and the ``PYPCAPKIT`` token and ``PYPCAPKIT_ACTIONS_DEPLOY_KEY`` are secrets of diff --git a/docs/source/ext.rst b/docs/source/ext.rst index de76db22e3..e012be862c 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -4,9 +4,9 @@ Customisation & Extensions :mod:`pcapkit` is built for customisation: new protocols, layers, fields and even new file formats can all be added from outside the library. ------------------------- -What's in for Protocols? ------------------------- +--------------------- +Customising Protocols +--------------------- :class:`~pcapkit.protocols.protocol.Protocol` is the most essential concept and class in :mod:`pcapkit`: the user-facing abstract base class for a diff --git a/docs/source/pcapkit/const/mh.rst b/docs/source/pcapkit/const/mh.rst index 3a75b12717..2a3e0bcb93 100644 --- a/docs/source/pcapkit/const/mh.rst +++ b/docs/source/pcapkit/const/mh.rst @@ -472,7 +472,7 @@ which is automatically generated from :class:`pcapkit.vendor.mh.option.Option`. :undoc-members: :show-inheritance: -Mobility Header Types - for the MH Type field in the Mobility Header +Mobility Header Types - for the MH Type Field in the Mobility Header ==================================================================== .. module:: pcapkit.const.mh.packet diff --git a/docs/source/pcapkit/const/tcp.rst b/docs/source/pcapkit/const/tcp.rst index 65074282a2..c605b8eae1 100644 --- a/docs/source/pcapkit/const/tcp.rst +++ b/docs/source/pcapkit/const/tcp.rst @@ -32,7 +32,7 @@ which is automatically generated from :class:`pcapkit.vendor.tcp.checksum.Checks :undoc-members: :show-inheritance: -Multipath TCP options +Multipath TCP Options ===================== .. module:: pcapkit.const.tcp.mp_tcp_option diff --git a/docs/source/pcapkit/const/vlan.rst b/docs/source/pcapkit/const/vlan.rst index 654424f192..bbaf693e02 100644 --- a/docs/source/pcapkit/const/vlan.rst +++ b/docs/source/pcapkit/const/vlan.rst @@ -13,7 +13,7 @@ enumerations include: * - :class:`VLAN_PriorityLevel ` - Priority Levels [*]_ -Priority levels defined in IEEE 802.1p +Priority Levels Defined in IEEE 802.1p ====================================== .. module:: pcapkit.const.vlan.priority_level diff --git a/docs/source/pcapkit/protocols/application/sslv2.rst b/docs/source/pcapkit/protocols/application/sslv2.rst index 34e76eaa91..d34e667e65 100644 --- a/docs/source/pcapkit/protocols/application/sslv2.rst +++ b/docs/source/pcapkit/protocols/application/sslv2.rst @@ -1,4 +1,4 @@ -SSLv2 - Secure Sockets Layer version 2 +SSLv2 - Secure Sockets Layer Version 2 ======================================= .. module:: pcapkit.protocols.application.sslv2 diff --git a/docs/source/pcapkit/protocols/application/sslv3.rst b/docs/source/pcapkit/protocols/application/sslv3.rst index 2bdcff3501..e90d63373f 100644 --- a/docs/source/pcapkit/protocols/application/sslv3.rst +++ b/docs/source/pcapkit/protocols/application/sslv3.rst @@ -1,4 +1,4 @@ -SSLv3 - Secure Sockets Layer version 3 +SSLv3 - Secure Sockets Layer Version 3 ====================================== .. module:: pcapkit.protocols.application.sslv3 diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index 29800a96c7..b4de7a8f76 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -127,7 +127,7 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: click A "protocol.html#pcapkit.protocols.protocol.ProtocolMeta" click B "protocol.html#pcapkit.protocols.protocol.ProtocolBase" click C "protocol.html#pcapkit.protocols.protocol.Protocol" - click D "../../ext.html#what-s-in-for-protocols" + click D "../../ext.html#customising-protocols" click Link "link/link.html#pcapkit.protocols.link.link.Link" click Ethernet "link/ethernet.html#pcapkit.protocols.link.ethernet.Ethernet" diff --git a/docs/source/pcapkit/protocols/internet/igmpv1.rst b/docs/source/pcapkit/protocols/internet/igmpv1.rst index e5a1324826..826945df2f 100644 --- a/docs/source/pcapkit/protocols/internet/igmpv1.rst +++ b/docs/source/pcapkit/protocols/internet/igmpv1.rst @@ -1,4 +1,4 @@ -IGMPv1 - Internet Group Management Protocol version 1 +IGMPv1 - Internet Group Management Protocol Version 1 ============================================================= .. module:: pcapkit.protocols.internet.igmpv1 diff --git a/docs/source/pcapkit/protocols/internet/igmpv2.rst b/docs/source/pcapkit/protocols/internet/igmpv2.rst index 16e0e92200..585040eb7d 100644 --- a/docs/source/pcapkit/protocols/internet/igmpv2.rst +++ b/docs/source/pcapkit/protocols/internet/igmpv2.rst @@ -1,4 +1,4 @@ -IGMPv2 - Internet Group Management Protocol version 2 +IGMPv2 - Internet Group Management Protocol Version 2 ============================================================= .. module:: pcapkit.protocols.internet.igmpv2 diff --git a/docs/source/pcapkit/protocols/internet/igmpv3.rst b/docs/source/pcapkit/protocols/internet/igmpv3.rst index 23c5c6bf4e..450272889e 100644 --- a/docs/source/pcapkit/protocols/internet/igmpv3.rst +++ b/docs/source/pcapkit/protocols/internet/igmpv3.rst @@ -1,4 +1,4 @@ -IGMPv3 - Internet Group Management Protocol version 3 +IGMPv3 - Internet Group Management Protocol Version 3 ============================================================== .. module:: pcapkit.protocols.internet.igmpv3 diff --git a/docs/source/pcapkit/protocols/internet/ipv4.rst b/docs/source/pcapkit/protocols/internet/ipv4.rst index 76d1dc2f7d..ac58a5f32d 100644 --- a/docs/source/pcapkit/protocols/internet/ipv4.rst +++ b/docs/source/pcapkit/protocols/internet/ipv4.rst @@ -1,4 +1,4 @@ -IPv4 - Internet Protocol version 4 +IPv4 - Internet Protocol Version 4 ================================== .. module:: pcapkit.protocols.internet.ipv4 diff --git a/docs/source/pcapkit/protocols/internet/ipv6.rst b/docs/source/pcapkit/protocols/internet/ipv6.rst index b48f2e44e0..546e06f6ae 100644 --- a/docs/source/pcapkit/protocols/internet/ipv6.rst +++ b/docs/source/pcapkit/protocols/internet/ipv6.rst @@ -1,4 +1,4 @@ -IPv6 - Internet Protocol version 6 +IPv6 - Internet Protocol Version 6 ================================== .. module:: pcapkit.protocols.internet.ipv6 diff --git a/docs/source/pcapkit/protocols/link/l2tpv2.rst b/docs/source/pcapkit/protocols/link/l2tpv2.rst index 8e000c682f..bdb03b4212 100644 --- a/docs/source/pcapkit/protocols/link/l2tpv2.rst +++ b/docs/source/pcapkit/protocols/link/l2tpv2.rst @@ -1,4 +1,4 @@ -L2TPv2 - Layer Two Tunnelling Protocol version 2 +L2TPv2 - Layer Two Tunnelling Protocol Version 2 ================================================ .. module:: pcapkit.protocols.link.l2tpv2 diff --git a/docs/source/pcapkit/utilities/logging.rst b/docs/source/pcapkit/utilities/logging.rst index 59d6d3f783..ceaa145663 100644 --- a/docs/source/pcapkit/utilities/logging.rst +++ b/docs/source/pcapkit/utilities/logging.rst @@ -42,8 +42,8 @@ Logger names are the module paths, e.g. ``pcapkit.foundation.extraction``, ``pca .. autodata:: pcapkit.utilities.logging.ROOT_LOGGER_NAME -What ``DEBUG`` Will Tell You -============================ +``DEBUG`` Logging +================= At :data:`logging.DEBUG` the library reports what it did with a file: which input was opened, which engine was requested and which was actually used diff --git a/docs/source/pcapkit/utilities/warnings.rst b/docs/source/pcapkit/utilities/warnings.rst index 9ec494d88b..425dba8276 100644 --- a/docs/source/pcapkit/utilities/warnings.rst +++ b/docs/source/pcapkit/utilities/warnings.rst @@ -11,8 +11,8 @@ User Defined Warnings :mod:`pcapkit.utilities.logging`; read :data:`pcapkit.utilities.logging.DEVMODE` instead. -How a Warning Is Reported -------------------------- +Reporting a Warning +------------------- Every warning :mod:`pcapkit` reports goes through :func:`~pcapkit.utilities.warnings.warn`, which reports it **exactly once on each diff --git a/docs/source/pcapkit/vendor/mh.rst b/docs/source/pcapkit/vendor/mh.rst index c30da6506b..102dcaba30 100644 --- a/docs/source/pcapkit/vendor/mh.rst +++ b/docs/source/pcapkit/vendor/mh.rst @@ -443,7 +443,7 @@ which is automatically generating :class:`pcapkit.const.mh.option.Option`. :members: FLAG, LINK :show-inheritance: -Mobility Header Types - for the MH Type field in the Mobility Header +Mobility Header Types - for the MH Type Field in the Mobility Header ==================================================================== .. module:: pcapkit.vendor.mh.packet diff --git a/docs/source/pcapkit/vendor/tcp.rst b/docs/source/pcapkit/vendor/tcp.rst index 59cf67547b..9f71d95a29 100644 --- a/docs/source/pcapkit/vendor/tcp.rst +++ b/docs/source/pcapkit/vendor/tcp.rst @@ -31,7 +31,7 @@ which is automatically generating :class:`pcapkit.const.tcp.checksum.Checksum`. :members: FLAG :show-inheritance: -Multipath TCP options +Multipath TCP Options ===================== .. module:: pcapkit.vendor.tcp.mp_tcp_option diff --git a/docs/source/pcapkit/vendor/vlan.rst b/docs/source/pcapkit/vendor/vlan.rst index 4503f19fa0..80d3a3b917 100644 --- a/docs/source/pcapkit/vendor/vlan.rst +++ b/docs/source/pcapkit/vendor/vlan.rst @@ -13,7 +13,7 @@ vendor crawlers include: * - :const:`VLAN_PriorityLevel ` - Priority Levels [*]_ -Priority levels defined in IEEE 802.1p +Priority Levels Defined in IEEE 802.1p ====================================== .. module:: pcapkit.vendor.vlan.priority_level diff --git a/pcapkit/protocols/misc/checksum/ip_pseudo.py b/pcapkit/protocols/misc/checksum/ip_pseudo.py index cb26fe18cb..818ad20dfb 100644 --- a/pcapkit/protocols/misc/checksum/ip_pseudo.py +++ b/pcapkit/protocols/misc/checksum/ip_pseudo.py @@ -432,7 +432,7 @@ def _walk(ip: 'IPv6') -> 'tuple[Enum_TransType, int]': Returns: The code the walk stopped on, and the octets of the headers ahead of it. The code is an extension header's if ``ip`` did not parse that - header: a malformed one (:issue:`2480`), one past the header limit, + header: a malformed one (:issue:`2477`), one past the header limit, or one not captured. """ diff --git a/pcapkit/vendor/default.py b/pcapkit/vendor/default.py index 89e78a906d..542185e775 100644 --- a/pcapkit/vendor/default.py +++ b/pcapkit/vendor/default.py @@ -119,8 +119,7 @@ def render_proto_dest(descriptors: 'tuple[ModuleDescriptor[Any], ...]') -> 'str' #: Default constant template of enumeration registry from IANA CSV. #: #: ``DEST`` is the crawler's :attr:`Vendor.PROTO_DEST` and defaults to empty, so -#: a caller that renders an enumeration keying no ``__proto__`` -- every one -#: outside the handful :issue:`2392` names -- passes six arguments as before. +#: a caller that renders an enumeration keying no ``__proto__`` leaves it out. #: #: NOTE: the type comment says ``Callable[..., str]`` rather than spelling the #: parameters out, because mypy cannot infer a lambda's type against an explicit @@ -325,7 +324,8 @@ class Vendor(metaclass=VendorMeta): #: ``__proto_dest__`` -- the declaration #: :func:`~pcapkit.foundation.registry.protocols.register_protocol_code` #: reads to resolve a bare member of that enumeration into its - #: destination(s), per :issue:`2392`. + #: destination(s). Asked for in review of :pr:`2392`; no issue holds the + #: ruling. #: #: Only the registries that key a ``__proto__`` declare one, and the #: template renders nothing for a crawler that leaves this empty, so every diff --git a/tests/project/issue_pr_roles_fixtures/kinds.json b/tests/project/issue_pr_roles_fixtures/kinds.json new file mode 100644 index 0000000000..eda6777c3d --- /dev/null +++ b/tests/project/issue_pr_roles_fixtures/kinds.json @@ -0,0 +1,180 @@ +{ + "issue": [ + 2, 29, 31, 32, 33, 34, 35, 37, 38, 40, 46, 55, 58, 65, 67, + 73, 80, 82, 100, 101, 102, 107, 113, 114, 116, 139, 140, 142, 155, 156, + 166, 180, 204, 218, 221, 240, 269, 270, 272, 275, 289, 341, 342, 343, 344, + 345, 346, 347, 348, 349, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, + 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 382, 399, 406, 414, 415, + 418, 421, 422, 423, 425, 429, 431, 433, 438, 439, 441, 442, 443, 444, 445, + 446, 447, 448, 452, 454, 455, 458, 459, 463, 465, 467, 469, 472, 473, 474, + 476, 477, 483, 487, 490, 491, 492, 493, 494, 495, 496, 505, 506, 507, 508, + 512, 513, 514, 515, 516, 517, 518, 519, 522, 525, 530, 533, 534, 537, 540, + 541, 545, 546, 548, 552, 553, 554, 555, 556, 564, 566, 567, 572, 573, 574, + 575, 576, 582, 583, 584, 587, 588, 591, 592, 594, 599, 601, 602, 603, 604, + 605, 606, 608, 610, 616, 617, 618, 620, 622, 623, 641, 642, 643, 644, 645, + 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 660, 661, 668, 672, 674, + 675, 678, 679, 682, 684, 685, 687, 688, 689, 690, 692, 700, 701, 702, 704, + 707, 708, 709, 710, 713, 715, 717, 718, 719, 720, 723, 729, 730, 732, 734, + 736, 738, 739, 743, 744, 745, 746, 749, 751, 753, 756, 757, 758, 759, 760, + 762, 765, 766, 767, 768, 770, 772, 775, 776, 778, 779, 780, 785, 787, 791, + 792, 794, 796, 798, 799, 800, 801, 804, 805, 806, 807, 808, 809, 810, 816, + 818, 819, 822, 824, 825, 828, 831, 832, 833, 839, 840, 841, 842, 843, 844, + 845, 851, 852, 857, 860, 862, 864, 866, 870, 872, 875, 877, 879, 880, 882, + 884, 886, 887, 888, 891, 892, 895, 896, 897, 900, 901, 902, 903, 908, 910, + 911, 917, 918, 920, 923, 925, 930, 931, 933, 934, 935, 937, 942, 944, 947, + 949, 950, 952, 956, 958, 960, 962, 964, 967, 969, 973, 974, 981, 985, 987, + 989, 995, 1013, 1016, 1019, 1021, 1022, 1024, 1026, 1029, 1038, 1041, 1042, 1045, 1050, + 1052, 1058, 1060, 1062, 1063, 1065, 1066, 1068, 1076, 1077, 1090, 1091, 1092, 1093, 1094, + 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1118, 1119, 1120, 1121, 1122, + 1123, 1124, 1125, 1126, 1127, 1133, 1134, 1136, 1139, 1140, 1141, 1144, 1148, 1150, 1151, + 1152, 1154, 1155, 1156, 1165, 1167, 1170, 1172, 1173, 1175, 1177, 1178, 1184, 1185, 1189, + 1190, 1193, 1195, 1196, 1199, 1200, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, + 1211, 1212, 1213, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1222, 1223, 1228, 1229, + 1230, 1231, 1232, 1233, 1234, 1235, 1236, 1237, 1238, 1239, 1240, 1241, 1242, 1243, 1244, + 1245, 1246, 1247, 1248, 1249, 1250, 1251, 1252, 1255, 1256, 1257, 1258, 1259, 1260, 1261, + 1262, 1263, 1264, 1265, 1266, 1267, 1268, 1269, 1270, 1271, 1272, 1273, 1274, 1275, 1276, + 1277, 1278, 1279, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1288, 1289, 1290, 1291, 1292, + 1293, 1294, 1295, 1296, 1297, 1298, 1299, 1300, 1302, 1303, 1306, 1308, 1312, 1313, 1314, + 1315, 1316, 1317, 1318, 1319, 1320, 1321, 1322, 1323, 1324, 1325, 1326, 1327, 1328, 1329, + 1330, 1332, 1333, 1337, 1340, 1343, 1349, 1350, 1351, 1354, 1355, 1356, 1357, 1358, 1359, + 1360, 1361, 1362, 1363, 1364, 1365, 1372, 1379, 1380, 1382, 1383, 1384, 1385, 1387, 1391, + 1392, 1393, 1394, 1401, 1402, 1403, 1404, 1405, 1406, 1408, 1413, 1414, 1417, 1418, 1422, + 1423, 1428, 1432, 1433, 1434, 1437, 1443, 1446, 1447, 1448, 1451, 1453, 1455, 1456, 1457, + 1458, 1459, 1461, 1464, 1468, 1470, 1471, 1473, 1474, 1478, 1479, 1482, 1484, 1485, 1487, + 1488, 1489, 1490, 1491, 1498, 1500, 1501, 1502, 1503, 1504, 1505, 1506, 1507, 1512, 1513, + 1514, 1515, 1516, 1517, 1518, 1519, 1520, 1522, 1524, 1534, 1537, 1538, 1539, 1541, 1545, + 1547, 1548, 1549, 1553, 1555, 1558, 1559, 1565, 1566, 1567, 1570, 1571, 1572, 1574, 1576, + 1577, 1580, 1581, 1583, 1584, 1585, 1586, 1590, 1591, 1592, 1593, 1596, 1597, 1599, 1602, + 1604, 1606, 1607, 1609, 1610, 1611, 1612, 1615, 1616, 1617, 1618, 1619, 1623, 1625, 1626, + 1627, 1632, 1633, 1634, 1635, 1636, 1637, 1638, 1639, 1640, 1643, 1646, 1647, 1648, 1649, + 1650, 1651, 1654, 1657, 1658, 1660, 1661, 1664, 1666, 1667, 1668, 1671, 1672, 1674, 1676, + 1680, 1681, 1685, 1687, 1689, 1691, 1692, 1693, 1694, 1695, 1696, 1699, 1704, 1705, 1706, + 1708, 1709, 1710, 1711, 1712, 1717, 1719, 1726, 1731, 1732, 1735, 1736, 1738, 1739, 1740, + 1744, 1747, 1748, 1753, 1756, 1758, 1769, 1770, 1771, 1772, 1773, 1774, 1775, 1792, 1793, + 1794, 1795, 1796, 1797, 1807, 1811, 1814, 1816, 1820, 1822, 1824, 1827, 1839, 1840, 1841, + 1842, 1843, 1844, 1845, 1846, 1847, 1848, 1849, 1850, 1851, 1852, 1853, 1854, 1855, 1856, + 1857, 1858, 1859, 1860, 1861, 1862, 1863, 1874, 1878, 1883, 1884, 1886, 1888, 1889, 1892, + 1894, 1897, 1898, 1899, 1902, 1903, 1906, 1916, 1917, 1918, 1919, 1920, 1921, 1922, 1923, + 1924, 1930, 1933, 1936, 1938, 1940, 1941, 1947, 1948, 1949, 1950, 1954, 1957, 1958, 1959, + 1960, 1961, 1962, 1963, 1964, 1967, 1968, 1969, 1975, 1977, 1980, 1984, 1985, 1986, 1997, + 1999, 2003, 2004, 2005, 2008, 2015, 2016, 2019, 2020, 2025, 2028, 2030, 2040, 2041, 2044, + 2047, 2048, 2051, 2060, 2061, 2062, 2073, 2074, 2077, 2078, 2082, 2083, 2084, 2085, 2087, + 2090, 2094, 2097, 2098, 2099, 2100, 2101, 2102, 2103, 2107, 2108, 2110, 2113, 2114, 2115, + 2120, 2123, 2125, 2126, 2129, 2130, 2132, 2134, 2140, 2144, 2145, 2160, 2163, 2164, 2165, + 2166, 2167, 2168, 2169, 2170, 2171, 2172, 2173, 2174, 2175, 2176, 2177, 2178, 2186, 2206, + 2218, 2222, 2223, 2224, 2225, 2226, 2227, 2228, 2229, 2230, 2231, 2234, 2235, 2236, 2237, + 2243, 2254, 2256, 2268, 2269, 2270, 2271, 2272, 2273, 2274, 2275, 2276, 2277, 2278, 2279, + 2280, 2281, 2282, 2283, 2284, 2285, 2286, 2287, 2288, 2289, 2290, 2291, 2292, 2293, 2294, + 2295, 2296, 2297, 2298, 2299, 2300, 2301, 2302, 2303, 2304, 2305, 2306, 2307, 2308, 2309, + 2310, 2311, 2312, 2313, 2314, 2315, 2316, 2317, 2318, 2319, 2320, 2321, 2322, 2323, 2324, + 2325, 2326, 2327, 2328, 2329, 2330, 2331, 2341, 2342, 2352, 2353, 2354, 2356, 2362, 2394, + 2406, 2409, 2410, 2411, 2416, 2417, 2423, 2425, 2426, 2429, 2433, 2436, 2437, 2438, 2439, + 2440, 2442, 2447, 2448, 2449, 2453, 2454, 2455, 2456, 2457, 2458, 2459, 2460, 2462, 2467, + 2468, 2470, 2471, 2472, 2473, 2474, 2475, 2476, 2477, 2481, 2484, 2485, 2487, 2489, 2490, + 2495, 2496, 2498, 2500, 2501, 2502, 2504, 2510, 2511, 2512, 2513, 2514, 2515, 2516, 2517, + 2524, 2525, 2526, 2529, 2530, 2531, 2532, 2533, 2534, 2535, 2536, 2545, 2546, 2547, 2548, + 2549, 2550, 2551, 2552, 2553, 2554, 2555, 2556, 2557, 2558, 2559, 2560, 2561, 2562, 2563, + 2564, 2565, 2566, 2567, 2571, 2573, 2574, 2575, 2576, 2578, 2580, 2581, 2582, 2588, 2589, + 2592, 2594, 2600, 2608, 2614, 2615, 2616, 2617, 2618 + ], + "pr": [ + 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 36, 39, + 41, 42, 43, 44, 45, 47, 48, 49, 50, 51, 52, 53, 54, 56, 57, + 59, 60, 61, 62, 63, 64, 66, 68, 69, 70, 71, 72, 74, 75, 76, + 77, 78, 79, 81, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, + 94, 95, 96, 97, 98, 99, 103, 104, 108, 109, 110, 111, 112, 115, 117, + 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, + 134, 135, 136, 137, 138, 141, 143, 144, 145, 146, 147, 148, 149, 150, 151, + 152, 153, 154, 157, 158, 159, 160, 161, 162, 163, 164, 165, 167, 168, 169, + 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 181, 182, 183, 184, 185, + 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, + 201, 202, 203, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, + 217, 219, 220, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, + 234, 235, 236, 237, 238, 239, 241, 242, 243, 244, 245, 246, 247, 248, 249, + 250, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, + 266, 267, 268, 271, 273, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, + 286, 287, 288, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, + 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, + 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, + 332, 333, 334, 335, 336, 337, 338, 339, 340, 350, 371, 372, 373, 374, 375, + 376, 377, 378, 379, 380, 381, 383, 384, 385, 386, 387, 388, 389, 390, 391, + 392, 393, 394, 395, 396, 397, 398, 400, 401, 402, 403, 404, 405, 407, 408, + 409, 410, 411, 412, 413, 416, 417, 419, 420, 424, 426, 427, 428, 430, 432, + 434, 435, 436, 437, 440, 449, 450, 451, 453, 456, 457, 460, 461, 462, 464, + 466, 468, 470, 471, 475, 478, 479, 480, 481, 482, 484, 485, 486, 488, 489, + 497, 498, 499, 500, 501, 502, 503, 504, 509, 510, 511, 520, 521, 523, 524, + 526, 527, 528, 529, 531, 532, 535, 536, 538, 539, 542, 543, 544, 547, 549, + 550, 551, 557, 558, 559, 560, 561, 562, 563, 565, 568, 569, 570, 571, 577, + 578, 579, 580, 581, 585, 586, 589, 590, 593, 595, 596, 597, 598, 600, 607, + 609, 611, 612, 613, 614, 615, 619, 621, 624, 625, 626, 627, 628, 629, 630, + 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 656, 657, 658, 659, 662, + 663, 664, 665, 666, 667, 669, 670, 671, 673, 676, 677, 680, 681, 683, 686, + 691, 693, 694, 695, 696, 697, 698, 699, 703, 705, 706, 711, 712, 714, 716, + 721, 722, 724, 725, 726, 727, 728, 731, 733, 735, 737, 740, 741, 742, 747, + 748, 750, 752, 754, 755, 761, 763, 764, 769, 771, 773, 774, 777, 781, 782, + 783, 784, 786, 788, 789, 790, 793, 795, 797, 802, 803, 811, 812, 813, 814, + 815, 817, 820, 821, 823, 826, 827, 829, 830, 834, 835, 836, 837, 838, 846, + 847, 848, 849, 850, 853, 854, 855, 856, 858, 859, 861, 863, 865, 867, 868, + 869, 871, 873, 874, 876, 878, 881, 883, 885, 889, 890, 893, 894, 898, 899, + 904, 905, 906, 907, 909, 912, 913, 914, 915, 916, 919, 921, 922, 924, 926, + 927, 928, 929, 932, 936, 938, 939, 940, 941, 943, 945, 946, 948, 951, 953, + 954, 955, 957, 959, 961, 963, 965, 966, 968, 970, 971, 972, 975, 976, 977, + 978, 979, 980, 982, 983, 984, 986, 988, 990, 991, 992, 993, 994, 996, 997, + 998, 999, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, + 1014, 1015, 1017, 1018, 1020, 1023, 1025, 1027, 1028, 1030, 1031, 1032, 1033, 1034, 1035, + 1036, 1037, 1039, 1040, 1043, 1044, 1046, 1047, 1048, 1049, 1051, 1053, 1054, 1055, 1056, + 1057, 1059, 1061, 1064, 1067, 1069, 1070, 1071, 1072, 1073, 1074, 1075, 1078, 1079, 1080, + 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1105, 1106, 1107, 1108, 1109, 1110, + 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1128, 1129, 1130, 1131, 1132, 1135, 1137, 1138, + 1142, 1143, 1145, 1146, 1147, 1149, 1153, 1157, 1158, 1159, 1160, 1161, 1162, 1163, 1164, + 1166, 1168, 1169, 1171, 1174, 1176, 1179, 1180, 1181, 1182, 1183, 1186, 1187, 1188, 1191, + 1192, 1194, 1197, 1198, 1201, 1224, 1225, 1226, 1227, 1253, 1254, 1287, 1301, 1304, 1305, + 1307, 1309, 1310, 1311, 1331, 1334, 1335, 1336, 1338, 1339, 1341, 1342, 1344, 1345, 1346, + 1347, 1348, 1352, 1353, 1366, 1367, 1368, 1369, 1370, 1371, 1373, 1374, 1375, 1376, 1377, + 1378, 1381, 1386, 1388, 1389, 1390, 1395, 1396, 1397, 1398, 1399, 1400, 1407, 1409, 1410, + 1411, 1412, 1415, 1416, 1419, 1420, 1421, 1424, 1425, 1426, 1427, 1429, 1430, 1431, 1435, + 1436, 1438, 1439, 1440, 1441, 1442, 1444, 1445, 1449, 1450, 1452, 1454, 1460, 1462, 1463, + 1465, 1466, 1467, 1469, 1472, 1475, 1476, 1477, 1480, 1481, 1483, 1486, 1492, 1493, 1494, + 1495, 1496, 1497, 1499, 1508, 1509, 1510, 1511, 1521, 1523, 1525, 1526, 1527, 1528, 1529, + 1530, 1531, 1532, 1533, 1535, 1536, 1540, 1542, 1543, 1544, 1546, 1550, 1551, 1552, 1554, + 1556, 1557, 1560, 1561, 1562, 1563, 1564, 1568, 1569, 1573, 1575, 1578, 1579, 1582, 1587, + 1588, 1589, 1594, 1595, 1598, 1600, 1601, 1603, 1605, 1608, 1613, 1614, 1620, 1621, 1622, + 1624, 1628, 1629, 1630, 1631, 1641, 1642, 1644, 1645, 1652, 1653, 1655, 1656, 1659, 1662, + 1663, 1665, 1669, 1670, 1673, 1675, 1677, 1678, 1679, 1682, 1683, 1684, 1686, 1688, 1690, + 1697, 1698, 1700, 1701, 1702, 1703, 1707, 1713, 1714, 1715, 1716, 1718, 1720, 1721, 1722, + 1723, 1724, 1725, 1727, 1728, 1729, 1730, 1733, 1734, 1737, 1741, 1742, 1743, 1745, 1746, + 1749, 1750, 1751, 1752, 1754, 1755, 1757, 1759, 1760, 1761, 1762, 1763, 1764, 1765, 1766, + 1767, 1768, 1776, 1777, 1778, 1779, 1780, 1781, 1782, 1783, 1784, 1785, 1786, 1787, 1788, + 1789, 1790, 1791, 1798, 1799, 1800, 1801, 1802, 1803, 1804, 1805, 1806, 1808, 1809, 1810, + 1812, 1813, 1815, 1817, 1818, 1819, 1821, 1823, 1825, 1826, 1828, 1829, 1830, 1831, 1832, + 1833, 1834, 1835, 1836, 1837, 1838, 1864, 1865, 1866, 1867, 1868, 1869, 1870, 1871, 1872, + 1873, 1875, 1876, 1877, 1879, 1880, 1881, 1882, 1885, 1887, 1890, 1891, 1893, 1895, 1896, + 1900, 1901, 1904, 1905, 1907, 1908, 1909, 1910, 1911, 1912, 1913, 1914, 1915, 1925, 1926, + 1927, 1928, 1929, 1931, 1932, 1934, 1935, 1937, 1939, 1942, 1943, 1944, 1945, 1946, 1951, + 1952, 1953, 1955, 1956, 1965, 1966, 1970, 1971, 1972, 1973, 1974, 1976, 1978, 1979, 1981, + 1982, 1983, 1987, 1988, 1989, 1990, 1991, 1992, 1993, 1994, 1995, 1996, 1998, 2000, 2001, + 2002, 2006, 2007, 2009, 2010, 2011, 2012, 2013, 2014, 2017, 2018, 2021, 2022, 2023, 2024, + 2026, 2027, 2029, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2042, 2043, 2045, + 2046, 2049, 2050, 2052, 2053, 2054, 2055, 2056, 2058, 2059, 2063, 2064, 2065, 2066, 2067, + 2068, 2069, 2070, 2071, 2075, 2076, 2079, 2080, 2081, 2086, 2088, 2089, 2091, 2092, 2093, + 2095, 2096, 2104, 2105, 2106, 2109, 2111, 2112, 2116, 2118, 2119, 2121, 2122, 2124, 2127, + 2128, 2131, 2133, 2136, 2138, 2139, 2142, 2146, 2147, 2150, 2151, 2152, 2153, 2154, 2155, + 2156, 2157, 2158, 2159, 2161, 2162, 2179, 2180, 2181, 2182, 2183, 2184, 2185, 2187, 2188, + 2189, 2190, 2191, 2192, 2193, 2194, 2195, 2196, 2197, 2198, 2199, 2200, 2201, 2202, 2203, + 2204, 2205, 2207, 2208, 2209, 2210, 2211, 2212, 2213, 2214, 2215, 2216, 2217, 2219, 2221, + 2232, 2233, 2238, 2239, 2240, 2241, 2242, 2244, 2245, 2246, 2247, 2248, 2249, 2250, 2251, + 2252, 2255, 2257, 2258, 2259, 2260, 2261, 2262, 2263, 2264, 2265, 2266, 2267, 2332, 2333, + 2334, 2335, 2336, 2337, 2338, 2339, 2340, 2343, 2344, 2345, 2346, 2347, 2348, 2349, 2350, + 2351, 2355, 2357, 2358, 2359, 2360, 2361, 2363, 2364, 2365, 2366, 2367, 2368, 2369, 2370, + 2371, 2372, 2373, 2374, 2375, 2376, 2377, 2378, 2379, 2380, 2381, 2382, 2383, 2384, 2385, + 2386, 2387, 2388, 2389, 2390, 2391, 2392, 2393, 2395, 2396, 2397, 2398, 2399, 2400, 2401, + 2402, 2403, 2404, 2405, 2407, 2408, 2412, 2413, 2414, 2415, 2418, 2419, 2420, 2421, 2422, + 2424, 2427, 2428, 2430, 2431, 2432, 2434, 2435, 2441, 2443, 2444, 2445, 2446, 2450, 2451, + 2452, 2461, 2463, 2464, 2465, 2466, 2469, 2478, 2479, 2480, 2482, 2483, 2486, 2488, 2491, + 2492, 2494, 2497, 2499, 2503, 2505, 2506, 2507, 2508, 2509, 2518, 2519, 2520, 2521, 2522, + 2523, 2527, 2528, 2537, 2538, 2539, 2540, 2541, 2542, 2543, 2544, 2568, 2569, 2570, 2572, + 2577, 2579, 2583, 2584, 2585, 2586, 2587, 2590, 2591, 2593, 2595, 2596, 2597, 2598, 2599, + 2601, 2602, 2603, 2604, 2605, 2606, 2607, 2609, 2610, 2611, 2612, 2613 + ] +} diff --git a/tests/project/test_docs_issue_pr_roles_1103_unit.py b/tests/project/test_docs_issue_pr_roles_1103_unit.py index 87764a8a1e..9d49af28f5 100644 --- a/tests/project/test_docs_issue_pr_roles_1103_unit.py +++ b/tests/project/test_docs_issue_pr_roles_1103_unit.py @@ -11,14 +11,20 @@ * :class:`ScannerTests` -- the scanners skip literals, code blocks, comments, link targets and URL fragments, and flag the rest. +* :class:`KindTests` -- a role up to :data:`NEWEST` is flagged when :data:`KINDS` + records its number as the other kind or as neither; one above it is skipped. +* :class:`RefreshScriptTests` -- :file:`util/issue_pr_kinds.py` writes + :data:`KINDS_FILE` in the form it is committed in, from ``gh api --paginate``. * :class:`RenderedTextTests` -- no ``.rst`` page under :file:`docs/source`, no :file:`pcapkit/` docstring and no ``#:`` doc-comment holds a hand-written link - to an issue or pull request of this repository, or a bare ``#NNN``. + to an issue or pull request of this repository, or a bare ``#NNN``, and every + role there names what its number is. Links to other repositories, to Discussions and to a comment on an issue keep their explicit URLs, which a role cannot express; ``GH-NNN`` is a deliberate issue form and is not matched. Everything is read as text or parsed with -:mod:`ast` and :mod:`tokenize`; nothing is imported or built. +:mod:`ast` and :mod:`tokenize`; :mod:`pcapkit` is not imported, nothing is +built, and the refresh script's ``gh`` call is mocked. """ @@ -26,11 +32,17 @@ import ast import io +import json import pathlib import re +import subprocess # nosec B404 +import tempfile import tokenize import unittest from typing import TYPE_CHECKING +from unittest import mock + +from util import issue_pr_kinds if TYPE_CHECKING: from typing import Iterator @@ -51,6 +63,22 @@ #: character references (`` ``) and ``##12``. BARE_NUMBER = re.compile(r'(??`') + +#: Each issue and pull request number of this repository's tracker, mapped to the +#: role it takes, as ``python util/issue_pr_kinds.py`` last wrote them; re-run it +#: to check a role citing a number above :data:`NEWEST`. +KINDS_FILE = pathlib.Path(__file__).resolve().with_name('issue_pr_roles_fixtures') / 'kinds.json' +KINDS = {number: kind for kind, numbers in json.loads(KINDS_FILE.read_text('utf-8')).items() + for number in numbers} +#: The highest number :data:`KINDS` records. A lower one it lacks, such as a +#: Discussion's, is neither an issue nor a pull request. +NEWEST = max(KINDS) + +#: What each role names, for a failure message. +KIND_NAMES = {'issue': 'an issue', 'pr': 'a pull request'} + #: An inline literal, which renders verbatim. INLINE_LITERAL = re.compile(r'``.+?``', re.DOTALL) @@ -227,16 +255,49 @@ def scan_python(source: 'str') -> 'Iterator[tuple[int, str]]': yield from scan_text(text, line) -def repository_matches() -> 'list[tuple[str, int, str]]': - """Every match in the rendered text of the repository.""" - found = [] +def scan_roles(text: 'str', first_line: 'int' = 1) -> 'Iterator[tuple[int, str, int]]': + """The line, role and number of each ``:issue:`` and ``:pr:`` role in ``text``.""" + body = INLINE_LITERAL.sub(_blank, '\n'.join(rendered_lines(text))) + for match in ROLE.finditer(body): + yield first_line + body.count('\n', 0, match.start()), match.group(1), int(match.group(2)) + + +def rendered_texts() -> 'Iterator[tuple[str, int, str]]': + """The path, first line and text of each ``.rst`` page, docstring and ``#:`` doc-comment.""" for path in sorted(DOCS.rglob('*.rst')): - name = path.relative_to(ROOT).as_posix() - found.extend((name, line, text) for line, text in scan_text(path.read_text('utf-8'))) + yield path.relative_to(ROOT).as_posix(), 1, path.read_text('utf-8') for path in sorted(PACKAGE.rglob('*.py')): - name = path.relative_to(ROOT).as_posix() - found.extend((name, line, text) for line, text in scan_python(path.read_text('utf-8'))) - return found + name, source = path.relative_to(ROOT).as_posix(), path.read_text('utf-8') + for line, text in docstrings(source): + yield name, line, text + for line, text in doc_comments(source): + yield name, line, text + + +def repository_matches() -> 'list[tuple[str, int, str]]': + """Every match in the rendered text of the repository.""" + return [(name, line, text) for name, first, body in rendered_texts() + for line, text in scan_text(body, first)] + + +def repository_roles() -> 'list[tuple[str, int, str, int]]': + """Every ``:issue:`` and ``:pr:`` role in the rendered text of the repository.""" + return [(name, line, role, number) for name, first, body in rendered_texts() + for line, role, number in scan_roles(body, first)] + + +def wrong_kinds(roles: 'list[tuple[str, int, str, int]]', + kinds: 'dict[int, str]') -> 'list[str]': + """Each role up to the highest number in ``kinds`` that it records as another kind or lacks. + + A number above that highest is newer than ``kinds`` and is skipped. + + """ + newest = max(kinds) + return [f'{path}:{line}: :{role}:`{number}` ' + + (f'names {KIND_NAMES[kinds[number]]}' if number in kinds + else 'is neither an issue nor a pull request') + for path, line, role, number in roles if number <= newest and kinds.get(number) != role] class ScannerTests(unittest.TestCase): @@ -281,9 +342,67 @@ def test_allowed_entries_still_match(self) -> None: found = {(path, text) for path, _, text in repository_matches()} self.assertEqual(sorted(ALLOWED - found), [], 'stale ALLOWED entries') + def test_reads_roles_outside_literals(self) -> None: + text = (':issue:`12`, :pr:`34`, :issue:`a caption <56>`, :discussion:`78`,\n' + '``:issue:`90``` and::\n\n :pr:`91`\n\nend :pr:`92`.\n') + self.assertEqual(list(scan_roles(text)), + [(1, 'issue', 12), (1, 'pr', 34), (1, 'issue', 56), (6, 'pr', 92)]) + + +class KindTests(unittest.TestCase): + """A role is checked against the kind its number is recorded as.""" + + def test_flags_the_other_kind_and_a_gap_and_skips_a_newer_number(self) -> None: + roles = [('a.rst', 1, 'issue', 2), ('a.rst', 2, 'pr', 3), ('a.rst', 3, 'issue', 4), + ('a.rst', 4, 'pr', 5), ('a.rst', 5, 'issue', 7), ('a.rst', 6, 'pr', 8)] + self.assertEqual(wrong_kinds(roles, {2: 'pr', 3: 'issue', 4: 'issue', 6: 'pr'}), + ['a.rst:1: :issue:`2` names a pull request', + 'a.rst:2: :pr:`3` names an issue', + 'a.rst:4: :pr:`5` is neither an issue nor a pull request']) + + def test_kinds_file_records_each_number_once(self) -> None: + data = json.loads(KINDS_FILE.read_text('utf-8')) + self.assertEqual(sorted(data), sorted(KIND_NAMES)) + for kind, numbers in data.items(): + with self.subTest(kind=kind): + self.assertEqual(numbers, sorted(set(numbers))) + self.assertEqual(set(data['issue']) & set(data['pr']), set()) + + +class RefreshScriptTests(unittest.TestCase): + """:file:`util/issue_pr_kinds.py` writes :data:`KINDS_FILE` from the tracker.""" + + def test_render_writes_the_committed_form(self) -> None: + self.assertEqual(issue_pr_kinds.render(KINDS), KINDS_FILE.read_text('utf-8')) + self.assertEqual(issue_pr_kinds.OUTPUT, KINDS_FILE) + + def test_fetch_reads_every_page(self) -> None: + done = subprocess.CompletedProcess([], 0, stdout='2\tissue\n3\tpr\n', stderr='') + with mock.patch.object(issue_pr_kinds.subprocess, 'run', return_value=done) as run: + self.assertEqual(issue_pr_kinds.fetch('o/r'), {2: 'issue', 3: 'pr'}) + command = run.call_args.args[0] + self.assertEqual(command[:3], ['gh', 'api', '--paginate']) + self.assertIn('repos/o/r/issues?state=all&per_page=100', command) + + def test_fetch_fails_on_an_error_or_an_empty_tracker(self) -> None: + for done in (subprocess.CompletedProcess([], 1, stdout='', stderr='HTTP 401'), + subprocess.CompletedProcess([], 0, stdout='', stderr='')): + with self.subTest(returncode=done.returncode), \ + mock.patch.object(issue_pr_kinds.subprocess, 'run', return_value=done), \ + self.assertRaises(RuntimeError): + issue_pr_kinds.fetch('o/r') + + def test_main_writes_the_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output = pathlib.Path(tmp, 'kinds.json') + with mock.patch.object(issue_pr_kinds, 'fetch', return_value={3: 'pr', 2: 'issue'}), \ + mock.patch('sys.stdout', new_callable=io.StringIO): + self.assertEqual(issue_pr_kinds.main(['--output', str(output)]), 0) + self.assertEqual(json.loads(output.read_text('utf-8')), {'issue': [2], 'pr': [3]}) + class RenderedTextTests(unittest.TestCase): - """No rendered text cites an issue or pull request without a role.""" + """No rendered text cites an issue or pull request without a role of its kind.""" def test_rendered_text_uses_roles(self) -> None: offending = [f'{path}:{line}: {text}' for path, line, text in repository_matches() @@ -291,6 +410,11 @@ def test_rendered_text_uses_roles(self) -> None: self.assertEqual(offending, [], 'cite issues as :issue:`NNN` and pull requests as ' ':pr:`NNN` in .rst pages, docstrings and #: doc-comments') + def test_roles_name_what_the_number_is(self) -> None: + self.assertEqual(wrong_kinds(repository_roles(), KINDS), [], + f'use the role of what the number is, recording an unlisted one in ' + f'{KINDS_FILE.relative_to(ROOT).as_posix()}') + if __name__ == '__main__': unittest.main() diff --git a/tests/project/test_issue_pr_kinds_2561.py b/tests/project/test_issue_pr_kinds_2561.py new file mode 100644 index 0000000000..76156a5b0a --- /dev/null +++ b/tests/project/test_issue_pr_kinds_2561.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +"""#2561: Issue and PR Kinds proposes a refresh of the #1103 roles test's snapshot. + +:file:`.github/workflows/cron-issue-pr-kinds.yml` runs :file:`util/issue_pr_kinds.py` +every Monday and on dispatch. ``detect`` regenerates +:file:`tests/project/issue_pr_roles_fixtures/kinds.json` with a read-only token; +``pr``, behind ``github-release``, commits it on ``bot/issue-pr-kinds`` with the +release App's token and opens that branch's pull request. ``main`` is never pushed. + +* :class:`TestTheWorkflow` pins the triggers, the permissions, the one secret and + the guard on ``pr``. +* :class:`TestDeciding` runs ``--github-output``'s decision against a stand-in + for :program:`git` and :program:`gh`. +* :class:`TestPublishing` runs ``--publish`` against the same stand-in: the + lease, the recreated branch and the pull request it opens or leaves. + +:mod:`tests.project.test_docs_issue_pr_roles_1103_unit` checks the file's form. + +""" + +from __future__ import annotations + +import io +import json +import os +import pathlib +import subprocess # nosec B404 +import tempfile +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +from tests import _workflows +from tests.project.test_release_gates import evaluate_condition +from util import issue_pr_kinds + +if TYPE_CHECKING: + from typing import Any, Callable + +#: Test tier (#1958): reads the workflow and runs the script against stand-ins. +__tier__ = 'LATEST' + +WORKFLOW = 'cron-issue-pr-kinds.yml' +#: The kinds file a stand-in run proposes. +TEXT = issue_pr_kinds.render({2: 'issue', 3: 'pr'}) + + +def done(returncode: 'int' = 0, stdout: 'str' = '', stderr: 'str' = '') -> 'subprocess.CompletedProcess[str]': + return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr) + + +class FakeRun: + """A stand-in for :func:`util.issue_pr_kinds.run`: each call recorded, each answered by ``reply``.""" + + def __init__(self, reply: 'Callable[[tuple[str, ...]], subprocess.CompletedProcess[str]]') -> None: + self.calls = [] # type: list[tuple[str, ...]] + self.reply = reply + + def __call__(self, *argv: 'str', check: 'bool' = True) -> 'subprocess.CompletedProcess[str]': + self.calls.append(argv) + result = self.reply(argv) + if check and result.returncode: + raise RuntimeError(f'{argv} failed') + return result + + def made(self, *prefix: 'str') -> 'list[tuple[str, ...]]': + return [call for call in self.calls if call[:len(prefix)] == prefix] + + +class TestTheWorkflow(unittest.TestCase): + """Two jobs: ``detect`` with no secret, ``pr`` with the App's token behind ``github-release``.""" + + @classmethod + def setUpClass(cls) -> None: + cls.spec = _workflows.load(WORKFLOW) + cls.jobs = cls.spec['jobs'] + + def step(self, job: 'str', label: 'str') -> 'dict[str, Any]': + return next(step for step in self.jobs[job]['steps'] if step.get('name') == label) + + def test_it_runs_on_a_schedule_and_on_dispatch_one_at_a_time(self) -> None: + self.assertEqual(self.spec['name'], 'Issue and PR Kinds') + self.assertEqual(self.spec['on'], {'schedule': [{'cron': '17 6 * * 1'}], 'workflow_dispatch': None}) + self.assertEqual(self.spec['permissions'], {}) + self.assertEqual(self.spec['concurrency'], {'group': 'issue-pr-kinds', 'cancel-in-progress': False}) + self.assertEqual(sorted(self.jobs), ['detect', 'pr']) + + def test_detect_reads_only_and_decides_with_the_script(self) -> None: + job = self.jobs['detect'] + self.assertEqual(job['permissions'], {'contents': 'read', 'issues': 'read', 'pull-requests': 'read'}) + self.assertNotIn('environment', job) + self.assertNotIn('secrets.', json.dumps(job)) + regenerate = self.step('detect', 'Regenerate the kinds') + self.assertEqual(regenerate['run'], 'python3 util/issue_pr_kinds.py --github-output') + self.assertEqual(regenerate['env'], {'GH_TOKEN': '${{ github.token }}'}) + self.assertEqual(job['outputs'], {'publish': '${{ steps.regenerate.outputs.publish }}'}) + upload = self.step('detect', 'Upload the kinds') + self.assertEqual(upload['with']['path'], issue_pr_kinds.PATH) + + def test_pr_runs_only_with_something_to_propose_the_app_and_main(self) -> None: + condition = self.jobs['pr']['if'] + for publish, client, ref, expected in (('true', 'Iv23example', 'refs/heads/main', True), + ('true', '', 'refs/heads/main', False), + ('false', 'Iv23example', 'refs/heads/main', False), + ('true', 'Iv23example', 'refs/heads/feature', False)): + with self.subTest(publish=publish, client=client, ref=ref): + state = {'needs': {'detect': {'outputs': {'publish': publish}}}, + 'vars': {'RELEASE_APP_CLIENT_ID': client}, 'github': {'ref': ref}} + self.assertIs(evaluate_condition(condition, state), expected) + + def test_pr_mints_the_narrow_token_behind_github_release_and_pushes_through_the_script(self) -> None: + job = self.jobs['pr'] + self.assertEqual(job['environment'], 'github-release') + self.assertEqual(job['needs'], 'detect') + self.assertEqual(job['permissions'], {'actions': 'read'}) + mint = self.step('pr', "Mint the release App's token") + self.assertTrue(_workflows.pinned(mint['uses'], 'actions/create-github-app-token'), mint['uses']) + self.assertEqual(mint['with'], { + 'client-id': '${{ vars.RELEASE_APP_CLIENT_ID }}', + 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}', + 'permission-contents': 'write', + 'permission-pull-requests': 'write', + }) + self.assertEqual([step for step in job['steps'] if 'secrets.' in json.dumps(step)], [mint]) + push = self.step('pr', 'Push the kinds and open or update their pull request') + self.assertEqual(push['run'].split()[:4], ['python3', 'util/issue_pr_kinds.py', '--publish', + '"$RUNNER_TEMP/kinds/kinds.json"']) + self.assertEqual(push['env']['GH_TOKEN'], '${{ steps.app.outputs.token }}') + text = (_workflows.WORKFLOWS / WORKFLOW).read_text(encoding='utf-8') + self.assertNotIn('git push', text) + self.assertEqual(issue_pr_kinds.BRANCH, 'bot/issue-pr-kinds') + self.assertEqual(issue_pr_kinds.BASE, 'main') + + +class TestDeciding(unittest.TestCase): + """``publish`` is true for a change the open pull request does not already hold.""" + + def decide(self, changed: 'bool', open_pr: 'bool', held: 'str') -> 'tuple[bool, FakeRun]': + def reply(argv: 'tuple[str, ...]') -> 'subprocess.CompletedProcess[str]': + if argv[:2] == ('git', 'diff'): + return done(1 if changed else 0) + if argv[:3] == ('gh', 'pr', 'list'): + return done(stdout='41\n' if open_pr else '') + return done(stdout=held) + + fake = FakeRun(reply) + with mock.patch.object(issue_pr_kinds, 'run', fake): + return issue_pr_kinds.should_publish('o/r', TEXT), fake + + def test_each_case(self) -> None: + for changed, open_pr, held, expected in ((False, True, 'other', False), (True, False, '', True), + (True, True, TEXT, False), (True, True, 'other', True)): + with self.subTest(changed=changed, open_pr=open_pr, held=held[:5]): + decided, fake = self.decide(changed, open_pr, held) + self.assertIs(decided, expected) + if open_pr and changed: + self.assertEqual(fake.calls[-1][-1], f'repos/o/r/contents/{issue_pr_kinds.PATH}' + f'?ref={issue_pr_kinds.BRANCH}') + + def test_main_writes_the_decision(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output, kinds = pathlib.Path(tmp, 'out'), pathlib.Path(tmp, 'kinds.json') + with mock.patch.dict(os.environ, {'GITHUB_OUTPUT': str(output)}), \ + mock.patch.object(issue_pr_kinds, 'fetch', return_value={2: 'issue', 3: 'pr'}), \ + mock.patch.object(issue_pr_kinds, 'should_publish', return_value=True) as decide, \ + mock.patch('sys.stdout', new_callable=io.StringIO): + self.assertEqual(issue_pr_kinds.main(['--github-output', '--output', str(kinds)]), 0) + self.assertEqual(output.read_text('utf-8'), 'publish=true\n') + self.assertEqual(decide.call_args.args[1], TEXT) + + +class TestPublishing(unittest.TestCase): + """``--publish`` moves only :data:`util.issue_pr_kinds.BRANCH` and opens its pull request once.""" + + def publish(self, staged: 'bool' = True, remote: 'str' = '', pushes: 'tuple[str, ...]' = ('',), + open_pr: 'bool' = False) -> 'FakeRun': + results = list(pushes) + + def reply(argv: 'tuple[str, ...]') -> 'subprocess.CompletedProcess[str]': + if argv[:3] == ('git', 'diff', '--cached'): + return done(1 if staged else 0) + if argv[:2] == ('git', 'ls-remote'): + return done(stdout=f'{remote}\trefs/heads/{issue_pr_kinds.BRANCH}\n' if remote else '') + if argv[:2] == ('git', 'push') and argv[-1].startswith('HEAD:'): + stderr = results.pop(0) + return done(1 if stderr else 0, stderr=stderr) + if argv[:3] == ('gh', 'pr', 'list'): + return done(stdout='41\n' if open_pr else '') + if argv[:3] == ('gh', 'pr', 'create'): + return done(stdout='https://github.com/o/r/pull/42\n') + return done() + + fake = FakeRun(reply) + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp, 'kinds.json') + source.write_text(TEXT, encoding='utf-8') + with mock.patch.object(issue_pr_kinds, 'run', fake), \ + mock.patch.object(issue_pr_kinds, 'OUTPUT', pathlib.Path(tmp, 'out.json')), \ + mock.patch.dict(os.environ, {'APP_SLUG': 'pcapkit-release', 'APP_BOT_ID': '7'}), \ + mock.patch('sys.stdout', new_callable=io.StringIO): + self.assertEqual(issue_pr_kinds.publish('o/r', source, 'https://run'), 0) + self.assertEqual(pathlib.Path(tmp, 'out.json').read_text('utf-8'), TEXT) + return fake + + def test_nothing_staged_pushes_nothing(self) -> None: + fake = self.publish(staged=False) + self.assertEqual(fake.made('git', 'commit'), []) + self.assertEqual(fake.made('git', 'push'), []) + + def test_a_new_branch_is_pushed_and_its_pull_request_opened(self) -> None: + fake = self.publish() + commit = fake.made('git', '-c') + self.assertEqual(commit[0][:4], ('git', '-c', 'user.name=pcapkit-release[bot]', + '-c')) + self.assertIn('user.email=7+pcapkit-release[bot]@users.noreply.github.com', commit[0]) + self.assertEqual(fake.made('git', 'push'), [ + ('git', 'push', f'--force-with-lease=refs/heads/{issue_pr_kinds.BRANCH}:', 'origin', + f'HEAD:refs/heads/{issue_pr_kinds.BRANCH}')]) + created = fake.made('gh', 'pr', 'create') + self.assertEqual(len(created), 1) + self.assertEqual(created[0][created[0].index('--head') + 1], issue_pr_kinds.BRANCH) + self.assertEqual(created[0][created[0].index('--base') + 1], 'main') + + def test_an_open_pull_request_is_updated_by_the_push_alone(self) -> None: + fake = self.publish(remote='a' * 40, open_pr=True) + self.assertEqual(fake.made('git', 'push')[0][2], f'--force-with-lease=refs/heads/{issue_pr_kinds.BRANCH}:' + f'{"a" * 40}') + self.assertEqual(fake.made('gh', 'pr', 'create'), []) + + def test_a_workflow_change_on_main_recreates_the_branch(self) -> None: + fake = self.publish(remote='a' * 40, pushes=(f'refusing {issue_pr_kinds.WORKFLOWS_REFUSAL}', '')) + pushes = fake.made('git', 'push') + self.assertEqual([push[-1] for push in pushes], [f'HEAD:refs/heads/{issue_pr_kinds.BRANCH}', + f':refs/heads/{issue_pr_kinds.BRANCH}', + f'HEAD:refs/heads/{issue_pr_kinds.BRANCH}']) + self.assertEqual(pushes[-1][2], f'--force-with-lease=refs/heads/{issue_pr_kinds.BRANCH}:') + + def test_another_push_failure_raises(self) -> None: + with self.assertRaises(RuntimeError): + self.publish(remote='a' * 40, pushes=('rejected: stale info',)) + + def test_the_body_fills_in_the_template(self) -> None: + text = issue_pr_kinds.body(TEXT, 'https://run') + self.assertIn('- [x] `test`', text) + self.assertIn('up to #3', text) + self.assertIn('https://run', text) + self.assertNotIn('Explanation of your *pull request*', text) + self.assertIn('[Searched]', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py index 0b5e2923a2..73f7a205da 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -436,10 +436,12 @@ def test_the_private_key_is_read_only_in_github_behind_its_environment(self) -> """An environment secret: "If the environment requires approval, a job cannot access environment secrets until one of the required reviewers approves it". - #1660's Python Versions ``pr`` job is the one other reader, behind the same - environment, minting the token it opens its pull request with. + #1660's Python Versions and #2561's Issue and PR Kinds ``pr`` jobs are the + other readers, behind the same environment, minting the token each opens its + pull request with. """ - readers_by_workflow = {WORKFLOW.name: ['github'], 'cron-python-versions.yml': ['pr']} + readers_by_workflow = {WORKFLOW.name: ['github'], 'cron-python-versions.yml': ['pr'], + 'cron-issue-pr-kinds.yml': ['pr']} for path in sorted(WORKFLOW.parent.glob('*.yml')): text = strip_comments(path.read_text(encoding='utf-8')) blocks = job_blocks(text, path) diff --git a/util/issue_pr_kinds.py b/util/issue_pr_kinds.py new file mode 100644 index 0000000000..8d8c542567 --- /dev/null +++ b/util/issue_pr_kinds.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +"""Regenerate the issue and pull request kinds the #1103 roles test reads. + +:file:`tests/project/test_docs_issue_pr_roles_1103_unit.py` checks that each +``:issue:`` and ``:pr:`` role in rendered text names what its number is, against +:file:`tests/project/issue_pr_roles_fixtures/kinds.json`. That file maps the role +each issue and pull request of this repository takes to their numbers, and the +test skips a number above its highest. This script rewrites it from the tracker, +read with ``gh api --paginate``, so a role citing such a number is checked after +one run from the repository root:: + + python util/issue_pr_kinds.py + +:file:`.github/workflows/cron-issue-pr-kinds.yml` runs it weekly (#2561): +``--github-output`` writes ``publish``, whether the file changed and the open +pull request from :data:`BRANCH` does not already hold it, and ``--publish FILE`` +commits that file on :data:`BRANCH`, force-pushes the branch and opens its pull +request if none is open, so ``main`` is never pushed. Standard library and the +:program:`gh` CLI only; it needs :program:`gh` signed in, or ``GH_TOKEN``. + +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +# The gh and git CLIs, with a fixed argument list and no shell. +import subprocess # nosec B404 +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional + +#: The checkout the script reads and writes. +ROOT = pathlib.Path(__file__).resolve().parents[1] +#: The repository whose tracker is read, unless ``$GITHUB_REPOSITORY`` names one. +REPO = 'JarryShaw/PyPCAPKit' +#: The file written, as git and the contents API name it. +PATH = 'tests/project/issue_pr_roles_fixtures/kinds.json' +#: The file written, beside the test that reads it. +OUTPUT = ROOT / PATH +#: The roles, in the order the file lists them. +ROLES = ('issue', 'pr') +#: Numbers per line of the file. A new number is the highest, so a refresh rewrites +#: only the last lines of each role. +PER_LINE = 15 +#: Each item of a page as ``\t``; an item is a pull request when it +#: carries ``pull_request``. +JQ = '.[] | [.number, (if .pull_request then "pr" else "issue" end)] | @tsv' +#: The branch the workflow proposes a refresh on, and the one it targets. +BRANCH = 'bot/issue-pr-kinds' +BASE = 'main' +#: The commit's subject and the pull request's title. +TITLE = 'test: refresh the issue and pull request kinds' +#: The repository's pull request template, which the body fills in. +TEMPLATE = ROOT / '.github' / 'PULL_REQUEST_TEMPLATE.md' +#: What a push says when moving the branch would change a workflow file, which +#: the App's token has no permission to do. +WORKFLOWS_REFUSAL = 'without `workflows` permission' + + +def run(*argv: 'str', check: 'bool' = True) -> 'subprocess.CompletedProcess[str]': + """``argv`` in :data:`ROOT`; a failure raises :exc:`RuntimeError` unless ``check`` is false.""" + proc = subprocess.run(list(argv), cwd=ROOT, capture_output=True, text=True, check=False) # nosec B603 B607 + if check and proc.returncode != 0: + raise RuntimeError(f'{argv[0]} {argv[1]} failed ({proc.returncode}): {proc.stderr.strip()}') + return proc + + +def fetch(repo: 'str' = REPO) -> 'dict[int, str]': + """Every issue and pull request number of ``repo``, mapped to its role.""" + proc = run('gh', 'api', '--paginate', f'repos/{repo}/issues?state=all&per_page=100', '--jq', JQ) + kinds = {} + for line in proc.stdout.splitlines(): + number, role = line.split('\t') + kinds[int(number)] = role + if not kinds: + raise RuntimeError(f'gh api returned no issue or pull request of {repo}') + return kinds + + +def render(kinds: 'dict[int, str]') -> 'str': + """``kinds`` as the file's JSON: each role's sorted numbers, :data:`PER_LINE` a line.""" + blocks = [] + for role in ROLES: + numbers = sorted(number for number, kind in kinds.items() if kind == role) + rows = [', '.join(map(str, numbers[i:i + PER_LINE])) for i in range(0, len(numbers), PER_LINE)] + lines = ',\n'.join(f' {row}' for row in rows) + blocks.append(f' "{role}": [\n{lines}\n ]' if rows else f' "{role}": []') + return '{\n' + ',\n'.join(blocks) + '\n}\n' + + +def open_pull_request(repo: 'str') -> 'Optional[str]': + """The open pull request from ``repo``'s own :data:`BRANCH` into :data:`BASE`, by number.""" + numbers = run('gh', 'pr', 'list', '--repo', repo, '--head', BRANCH, '--base', BASE, '--state', 'open', + '--json', 'number,isCrossRepository', + '--jq', '.[] | select(.isCrossRepository | not) | .number').stdout.split() + return numbers[0] if numbers else None + + +def should_publish(repo: 'str', text: 'str') -> 'bool': + """Whether ``text``, written to :data:`PATH`, is a change the open pull request does not already hold.""" + if run('git', 'diff', '--quiet', '--', PATH, check=False).returncode == 0: + return False + if open_pull_request(repo) is None: + return True + held = run('gh', 'api', '-H', 'Accept: application/vnd.github.raw', + f'repos/{repo}/contents/{PATH}?ref={BRANCH}', check=False) + return held.returncode != 0 or held.stdout != text + + +def body(kinds_text: 'str', run_url: 'str') -> 'str': + """The pull request template, ``test`` ticked, filled in for a refresh.""" + newest = max(number for numbers in json.loads(kinds_text).values() for number in numbers) + text = TEMPLATE.read_text(encoding='utf-8') + text = re.sub(r'(?m)^- \[ \] `test`', '- [x] `test`', text) + text = re.sub(r'(?m)^(- \[ \] `make test` passes.*)$', r"\1 -- not run here; this pull request's checks run it", + text) + text = re.sub(r'(?m)^(- \[ \] Added a \[changelog entry\].*)$', r'\1 — N/A — centralised in #657', text) + description = (f'Regenerated `{PATH}` from the tracker with `python util/issue_pr_kinds.py`, up to #{newest}, ' + 'so `tests/project/test_docs_issue_pr_roles_1103_unit.py` checks every role citing a number ' + f'up to it. Opened by the Issue and PR Kinds workflow ({run_url}); a later run force-pushes ' + 'this branch, so edit `main` rather than this branch.') + placeholder = re.search(r'(?m)^  Explanation of your \*pull request\*.*$', text) + if placeholder is None: + return text.rstrip('\n') + '\n\n' + description + '\n' + return text[:placeholder.start()] + description + text[placeholder.end():].rstrip('\n') + '\n' + + +def push(lease: 'str') -> 'subprocess.CompletedProcess[str]': + """``HEAD`` to ``origin``'s :data:`BRANCH`, if that is still at ``lease`` (``''``: absent).""" + return run('git', 'push', f'--force-with-lease=refs/heads/{BRANCH}:{lease}', 'origin', + f'HEAD:refs/heads/{BRANCH}', check=False) + + +def publish(repo: 'str', source: 'pathlib.Path', run_url: 'str') -> 'int': + """Commit ``source`` as :data:`PATH` on :data:`BRANCH`, push it, and open its pull request if none is.""" + slug = os.environ.get('APP_SLUG', '') + if not re.fullmatch(r'[\w-]+', slug): + raise ValueError(f'APP_SLUG {slug!r} is not an App slug') + bot_id = os.environ.get('APP_BOT_ID', '') + email = f'{bot_id}+{slug}[bot]@users.noreply.github.com' if bot_id.isdigit() else \ + f'{slug}[bot]@users.noreply.github.com' + text = source.read_text(encoding='utf-8') + OUTPUT.write_text(text, encoding='utf-8') + run('git', 'add', '--', PATH) + if run('git', 'diff', '--cached', '--quiet', check=False).returncode == 0: + print(f'{PATH} is already as proposed; nothing to push') + return 0 + run('git', '-c', f'user.name={slug}[bot]', '-c', f'user.email={email}', 'commit', '--quiet', '-m', TITLE) + + listed = run('git', 'ls-remote', '--heads', 'origin', f'refs/heads/{BRANCH}').stdout.split() + lease = listed[0] if listed else '' + pushed = push(lease) + if pushed.returncode and lease and WORKFLOWS_REFUSAL in pushed.stderr: + # main changed a workflow file since the branch was cut; a new branch moves none. + print(f'recreating {BRANCH}, which this token cannot move past a workflow change; its pull request closes') + run('git', 'push', f'--force-with-lease=refs/heads/{BRANCH}:{lease}', 'origin', f':refs/heads/{BRANCH}') + pushed = push('') + if pushed.returncode: + raise RuntimeError(f'git push failed ({pushed.returncode}): {pushed.stderr.strip()}') + + number = open_pull_request(repo) + if number is not None: + print(f'updated pull request #{number}') + else: + url = run('gh', 'pr', 'create', '--repo', repo, '--base', BASE, '--head', BRANCH, '--title', TITLE, + '--body', body(text, run_url)).stdout.strip() + print(f'opened {url}') + return 0 + + +def main(argv: 'Optional[list[str]]' = None) -> 'int': + parser = argparse.ArgumentParser(description='Regenerate the issue and pull request kinds ' + 'the #1103 roles test reads.') + parser.add_argument('--repo', default=os.environ.get('GITHUB_REPOSITORY') or REPO, + help=f'repository to read (default: $GITHUB_REPOSITORY, else {REPO})') + parser.add_argument('--output', type=pathlib.Path, default=OUTPUT, help='file to write') + parser.add_argument('--github-output', action='store_true', + help='also write publish=true|false to $GITHUB_OUTPUT') + parser.add_argument('--publish', type=pathlib.Path, metavar='FILE', + help=f'commit FILE on {BRANCH} and open or update its pull request; fetches nothing') + parser.add_argument('--run-url', default='', help='the run to name in a pull request it opens') + args = parser.parse_args(argv) + if args.publish is not None: + return publish(args.repo, args.publish, args.run_url) + kinds = fetch(args.repo) + text = render(kinds) + args.output.write_text(text, encoding='utf-8') + print(f'{args.output}: {len(kinds)} numbers, up to #{max(kinds)}') + if args.github_output: + decided = should_publish(args.repo, text) + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as file: + file.write(f'publish={str(decided).lower()}\n') + print(f'publish={str(decided).lower()}') + return 0 + + +if __name__ == '__main__': + sys.exit(main())