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())