diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d5788d04..6717a337 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,12 @@ + (CR-…, DOC-…, TECH-…) you address. Write "None" if there is no issue. + Put one keyword before each issue (`Fixes #12, fixes #13`): GitHub closes only the first of + `Fixes #12, #13`. The "Issue dependencies" check fails while an issue this PR links (title, + description, commits or the Development sidebar) is blocked, unless GitHub lists its blockers + as closed by this PR too, or a maintainer adds `dependency-override`. Mention an issue without + a keyword if this PR does not work on it. --> Fixes # ## Changes diff --git a/.github/workflows/issue-dependencies.yml b/.github/workflows/issue-dependencies.yml new file mode 100644 index 00000000..597b998e --- /dev/null +++ b/.github/workflows/issue-dependencies.yml @@ -0,0 +1,58 @@ +name: Issue dependencies + +# Keeps the `blocked` label in step with each issue's native "Blocked by" links (the rules live in +# tools/issue_dependencies.py and CONTRIBUTING.md, "Dependencies between issues"): +# - an open issue with a blocker that is not closed as completed gets `blocked`; +# - every other issue loses it, with one comment when an open issue is unblocked; +# - a blocker closed as not planned or as a duplicate leaves the issue blocked and asks a maintainer to re-plan it; +# - every open pull request whose "Issue dependencies" check no longer matches its linked issues (red although +# they are unblocked, or green although one became blocked) gets that check re-run. +# +# GitHub sends no workflow event when a "Blocked by" link is added or removed, so this runs when an issue is closed +# or reopened (what unblocks or re-blocks its dependents), hourly as the backstop for link edits, and on demand. +# Every run is a full reconciliation of state, not a reaction to a transition, so the concurrency group may drop a +# pending run, and a run that fails half-way is finished by the next one. tests/test_issue_dependencies.py pins: +# - the token holds `contents: read`, `issues: write`, `pull-requests: read`, `checks: read` and `actions: write`, +# nothing else; +# - no `${{ }}` appears in a `run:` line; the script reads the environment itself; +# - the checkout takes the default branch, one file, no stored credentials, pinned to a full commit SHA. +# +# Maintainers: to stop it, open Actions > "Issue dependencies" > the "..." menu > "Disable workflow". The links +# stay; only the label stops following them. + +on: + issues: + types: [closed, reopened] + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +permissions: {} + +concurrency: + group: issue-dependencies + cancel-in-progress: false + +jobs: + sync: + # This repository, and only its default branch: a manual run started from another branch would check out that + # branch's copy of the script with this job's write token. + if: ${{ github.repository == 'HodeTech/ForgeLM' && github.ref == 'refs/heads/development' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read # the checkout reads one script from the default branch + issues: write # add or remove `blocked` / `needs-triage`, and post the notices + pull-requests: read # read the open pull requests and the issues they link + checks: read # read each one's latest "Issue dependencies" result + actions: write # re-run that check when the result no longer matches + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: tools/issue_dependencies.py + sparse-checkout-cone-mode: false + - name: Bring the blocked label in step with the Blocked-by links + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 tools/issue_dependencies.py sync diff --git a/.github/workflows/pr-dependencies.yml b/.github/workflows/pr-dependencies.yml new file mode 100644 index 00000000..2a1ee613 --- /dev/null +++ b/.github/workflows/pr-dependencies.yml @@ -0,0 +1,58 @@ +name: PR dependencies + +# The "Issue dependencies" check: fails while the pull request links an issue that is still blocked (Fixes / Closes / +# Resolves / Refs #N in its title, description or commits, or a Development-sidebar link), unless GitHub lists every +# open blocker as closed by the same pull request or a maintainer has labelled it `dependency-override`. Rules: +# tools/issue_dependencies.py and CONTRIBUTING.md, "Dependencies between issues". The "Issue dependencies" workflow +# re-runs this check whenever its result no longer matches the issues. +# +# Why `pull_request_target`. A `pull_request` run takes this file from the pull request itself, so a pull request +# could rewrite the check that judges it. `pull_request_target` takes the workflow and the script from the default +# branch instead. Its known risk is running the pull request's code with a privileged token; this job never does: +# the checkout has no `ref`, so it reads one file from the default branch; nothing from the pull request is checked +# out, built or executed; the token is read-only; no secret but GITHUB_TOKEN is used; and the script treats the +# description as data. tests/test_issue_dependencies.py pins these properties: +# - `pull_request_target` only, no `paths:` or `branches:` filter (a required check must always report); +# - the token holds `contents: read`, `issues: read` and `pull-requests: read`, nothing else; +# - the checkout has no `ref`, takes one file, keeps no credentials and is pinned to a full commit SHA, and no +# expression in this file names the pull request's head; +# - no `${{ }}` appears in a `run:` line; the script reads the event file itself. +# What it cannot stop: a pull request may add a different workflow with a job of the same name, and branch +# protection cannot tell two GitHub Actions checks of one name apart (true of every required Actions check), so a +# maintainer reads every change under `.github/` and `tools/` before merging it. +# +# Prerequisite: from 2 November 2026 GitHub blocks `pull_request_target` in public repositories unless an Actions +# policy allows it. This repository's policy (Settings > Actions > Policies) allows it for this file's path only; +# renaming or moving this file needs the policy updated in the same change, or the check stops reporting. +# +# One run per pull request at a time: a newer event cancels the run still going, so a burst of edits costs one run. + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review, labeled, unlabeled] + +permissions: {} + +concurrency: + group: pr-dependencies-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + issue-dependencies: + name: Issue dependencies + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read # the checkout reads one script from the default branch + issues: read # read the linked issues, their "Blocked by" links and the label events + pull-requests: read # read the pull request's current description and labels + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: tools/issue_dependencies.py + sparse-checkout-cone-mode: false + - name: Fail if a linked issue is still blocked + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 tools/issue_dependencies.py check-pr diff --git a/AGENTS.md b/AGENTS.md index 1b8fd74a..765791bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ ForgeLM/ │ # export, inference, results, utils, __main__, │ # _pypdf_normalise, _script_sanity, │ # _strip_pattern -├── tests/ # 131 test modules; count grows over time (run `ls tests/test_*.py | wc -l` for current) +├── tests/ # 132 test modules; count grows over time (run `ls tests/test_*.py | wc -l` for current) ├── tools/ # CI guards: check_anchor_resolution, │ # check_bilingual_parity, check_cli_help_consistency, │ # check_field_descriptions, check_no_analysis_refs, @@ -387,6 +387,12 @@ Work on ForgeLM is planned and tracked on GitHub, not in local notes: security vulnerability — never a public issue; it goes to a private advisory as [SECURITY.md](SECURITY.md) describes. - **Closing is not triage.** Every item is an issue; which issues appear on the front door is decided by labels and views, never by closing them. +- **Dependencies are links, not prose.** When an issue cannot start before + another is done, add a **Blocked by** link in its sidebar. The `blocked` + label, `/assign` and the **Issue dependencies** pull-request check + all follow those links (`tools/issue_dependencies.py`; CONTRIBUTING.md, + *Dependencies between issues*). Never set or remove `blocked` by hand, and do + not start work on a `blocked` issue. - **Name what you resolved.** For a grouped issue, list in the pull request the finding IDs your change resolves, so each can be ticked. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7680d5c4..ebb450a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ All notable changes to ForgeLM are documented here. when nobody holds the issue, refuses an issue that already has an assignee without changing it, allows at most 2 open assignments per contributor (maintainers are exempt) and leaves `security`, `needs-triage`, `deferred`, epic and Phase 16 issues to a maintainer. Reassigning stays a maintainer's decision. +- Dependencies between issues are tracked and gated. A dependency is a native "Blocked by" link; the + `Issue dependencies` workflow (`tools/issue_dependencies.py`) labels every open issue with an unresolved blocker + `blocked` and removes the label, with one comment, once every blocker is closed as completed. A blocker closed as + not planned or as a duplicate keeps the issue blocked and flags it `needs-triage`. `/assign` refuses a blocked + issue, and the new `Issue dependencies` pull-request check fails a pull request that links one (`Fixes` / + `Closes` / `Resolves` / `Refs` in its title, description or commits, or a *Development* link), unless GitHub will + close the blockers with the same pull request or a maintainer sets `dependency-override`. The check runs from the + default branch (`pull_request_target`; no pull-request code is checked out), and the workflow re-runs it on every + open pull request whose result no longer matches. It is advisory until branch protection lists it (#1034). - `GOVERNANCE.md` and `MAINTAINERS.md` at the repository root, recording how decisions are made, how the maintainer roles work, and which areas the current maintainer owns (#964). diff --git a/CLAUDE.md b/CLAUDE.md index b934017e..8e30b884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ ForgeLM/ │ # export, inference, results, utils, __main__, │ # _pypdf_normalise, _script_sanity, │ # _strip_pattern -├── tests/ # 131 test modules; count grows over time (run `ls tests/test_*.py | wc -l` for current) +├── tests/ # 132 test modules; count grows over time (run `ls tests/test_*.py | wc -l` for current) ├── tools/ # CI guards: check_anchor_resolution, │ # check_bilingual_parity, check_cli_help_consistency, │ # check_field_descriptions, check_no_analysis_refs, @@ -387,6 +387,12 @@ Work on ForgeLM is planned and tracked on GitHub, not in local notes: security vulnerability — never a public issue; it goes to a private advisory as [SECURITY.md](SECURITY.md) describes. - **Closing is not triage.** Every item is an issue; which issues appear on the front door is decided by labels and views, never by closing them. +- **Dependencies are links, not prose.** When an issue cannot start before + another is done, add a **Blocked by** link in its sidebar. The `blocked` + label, `/assign` and the **Issue dependencies** pull-request check + all follow those links (`tools/issue_dependencies.py`; CONTRIBUTING.md, + *Dependencies between issues*). Never set or remove `blocked` by hand, and do + not start work on a `blocked` issue. - **Name what you resolved.** For a grouped issue, list in the pull request the finding IDs your change resolves, so each can be ticked. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba4fa056..bff67b65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Thanks for your interest in contributing! ForgeLM is an open-source project and 6. A new contributor's first workflow run may wait for a maintainer to approve it; this is normal [GitHub workflow approval](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/approve-runs-from-forks). -[good-first-issue]: https://github.com/HodeTech/ForgeLM/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22good+first+issue%22 +[good-first-issue]: https://github.com/HodeTech/ForgeLM/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee+label%3A%22good+first+issue%22+-label%3Ablocked ## Ways to Contribute @@ -68,7 +68,7 @@ Thanks for your interest in contributing! ForgeLM is an open-source project and Work is tracked in two places that play different roles: - **Issues are where the work happens.** Each issue is one unit of work: it describes the problem, links the code, and lists acceptance criteria. Discussion, claiming and pull-request links all happen on the issue. -- **The [ForgeLM Project Plan](https://github.com/orgs/HodeTech/projects/5) is where the work is planned.** It shows every issue with its priority, release target and iteration, in several views: *Backlog* (table), *Board* (by status), *Current iteration* and *Roadmap* (timeline), plus filtered views such as *Front door*, *Small tasks*, *Epics*, *Docs & TR mirror* and *Roadmap — Phase 16 & deferred*. Use it to choose what to work on; you never need to edit it — maintainers keep it current, and a card moves automatically when its issue is closed. +- **The [ForgeLM Project Plan](https://github.com/orgs/HodeTech/projects/5) is where the work is planned.** It shows every issue with its priority, release target and iteration, in several views: *Backlog* (table), *Board* (by status), *Current iteration* and *Roadmap* (timeline), plus filtered views such as *Front door*, *Small tasks*, *Epics*, *Docs & TR mirror*, *Roadmap — Phase 16 & deferred* and *Blocked*. Use it to choose what to work on; you never need to edit it — maintainers keep it current, and a card moves automatically when its issue is closed. ### Planned work lives here too @@ -93,6 +93,8 @@ Issues carry labels, and the project adds planning fields on top of them. | `source: roadmap` | Planned work recorded in [`docs/roadmap/`](docs/roadmap/): a roadmap step or a deferred item | | `phase: 16` | Part of [Phase 16](docs/roadmap/phase-16-trust-surface-hardening.md): one of its remaining steps, one of its two prerequisite standards, or the phase epic. Deferred items are not part of the phase and do not carry it | | `deferred` | Waits for a condition recorded in the issue; check that it holds before starting. A deferred item is labelled `source: roadmap` and `deferred` | +| `blocked` | Waits for another issue, listed under *Blocked by* in the sidebar. Set and removed automatically, never by hand; do not start it — see [Dependencies between issues](#dependencies-between-issues) | +| `dependency-override` | On a pull request: a maintainer accepts that it works on a blocked issue | | `epic` | A parent issue that groups one theme or roadmap phase; its sub-issues are the work | In the project, the **Wave**, **Severity**, **Theme**, **Kind**, **Area**, **Size** (effort: XS–XL) and **Iteration** fields let you filter and group the same issues. **Milestones** are the waves: waves 0–2 carry a target date at the end of their last iteration, and waves 3–4 are open-ended backlog. @@ -111,6 +113,7 @@ Useful starting points: - `/assign` and `/unassign` act on the person who writes them, and the command must be the first thing in the comment; they cannot name someone else. If two people claim an issue in the same second, the maintainer's claim wins, and between two contributors one claim is released again and its author is told so. - Anything the automation does not cover stays a maintainer's call: an issue labelled `help wanted` without `good first issue` (grouped sweeps are taken one finding ID at a time, see below), an issue labelled `needs-triage`, `deferred`, `security`, `epic` or `phase: 16`, and a request for more than 2 open issues. Comment on the issue, say what you would take on and mention a maintainer ([MAINTAINERS.md](MAINTAINERS.md)). A plain comment such as "I would like to work on this" is read by a maintainer too, who assigns it by hand. - Skip issues labelled `needs-triage`, and check the recorded condition of an issue labelled `deferred` before you start. + - Skip issues labelled `blocked` until the label is gone: they wait for another issue, and `/assign` refuses them whatever their other labels (see [Dependencies between issues](#dependencies-between-issues)). 2. **Re-check against `development`.** Issues from the review link code at the commit they were found on; the links stay valid, but line numbers may have moved. Confirm the problem still exists on `development` before fixing it. 3. **Follow the acceptance criteria** in the issue. In particular, a code fix carries a regression test that reproduces the described failure, and a documentation fix updates the English page and its Turkish mirror (`-tr.md`, or the `tr/` tree for user manuals) in the same pull request. 4. **Reference the issue in your pull request.** Use `Fixes #123` when the PR resolves the whole issue, and `Refs #123` when it resolves only part of it. @@ -121,6 +124,21 @@ Useful starting points: **Finding IDs.** Issues created from the review name their findings with stable IDs — `CR-…` for code, `DOC-…` for documentation (including the Turkish mirror), `TECH-…` for dependency and ecosystem currency. Quote the ID in commits and pull requests so the finding can be traced. +### Dependencies between issues + +Some issues can only be done after another one: each Phase 16 step waits for the step before it, a deferred item may wait for the step that changes the same file, and a review issue whose every finding is a unit of a Phase 16 step waits for that step, whose pull request fixes it. Such a dependency is recorded as a **Blocked by** link in the issue's sidebar (*Relationships*), never only in prose. + +- **An issue with an open blocker is labelled `blocked`.** Do not start on it and do not open a pull request for it: `/assign` refuses it, and the **Issue dependencies** pull-request check fails a pull request that links it — with `Fixes`, `Closes`, `Resolves` or `Refs #…` in the title, the description or any commit message, or through GitHub's *Development* sidebar. If your pull request only mentions such an issue for context, drop the keyword. The check reads at most 50 linked issues, 100 commits and 50 closing references; a pull request over those limits fails until it is split or a maintainer accepts it. +- **The label is automatic.** Nobody sets or removes it by hand. The *Issue dependencies* workflow adds it while a blocker is open and removes it once every blocker is closed as completed — as soon as that blocker is closed, or within an hour of a link being added or removed. When it removes the label it says so in a comment on the issue. Within the same hour it also re-runs the check of every open pull request whose result no longer matches, so a red check turns green once its blockers are completed, and a green one turns red if a linked issue becomes blocked. +- **A blocker closed as not planned or as a duplicate unblocks nothing**, because the work the issue waits for was not done. The issue stays `blocked`, gets `needs-triage`, and a maintainer re-plans it: removes the link, or links the issue that replaces the blocker. Once the dependency is re-planned the label goes, but the issue can be picked up only after a maintainer also removes `needs-triage`. +- **A pull request may close the blocker as well.** When it closes every open blocker of the issues it links, the check passes. What counts is GitHub's own list of issues the pull request will close — the *Development* sidebar — and only for a pull request into `development`, where GitHub closes on merge. Use one keyword per issue (`Fixes #12, fixes #13`): GitHub closes only the first issue of `Fixes #12, #13`. +- **A maintainer can accept a dependency deliberately** with the `dependency-override` label on the pull request. The check then passes and lists what it accepted; the label set by anyone not listed in [MAINTAINERS.md](MAINTAINERS.md) is ignored. The override covers the pull request as it stands, so read it again before merging if it changed after the label was set. +- **Grouped issues are not blocked as a whole** when only some of their findings belong to a Phase 16 step. Those findings say so in the checklist (`KNOWN-OPEN … (S8 open)`): leave them to the step and take the others. + +The project's *Blocked* view lists every blocked issue; *Front door* and *Small tasks* leave them out. + +**For maintainers.** Record a dependency with a *Blocked by* link and let the label follow; to apply a change at once instead of within the hour, run the *Issue dependencies* workflow by hand (Actions > *Issue dependencies* > *Run workflow*). To stop the automation, disable either workflow in the Actions tab; the links stay. The **Issue dependencies** check runs on `pull_request_target`, which GitHub blocks in public repositories from 2 November 2026 unless an Actions policy allows it: the repository's policy (Settings > Actions > Policies) allows it for `.github/workflows/pr-dependencies.yml` only, so moving or renaming that file needs the policy updated too, or the check stops reporting. + ## Reporting Security Vulnerabilities Please **do not open a public issue** for a security vulnerability, and do not post exploit details in comments. Report it privately through GitHub: on the repository's **Security** tab choose **Report a vulnerability**. [`SECURITY.md`](SECURITY.md) explains what to include, what counts as a vulnerability, and how disclosure works. Maintainers track confirmed vulnerabilities in private security advisories and publish them after a fixed release is available, so some planned security work is intentionally not visible in the public issue list. @@ -238,7 +256,7 @@ keep this list and that one in sync if either changes. Push your branch to your fork and open a Pull Request against `development` (the default base). Link the issue it addresses (`Fixes #123`, or `Refs #123` for part of a grouped issue) and quote any finding IDs — see [Working on an issue](#working-on-an-issue). -**Before a pull request can merge**, branch protection on `development` (and on `main`) requires three things: the six CI checks — `lint`, `test (3.10)`, `test (3.11)`, `test (3.12)`, `test (3.13)` and `validate` — passing; the branch up to date with its target (use *Update branch*); and every review conversation resolved, including the comments of the automated reviewers. +**Before a pull request can merge**, branch protection on `development` (and on `main`) requires three things: the six CI checks — `lint`, `test (3.10)`, `test (3.11)`, `test (3.12)`, `test (3.13)` and `validate` — passing; the branch up to date with its target (use *Update branch*); and every review conversation resolved, including the comments of the automated reviewers. The **Issue dependencies** check ([Dependencies between issues](#dependencies-between-issues)) runs on every pull request too, but it is not yet among the required checks: a maintainer adds it to the branch protection of `development` once it has run on a pull request after this change (tracked in [#1034](https://github.com/HodeTech/ForgeLM/issues/1034)). Until then a red result is a signal, not a merge block. Maintainers **squash contributor PRs into `development` by default**. Releases into `main` and synchronization PRs from `main` back into `development` use **merge @@ -254,7 +272,7 @@ ForgeLM is a single-package layout: a mix of single-file modules and five focused sub-packages (`forgelm/cli/` post-Phase-15 split, `forgelm/data_audit/` post-Phase-14 split, `forgelm/wizard/` from Phase 22, `forgelm/safety/` from the post-v0.9.1 split and `forgelm/verify/` from -Phase 16 S1) under `forgelm/`, 131 test files +Phase 16 S1) under `forgelm/`, 132 test files under `tests/` (collected-test count grows over time — run `pytest --collect-only -q` for current), plus `configs/`, `docs/`, `tools/` (CI guards), and `notebooks/`. For the authoritative module-by-module map diff --git a/README.md b/README.md index e19d295a..aa96c28b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ and an audit trail. Start with SFT, use preference or reasoning methods when you them, and opt into safety evaluation, human approval, and EU AI Act documentation. The same configuration works locally and in a CI/CD pipeline. -**Here to contribute?** Pick an [unassigned good first issue](https://github.com/HodeTech/ForgeLM/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22%20no%3Aassignee) +**Here to contribute?** Pick an [unassigned good first issue](https://github.com/HodeTech/ForgeLM/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22%20no%3Aassignee%20-label%3Ablocked) and follow [your first pull request](https://github.com/HodeTech/ForgeLM/blob/development/CONTRIBUTING.md#your-first-pull-request). Documentation, Turkish translations, examples, and tests are welcome alongside code. @@ -181,7 +181,7 @@ translate an example, reproduce a bug, add a regression test, or work on a plann feature. Start small; you do not need a GPU to run the unit tests or documentation checks. 1. Start at the [Front door](https://github.com/orgs/HodeTech/projects/5/views/16): the open, unassigned `good first issue`s - with their size and area. You can also [search by label](https://github.com/HodeTech/ForgeLM/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22%20no%3Aassignee). + with their size and area. You can also [search by label](https://github.com/HodeTech/ForgeLM/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22%20no%3Aassignee%20-label%3Ablocked). The rest of the planned work is in the [project plan](https://github.com/orgs/HodeTech/projects/5). 2. Follow [your first pull request](https://github.com/HodeTech/ForgeLM/blob/development/CONTRIBUTING.md#your-first-pull-request) to set up the environment and run the relevant checks. diff --git a/docs/reference/architecture-tr.md b/docs/reference/architecture-tr.md index 5da4fea3..344b9b89 100644 --- a/docs/reference/architecture-tr.md +++ b/docs/reference/architecture-tr.md @@ -96,7 +96,7 @@ ForgeLM/ │ ├── deepspeed/ # ZeRO-2, ZeRO-3, ZeRO-3+Offload ön ayarları │ └── safety_prompts/ # Yerleşik adversarial prompt kütüphanesi (140 prompt, 6 kategori) ├── notebooks/ # 10 Colab-uyumlu Jupyter notebook -├── tests/ # 131 test modülü +├── tests/ # 132 test modülü ├── tools/ # CI guard'ları: bilingual_parity, anchor_resolution, │ # cli_help_consistency, yaml_snippets, │ # audit_event_catalog, library_api_doc, diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index e3872c43..34fa443c 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -96,7 +96,7 @@ ForgeLM/ │ ├── deepspeed/ # ZeRO-2, ZeRO-3, ZeRO-3+Offload presets │ └── safety_prompts/ # Built-in adversarial prompt library (140 prompts, 6 categories) ├── notebooks/ # 10 Colab-ready Jupyter notebooks -├── tests/ # 131 test modules +├── tests/ # 132 test modules ├── tools/ # CI guards: bilingual_parity, anchor_resolution, │ # cli_help_consistency, yaml_snippets, │ # audit_event_catalog, library_api_doc, diff --git a/docs/standards/code-review.md b/docs/standards/code-review.md index e5aef03b..a02f321e 100644 --- a/docs/standards/code-review.md +++ b/docs/standards/code-review.md @@ -13,6 +13,9 @@ A PR merges only after: 3. **Self-checklist complete** — every box in the PR template ticked honestly. 4. **Every review conversation resolved** — branch protection enforces this on `development` and `main`, including the comments of the automated reviewers. +5. **No linked issue is blocked** — the **Issue dependencies** check passes, or a maintainer accepted the dependency + with `dependency-override` ([CONTRIBUTING.md, "Dependencies between issues"](../../CONTRIBUTING.md#dependencies-between-issues)). + Until branch protection lists that check, a red result does not disable the merge button, so read it. No exceptions for "trivial" changes. Even typo fixes go through CI. diff --git a/site/index.html b/site/index.html index af90addc..dce4c389 100644 --- a/site/index.html +++ b/site/index.html @@ -686,7 +686,7 @@

Contribute to ForgeLM

Start with the first pull request guide and an unassigned good first issue. Documentation, translations, tests, and small fixes all help.

Your first pull request - Browse good first issues + Browse good first issues
diff --git a/tests/test_assign_on_comment.py b/tests/test_assign_on_comment.py index c6931ab8..12fc02ea 100644 --- a/tests/test_assign_on_comment.py +++ b/tests/test_assign_on_comment.py @@ -53,14 +53,27 @@ def __init__( self.human_comments: List[str] = [] # comments by people (the bot's own are in `comments`) self.assigned_pages: Optional[List[List[Dict[str, Any]]]] = None # overrides the count answer, page by page self.refuse_delete = False # accept the DELETE but keep the assignee + self.open_blockers: Optional[int] = 0 # GitHub's count of open "Blocked by" issues; None drops the summary + self.closed_blockers: List[Optional[str]] = [] # the state_reason of each closed "Blocked by" issue + self.listed_open: Optional[int] = None # open blockers in the list, when it disagrees with the summary self._reads = 0 def _issue(self) -> Dict[str, Any]: - return { + issue: Dict[str, Any] = { "state": self.state, "labels": [{"name": n} for n in self.labels], "assignees": [{"login": a} for a in self.assignees], } + if self.open_blockers is not None: + total = self.open_blockers + len(self.closed_blockers) + issue["issue_dependencies_summary"] = {"blocked_by": self.open_blockers, "total_blocked_by": total} + return issue + + def _get_blockers(self, endpoint: str, payload: Any) -> List[Dict[str, Any]]: + listed = self.open_blockers if self.listed_open is None else self.listed_open + return [{"state": "open", "state_reason": None}] * (listed or 0) + [ + {"state": "closed", "state_reason": reason} for reason in self.closed_blockers + ] def __call__(self, method: str, endpoint: str, payload: Any = None) -> Any: self.calls.append((method, endpoint, payload)) @@ -79,6 +92,8 @@ def _route(self, method: str, endpoint: str): return self._get_issue if "?assignee=" in endpoint: return self._get_assigned_issues + if endpoint.startswith(f"repos/{REPO}/issues/7/dependencies/blocked_by?"): + return self._get_blockers if "/comments?" in endpoint: return self._get_comments elif endpoint.endswith("/assignees"): @@ -339,6 +354,78 @@ def test_a_closed_issue_cannot_be_claimed() -> None: assert gh.assignees == [] and gh.comments == [tool.MSG_CLOSED] +# --- dependencies --------------------------------------------------------------------------------------------- + + +def test_a_blocked_good_first_issue_cannot_be_claimed_and_says_why_without_pinging() -> None: + gh = FakeGitHub(labels=["good first issue", "blocked"]) + assert run(gh) == 0 + assert gh.assignees == [] and gh.writes() == [] + assert gh.comments == [tool.MSG_BLOCKED.format(front_door=tool.FRONT_DOOR_URL)] + assert tool.MAINTAINER_MENTION not in gh.comments[0] # nothing to decide: it opens by itself + + +def test_an_open_blocker_refuses_the_claim_before_the_label_has_caught_up() -> None: + gh = FakeGitHub(labels=["good first issue"]) + gh.open_blockers = 1 # linked a minute ago; the hourly sync has not labelled it yet + assert run(gh) == 0 + assert gh.assignees == [] and gh.comments[0].startswith("This issue is blocked") + + +def test_blockers_that_are_all_closed_as_completed_do_not_refuse_the_claim() -> None: + gh = FakeGitHub(labels=["good first issue"]) + gh.closed_blockers = ["completed", None] # None: closed before GitHub recorded reasons + assert run(gh) == 0 + assert gh.assignees == ["newcomer"] + + +@pytest.mark.parametrize("reason", ["not_planned", "duplicate"]) +def test_a_blocker_closed_without_being_completed_refuses_before_the_label_has_caught_up(reason: str) -> None: + gh = FakeGitHub(labels=["good first issue"]) + gh.closed_blockers = ["completed", reason] # the summary counts neither as open + assert run(gh) == 0 + assert gh.assignees == [] and gh.comments[0].startswith("This issue is blocked") + + +def test_the_blocker_list_is_read_only_when_the_summary_cannot_decide() -> None: + labelled = FakeGitHub(labels=["good first issue", "blocked"]) + labelled.closed_blockers = ["not_planned"] # the label already refuses + no_closed = FakeGitHub(labels=["good first issue"]) # nothing closed to look up + for gh in (labelled, no_closed): + run(gh) + assert not [c for c in gh.calls if "/dependencies/" in c[1]] + + +def test_without_a_dependency_summary_the_blocker_list_decides() -> None: + free, cancelled = FakeGitHub(labels=["good first issue"]), FakeGitHub(labels=["good first issue"]) + free.open_blockers = cancelled.open_blockers = None + cancelled.closed_blockers = ["not_planned"] + assert run(free) == 0 and free.assignees == ["newcomer"] + assert run(cancelled) == 0 and cancelled.assignees == [] + assert [c for c in free.calls if "/dependencies/" in c[1]] # read, not assumed free + + +def test_the_list_is_counted_on_its_own_when_it_disagrees_with_the_summary() -> None: + gh = FakeGitHub(labels=["good first issue"]) + gh.closed_blockers = ["completed"] # the summary: no open blocker, one closed + gh.listed_open = 1 # the list, read a moment later: a blocker was reopened + assert run(gh) == 0 and gh.assignees == [] + + +def test_a_blocked_issue_can_still_be_released() -> None: + gh = FakeGitHub(labels=["good first issue", "blocked"], assignees=["first"]) + assert run(gh, "/unassign", login="first") == 0 + assert gh.assignees == [] + + +def test_unassign_never_reads_the_dependencies_so_a_failing_read_cannot_stop_it() -> None: + gh = FakeGitHub(labels=["good first issue"], assignees=["first"]) + gh.closed_blockers = ["not_planned"] + gh.fail[("GET", f"repos/{REPO}/issues/7/dependencies")] = "boom" + assert run(gh, "/unassign", login="first") == 0 + assert gh.assignees == [] and not [c for c in gh.calls if "/dependencies/" in c[1]] + + # --- the per-person limit ------------------------------------------------------------------------------------- diff --git a/tests/test_issue_dependencies.py b/tests/test_issue_dependencies.py new file mode 100644 index 00000000..efbad1bb --- /dev/null +++ b/tests/test_issue_dependencies.py @@ -0,0 +1,916 @@ +"""Tests for tools/issue_dependencies.py and its two workflows. + +The tool derives the ``blocked`` label from GitHub's native "Blocked by" links and fails a pull request that works +on a blocked issue (CONTRIBUTING.md, "Dependencies between issues"). These tests pin the rules, the reading of +pull-request texts written by anyone (including its linear running time), the use of GitHub's own closing list for +exemptions, the write paths of ``sync`` and the pull-request check reconciliation against a fake API (with GitHub's +real error shapes), the fixed-endpoint discipline of the GitHub calls, and the security posture of both workflow +files, since no other guard reads them. +""" + +from __future__ import annotations + +import datetime +import json +import re +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) + +import assign_on_comment # noqa: E402 +import issue_dependencies as tool # noqa: E402 + +REPO = "HodeTech/ForgeLM" +OTHER = "HodeTech/Other" +SHA = "a" * 40 +NOT_FOUND = { + "type": "NOT_FOUND", + "path": ["repository", "issueOrPullRequest"], + "message": "Could not resolve to an issue or pull request with the number of 404.", +} + + +def blocker(number: int, state: str = "OPEN", reason: Optional[str] = None, repo: str = REPO) -> tool.Blocker: + return tool.Blocker(number, repo, state, reason) + + +def issue(number: int, *blockers: tool.Blocker, labels: Tuple[str, ...] = (), is_open: bool = True) -> tool.IssueState: + return tool.IssueState(number, is_open, frozenset(labels), tuple(blockers)) + + +def pull_node( + number: int = 30, + body: str = "", + title: str = "", + labels: Sequence[str] = (), + sha: str = SHA, + base: str = "development", + closing: Sequence[Any] = (), + commits: Sequence[str] = (), + closing_total: Optional[int] = None, + commit_total: Optional[int] = None, +) -> Dict[str, Any]: + """A pull request as the GraphQL queries return it; a closing entry is `n` or `(n, "owner/repo")`.""" + refs = [c if isinstance(c, tuple) else (c, REPO) for c in closing] + return { + "number": number, + "title": title, + "body": body, + "headRefOid": sha, + "baseRefName": base, + "labels": {"totalCount": len(labels), "nodes": [{"name": n} for n in labels]}, + "closingIssuesReferences": { + "totalCount": len(refs) if closing_total is None else closing_total, + "nodes": [{"number": n, "repository": {"nameWithOwner": r}} for n, r in refs], + }, + "commits": { + "totalCount": len(commits) if commit_total is None else commit_total, + "nodes": [{"commit": {"message": m}} for m in commits], + }, + } + + +def facts(**kwargs: Any) -> tool.PullFacts: + return tool._pull_facts(pull_node(**kwargs), "development") + + +def verdict( + links: Dict[int, Optional[Tuple[tool.Blocker, ...]]], override: Optional[bool] = None, **kwargs: Any +) -> tool.Verdict: + return tool.check_pull_request(override, facts(**kwargs), lambda n: links.get(n, ()), REPO) + + +# --- the rules ------------------------------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "state, reason, status", + [ + ("OPEN", None, "waiting"), + ("OPEN", "REOPENED", "waiting"), + ("CLOSED", "COMPLETED", "resolved"), + ("CLOSED", None, "resolved"), # closed before GitHub recorded reasons: closing meant completed + ("CLOSED", "NOT_PLANNED", "not-completed"), + ("CLOSED", "DUPLICATE", "not-completed"), + ], +) +def test_a_blocker_is_resolved_only_when_closed_as_completed(state: str, reason: Optional[str], status: str) -> None: + assert blocker(1, state, reason).status == status + + +def test_an_open_blocker_labels_the_issue_without_a_comment() -> None: + assert tool.plan_issue(issue(5, blocker(1)), REPO) == tool.Plan(add_label=True) + + +def test_an_issue_already_labelled_and_still_waiting_changes_nothing() -> None: + assert not tool.plan_issue(issue(5, blocker(1), labels=("blocked",)), REPO).changes + + +def test_one_open_blocker_among_closed_ones_keeps_the_issue_blocked() -> None: + plan = tool.plan_issue(issue(5, blocker(1, "CLOSED", "COMPLETED"), blocker(2)), REPO) + assert plan.add_label and not plan.remove_label + + +def test_when_every_blocker_is_completed_the_label_goes_and_one_comment_names_them() -> None: + done = (blocker(1, "CLOSED", "COMPLETED"), blocker(9, "CLOSED", "COMPLETED", repo=OTHER)) + plan = tool.plan_issue(issue(5, *done, labels=("blocked", "wave: 3")), REPO) + assert plan.remove_label and not plan.add_label + assert "(#1, HodeTech/Other#9)" in plan.comment and plan.comment.startswith("This issue is no longer blocked") + assert "can be picked up now" in plan.comment + assert plan.marker == "" + assert plan.comment.endswith(plan.marker) + + +def test_an_unblocked_issue_that_still_needs_triage_does_not_say_it_can_be_picked_up() -> None: + plan = tool.plan_issue(issue(5, blocker(1, "CLOSED", "COMPLETED"), labels=("blocked", "needs-triage")), REPO) + assert plan.remove_label and "still carries `needs-triage`" in plan.comment + assert "can be picked up now" not in plan.comment + + +def test_a_hand_set_label_without_links_is_removed_with_the_explanation() -> None: + plan = tool.plan_issue(issue(5, labels=("blocked",)), REPO) + assert plan.remove_label and "set and removed automatically" in plan.comment and plan.marker.endswith(":none -->") + + +def test_an_issue_that_was_never_blocked_is_left_alone() -> None: + assert not tool.plan_issue(issue(5), REPO).changes + assert not tool.plan_issue(issue(5, blocker(1, "CLOSED", "COMPLETED")), REPO).changes + + +def test_a_closed_issue_loses_the_label_silently() -> None: + plan = tool.plan_issue(issue(5, blocker(1), labels=("blocked",), is_open=False), REPO) + assert plan == tool.Plan(remove_label=True) + + +@pytest.mark.parametrize("reason", ["NOT_PLANNED", "DUPLICATE"]) +def test_a_blocker_closed_without_being_completed_keeps_the_issue_blocked_and_asks_for_triage(reason: str) -> None: + gone = blocker(1, "CLOSED", reason) + plan = tool.plan_issue(issue(5, gone, labels=("blocked",)), REPO) + assert not plan.remove_label and plan.not_completed == (gone,) + notice = tool.not_completed_notice(gone, REPO) + assert ("a duplicate" if reason == "DUPLICATE" else "not planned") in notice and notice.startswith("#1, which") + assert notice.endswith("") + assert tool.MAINTAINER_MENTION in notice + + +def test_blockers_in_two_repositories_with_one_number_never_share_a_notice() -> None: + here, there = blocker(7, "CLOSED", "NOT_PLANNED"), blocker(7, "CLOSED", "NOT_PLANNED", repo=OTHER) + a, b = tool.not_completed_notice(here, REPO), tool.not_completed_notice(there, REPO) + assert a.startswith("#7, which") and b.startswith("HodeTech/Other#7, which") + assert a.splitlines()[-1] != b.splitlines()[-1] + + +# --- reading the texts a pull request is made of --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "text, linked", + [ + ("Fixes #12", {12}), + ("fixed #12, closes #13 and resolves #14", {12, 13, 14}), + ("FIXES #12", {12}), + ("Fixes HodeTech/ForgeLM#12", {12}), + ("Fixes #12, #13 and #14", {12, 13, 14}), # every issue of a list counts as linked + ("Fixes#12", {12}), # read leniently: links only make the check stricter + ("Resolves: #12", {12}), + ("Fixes https://github.com/HodeTech/ForgeLM/issues/12", {12}), + ("Refs http://github.com/HodeTech/ForgeLM/issues/12", {12}), + ("Refs #12", {12}), + ("references #12 & #13", {12, 13}), + ("Fixes other/repo#12 and https://github.com/other/repo/issues/13", set()), + ("See #12 for context; prefixes #13 are not keywords; refsfoo/bar#14", set()), + ("Fixes\n#12", set()), # a keyword never reaches across a line + ("Fixes #5abc and refs #7", set()), # a reference is ASCII digits and a whole token + ("\n`Refs #13`\n```\nFixes #14\n```\nRefs #15", {15}), + ], +) +def test_linked_issues(text: str, linked: set) -> None: + assert tool.linked_issues(text, REPO) == frozenset(linked) + + +def test_the_pull_request_template_itself_links_nothing() -> None: + template = (ROOT / ".github/pull_request_template.md").read_text(encoding="utf-8") + assert tool.linked_issues(template, REPO) == frozenset() + + +@pytest.mark.parametrize( + "text, linked", + [ + ("Refs #1\n```\nFixes #2\nRefs #3", {1}), # an unclosed fence runs to the end + ("~~~~\nFixes #2\n~~~\nstill code\n~~~~\nRefs #3", {3}), # a closing fence is at least as long + ("```\nFixes #2\n~~~\nRefs #3", set()), # and of the same character + ("```py `x`\nRefs #3\n```", {3}), # a backtick fence's info string has no backtick: not a fence + (" ```\nFixes #2\nRefs #3", {2, 3}), # four spaces: not a fence opener + ("Refs #1 Refs #3", {1, 3}), + ("Refs #1 ` took about 2 s here + "unclosed fence openers": "```x\n" * 15_000, # the earlier fence regex took about 5 s here + "alternating fences": "```\n" * 15_000, + "mixed fences": "~~~~\n```\n" * 7_500, + "keywords without references": "Fixes " * 10_000 + "#1", + "a keyword and a long space run": "Fixes" + " " * 60_000 + "x", + "a long reference list": "Fixes #1, " * 6_000, + "one backtick run": "`" * 60_000, + "many single backticks": "`a" * 30_000, + "unequal backtick runs": "``a`" * 15_000, + "digit runs that are not tokens": "#" + "1" * 60_000 + "a", + "comments then fences": "```\n" * 5_000, +} + + +@pytest.mark.parametrize("text", HOSTILE.values(), ids=HOSTILE.keys()) +def test_a_hostile_text_is_read_in_linear_time(text: str) -> None: + # docs/standards/regex.md: a generous absolute floor; a quadratic reader blows far past it at this size. + start = time.perf_counter() + tool.linked_issues(text, REPO) + assert time.perf_counter() - start < 1.0 + + +# --- the pull-request rule ------------------------------------------------------------------------------------- + + +def test_a_pull_request_on_a_blocked_issue_fails_and_names_the_blocker() -> None: + v = verdict({5: (blocker(1),)}, body="Fixes #5", closing=[5]) + assert not v.ok and v.lines[0] == "#5 is blocked by #1, which is still open." + assert any(line.startswith("Wait until the blocker is closed as completed") for line in v.lines) + + +def test_refs_counts_as_working_on_the_issue_and_the_message_says_how_to_drop_it() -> None: + v = verdict({5: (blocker(1),)}, body="Refs #5") + assert not v.ok and any("remove the reference" in line for line in v.lines) + + +@pytest.mark.parametrize( + "where", + [{"title": "Refs #5"}, {"commits": ["feat: x\n\nFixes #5"]}, {"closing": [5]}], # a manual Development link + ids=["title", "commit message", "GitHub's closing list"], +) +def test_every_place_a_pull_request_can_link_an_issue_counts(where: Dict[str, Any]) -> None: + assert not verdict({5: (blocker(1),)}, body="Docs only", **where).ok + + +def test_a_blocker_github_will_close_in_the_same_pull_request_is_exempt() -> None: + assert verdict({5: (blocker(1),), 1: ()}, body="Fixes #1\nFixes #5", closing=[1, 5]).ok + + +def test_a_blocker_in_another_repository_is_exempt_when_github_will_close_it() -> None: + there = blocker(7, repo=OTHER) + assert verdict({5: (there,)}, body="Fixes #5", closing=[5, (7, OTHER)]).ok + assert not verdict({5: (there,)}, body="Fixes #5", closing=[5, 7]).ok # #7 here is another issue + + +@pytest.mark.parametrize( + "body", ["Fixes #5\nFixes #1", "Fixes #5\nFixes#1", "Fixes #5, #1", "Fixes #5\n> ~~~\n> Fixes #1\n> ~~~"] +) +def test_only_githubs_closing_list_exempts_a_blocker_never_the_texts(body: str) -> None: + # GitHub lists only #5 as closed by this pull request, whatever the description seems to say about #1. + assert not verdict({5: (blocker(1),), 1: ()}, body=body, closing=[5]).ok + + +def test_off_the_default_branch_nothing_is_exempt_because_github_closes_nothing_on_merge() -> None: + assert not verdict({5: (blocker(1),), 1: ()}, body="Fixes #1\nFixes #5", closing=[1, 5], base="main").ok + + +def test_a_blocker_closed_without_being_completed_fails_and_asks_for_a_re_plan_not_a_wait() -> None: + v = verdict({5: (blocker(1, "CLOSED", "NOT_PLANNED"),)}, body="Fixes #1\nFixes #5", closing=[1, 5]) + assert not v.ok and "re-plan that dependency" in v.lines[0] + assert not any(line.startswith("Wait until") for line in v.lines) + + +def test_completed_blockers_pull_requests_closed_and_missing_issues_pass() -> None: + v = verdict({5: (blocker(1, "CLOSED", "COMPLETED"),), 6: None}, body="Fixes #5, #6") + assert v.ok and v.lines == ("No linked issue is blocked (1 issue(s) checked).",) + + +def test_more_links_than_the_bound_fail_without_a_single_lookup() -> None: + asked: List[int] = [] + body = " ".join(f"Refs #{n}" for n in range(1, tool._MAX_LINKED + 2)) + v = tool.check_pull_request(None, facts(body=body), lambda n: asked.append(n) or (), REPO) + assert not v.ok and "at most 50" in v.lines[0] and asked == [] + accepted = tool.check_pull_request(True, facts(body=body), lambda n: asked.append(n) or (), REPO) + assert accepted.ok and asked == [] + + +@pytest.mark.parametrize("kwargs", [{"commit_total": 101}, {"closing": [5], "closing_total": 51}]) +def test_lists_longer_than_one_page_fail_instead_of_being_read_in_part(kwargs: Dict[str, Any]) -> None: + assert not verdict({}, body="Docs only", **kwargs).ok + assert verdict({}, override=True, body="Docs only", **kwargs).ok + + +def test_a_maintainers_override_passes_and_lists_what_it_accepts() -> None: + v = verdict({5: (blocker(1),)}, override=True, body="Fixes #5") + assert v.ok and "set by a maintainer" in v.lines[0] and v.lines[1] == "#5 is blocked by #1, which is still open." + + +def test_an_override_set_by_anyone_else_is_ignored_and_says_so() -> None: + v = verdict({5: (blocker(1),)}, override=False, body="Fixes #5") + assert not v.ok and any("is ignored" in line for line in v.lines) + + +def label_event(kind: str, login: str, at: str = "2026-10-20T10:00:00Z", label: str = "dependency-override") -> dict: + return {"event": kind, "actor": {"login": login}, "label": {"name": label}, "created_at": at, "id": 1} + + +@pytest.mark.parametrize( + "events, authorized", + [ + ([label_event("labeled", "cemililik")], True), + ([label_event("labeled", "CemilIlik")], True), + ([label_event("labeled", "contributor")], False), + ( + [ + label_event("labeled", "cemililik", "2026-10-20T10:00:00Z"), + label_event("unlabeled", "x", "2026-10-20T11:00:00Z"), + label_event("labeled", "x", "2026-10-20T12:00:00Z"), + ], + False, + ), + ([label_event("labeled", "cemililik"), label_event("unlabeled", "cemililik", "2026-10-20T11:00:00Z")], False), + ([label_event("labeled", "contributor", label="bug"), label_event("labeled", "cemililik")], True), + # out of order: the latest by time decides, not the position in the answer + ([label_event("labeled", "contributor", "2026-10-21T09:00:00Z"), label_event("labeled", "cemililik")], False), + ([], False), + ], +) +def test_the_override_counts_only_when_a_maintainer_set_it_last(events: List[Dict[str, Any]], authorized: bool) -> None: + gh = FakeGitHub({}) + gh.events[30] = events + assert tool.override_authorized(gh, REPO, 30) is authorized + + +# --- a fake GitHub API ----------------------------------------------------------------------------------------- + + +class FakeGitHub: + """Plays the endpoints the tool calls: GraphQL for issues and pull requests, REST for the rest.""" + + def __init__(self, issues: Dict[int, Dict[str, Any]], closed_labelled: Tuple[int, ...] = ()) -> None: + # number -> {"labels": [...], "blockers": [(n, state, reason[, repo]), ...], "state": "OPEN", "type": "Issue"} + self.issues = issues + self.fresh: Dict[int, Dict[str, Any]] = {} # what a re-read of one issue sees, when it differs + self.closed_labelled = list(closed_labelled) + self.calls: List[Tuple[str, str, Any]] = [] + self.comments: Dict[int, List[Dict[str, Any]]] = {} + self.pulls: Dict[int, Dict[str, Any]] = {} + self.check_runs: Dict[str, List[Dict[str, Any]]] = {} + self.events: Dict[int, List[Dict[str, Any]]] = {} + self.fail: List[Tuple[str, str]] = [] # (method, endpoint prefix); `query:` for a GraphQL query + self.issue_page = 100 + + @staticmethod + def _node(number: int, data: Dict[str, Any]) -> Dict[str, Any]: + nodes = [ + {"number": b[0], "state": b[1], "stateReason": b[2], "repository": {"nameWithOwner": (b[3:] or (REPO,))[0]}} + for b in data["blockers"] + ] + return { + "__typename": data.get("type", "Issue"), + "number": number, + "state": data.get("state", "OPEN"), + "labels": { + "totalCount": data.get("label_total", len(data["labels"])), + "nodes": [{"name": n} for n in data["labels"]], + }, + "blockedBy": {"totalCount": len(nodes), "nodes": nodes}, + } + + def pull(self, number: int, body: str = "", **kwargs: Any) -> None: + self.pulls[number] = pull_node(number, body, **kwargs) + + def _graphql(self, payload: Dict[str, Any]) -> Any: + query = next(name for name, text in tool.QUERIES.items() if text == payload["query"]) + if ("POST", f"query:{query}") in self.fail: + raise tool.GhError(f"GraphQL {query} failed") + v = payload["variables"] + if query == "issue": + data = self.fresh.get(v["number"], self.issues.get(v["number"])) + if data is None: + return {"data": {"repository": {"issueOrPullRequest": None}}, "errors": [NOT_FOUND]} + return {"data": {"repository": {"issueOrPullRequest": self._node(v["number"], data)}}} + if query == "open_issues": + numbers = [ + n + for n, i in sorted(self.issues.items()) + if i.get("state", "OPEN") == "OPEN" and i.get("type", "Issue") == "Issue" + ] + start = int(v["after"] or 0) + page = numbers[start : start + self.issue_page] + more = start + self.issue_page < len(numbers) + info = {"hasNextPage": more, "endCursor": str(start + self.issue_page)} + return { + "data": { + "repository": {"issues": {"nodes": [self._node(n, self.issues[n]) for n in page], "pageInfo": info}} + } + } + branch = {"defaultBranchRef": {"name": "development"}} + if query == "pull": + return {"data": {"repository": {**branch, "pullRequest": self.pulls.get(v["number"])}}} + if query == "open_pulls": + nodes = [self.pulls[n] for n in sorted(self.pulls)] + start = int(v["after"] or 0) + info = {"hasNextPage": start + 25 < len(nodes), "endCursor": str(start + 25)} + return { + "data": { + "repository": {**branch, "pullRequests": {"nodes": nodes[start : start + 25], "pageInfo": info}} + } + } + raise AssertionError(query) + + def __call__(self, method: str, endpoint: str, payload: Any = None) -> Any: + self.calls.append((method, endpoint, payload)) + if endpoint == "graphql": + return self._graphql(payload) + if any(m == method and endpoint.startswith(prefix) for m, prefix in self.fail): + raise tool.GhError(f"{method} {endpoint} failed") + number = int(endpoint.split("/")[4]) if re.match(rf"repos/{REPO}/issues/\d+", endpoint) else None + if method == "GET" and endpoint.startswith(f"repos/{REPO}/issues?state=closed&labels=blocked"): + return [{"number": n} for n in self.closed_labelled] + if method == "GET" and "/comments?" in endpoint: + page = int(endpoint.rsplit("page=", 1)[1]) + return self.comments.get(number, [])[(page - 1) * tool._PAGE_SIZE : page * tool._PAGE_SIZE] + if method == "GET" and "/events?" in endpoint: + return self.events.get(number, []) + if method == "POST" and endpoint.endswith("/labels"): + self.issues.setdefault(number, {"labels": [], "blockers": []})["labels"] += payload["labels"] + return [] + if method == "DELETE" and "/labels/" in endpoint: + if number in self.issues: + self.issues[number]["labels"].remove("blocked") + return None + if method == "POST" and endpoint.endswith("/comments"): + self.comments.setdefault(number, []).append({"user": {"type": "Bot"}, "body": payload["body"]}) + return {} + if method == "GET" and "/check-runs?check_name=Issue%20dependencies" in endpoint: + return {"check_runs": self.check_runs.get(endpoint.split("/")[4], [])} + if method == "POST" and re.fullmatch(rf"repos/{REPO}/actions/jobs/\d+/rerun", endpoint): + return None + raise AssertionError(f"unexpected call {method} {endpoint}") + + def writes(self) -> List[Tuple[str, str]]: + return [(m, e) for m, e, _ in self.calls if m in ("POST", "DELETE") and e != "graphql"] + + def reruns(self) -> List[str]: + return [e for _, e in self.writes() if e.endswith("/rerun")] + + +RECENT = "2026-10-20T09:00:00Z" + + +def run( + job_id: int, + conclusion: Optional[str], + status: str = "completed", + app: str = "github-actions", + started: str = RECENT, +) -> dict: + return {"id": job_id, "status": status, "conclusion": conclusion, "app": {"slug": app}, "started_at": started} + + +@pytest.fixture(autouse=True) +def _fixed_clock(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tool, "_now", lambda: datetime.datetime(2026, 10, 20, 12, 0, tzinfo=datetime.timezone.utc)) + + +# --- sync ------------------------------------------------------------------------------------------------------ + + +def test_sync_labels_blocked_issues_and_unblocks_finished_ones() -> None: + gh = FakeGitHub( + { + 5: {"labels": [], "blockers": [(1, "OPEN", None)]}, + 6: {"labels": ["blocked"], "blockers": [(2, "CLOSED", "COMPLETED")]}, + 7: {"labels": ["blocked"], "blockers": [(1, "OPEN", None)]}, + 8: {"labels": [], "blockers": []}, + }, + closed_labelled=(40,), + ) + assert tool.run_sync(REPO, gh) == 0 + assert ( + gh.issues[5]["labels"] == ["blocked"] and gh.issues[6]["labels"] == [] and gh.issues[7]["labels"] == ["blocked"] + ) + assert set(gh.comments) == {6} and gh.comments[6][0]["body"].startswith("This issue is no longer blocked") + assert ("DELETE", f"repos/{REPO}/issues/40/labels/blocked") in gh.writes() # a closed issue waits for nothing + assert 40 not in gh.comments + + +def test_every_page_of_open_issues_is_read() -> None: + gh = FakeGitHub({n: {"labels": [], "blockers": [(1, "OPEN", None)]} for n in range(10, 15)}) + gh.issue_page = 2 + assert tool.run_sync(REPO, gh) == 0 + assert all(gh.issues[n]["labels"] == ["blocked"] for n in range(10, 15)) + + +def test_a_dry_run_reads_but_never_writes(capsys: pytest.CaptureFixture[str]) -> None: + gh = FakeGitHub({5: {"labels": [], "blockers": [(1, "OPEN", None)]}, 6: {"labels": ["blocked"], "blockers": []}}) + gh.pull(30, "Fixes #5") + gh.check_runs[SHA] = [run(77, "success")] + assert tool.run_sync(REPO, gh, dry_run=True) == 0 + out = capsys.readouterr().out + assert gh.writes() == [] and "would label #5 blocked" in out and "would re-run" in out + + +def test_a_blocker_reopened_since_the_snapshot_does_not_unblock_the_issue() -> None: + gh = FakeGitHub({6: {"labels": ["blocked"], "blockers": [(2, "CLOSED", "COMPLETED")]}}) + gh.fresh[6] = {"labels": ["blocked"], "blockers": [(2, "OPEN", "REOPENED")]} + assert tool.run_sync(REPO, gh) == 0 + assert gh.writes() == [] and gh.issues[6]["labels"] == ["blocked"] + + +def test_the_notice_goes_first_so_a_failed_label_removal_is_finished_without_a_second_notice() -> None: + gh = FakeGitHub({6: {"labels": ["blocked"], "blockers": [(2, "CLOSED", "COMPLETED")]}}) + gh.fail = [("DELETE", f"repos/{REPO}/issues/6/labels")] + assert tool.run_sync(REPO, gh) == 1 + assert len(gh.comments[6]) == 1 and gh.issues[6]["labels"] == ["blocked"] + gh.fail = [] + assert tool.run_sync(REPO, gh) == 0 + assert len(gh.comments[6]) == 1 and gh.issues[6]["labels"] == [] + + +def test_one_issue_that_cannot_be_written_stops_neither_the_others_nor_the_reconciliation() -> None: + gh = FakeGitHub( + { + 5: {"labels": [], "blockers": [(1, "OPEN", None)]}, # its label write fails + 6: {"labels": [], "blockers": [(1, "OPEN", None)]}, + } + ) + gh.fail = [("POST", f"repos/{REPO}/issues/5/labels")] + gh.pull(30, "Fixes #6") + gh.check_runs[SHA] = [run(77, "success")] + assert tool.run_sync(REPO, gh) == 1 # reported at the end + assert gh.issues[6]["labels"] == ["blocked"] and gh.reruns() == [f"repos/{REPO}/actions/jobs/77/rerun"] + + +def test_an_issue_whose_links_cannot_be_read_is_reported_and_never_treated_as_free( + capsys: pytest.CaptureFixture[str], +) -> None: + gh = FakeGitHub({5: {"labels": ["blocked"], "blockers": [(1, "OPEN", None)], "label_total": 101}}) + gh.pull(30, "Fixes #5") + gh.check_runs[SHA] = [run(77, "failure")] # red: must not be re-run as if #5 were free + assert tool.run_sync(REPO, gh) == 1 + assert gh.reruns() == [] and "more than 100 labels" in capsys.readouterr().err + + +def test_a_failing_list_of_closed_issues_is_reported_and_the_rest_still_runs() -> None: + gh = FakeGitHub({5: {"labels": [], "blockers": [(1, "OPEN", None)]}}) + gh.fail = [("GET", f"repos/{REPO}/issues?state=closed")] + assert tool.run_sync(REPO, gh) == 1 and gh.issues[5]["labels"] == ["blocked"] + + +def test_a_cancelled_blocker_is_flagged_once_and_a_persons_copy_of_the_marker_does_not_silence_it() -> None: + gh = FakeGitHub({5: {"labels": ["blocked"], "blockers": [(1, "CLOSED", "NOT_PLANNED")]}}) + gh.comments[5] = [ + {"user": {"type": "User"}, "body": tool.NOT_COMPLETED_MARKER.format(blocker="hodetech/forgelm#1")} + ] + assert tool.run_sync(REPO, gh) == 0 + assert gh.issues[5]["labels"] == ["blocked", "needs-triage"] and len(gh.comments[5]) == 2 + assert tool.run_sync(REPO, gh) == 0 # the next run finds the bot's notice and stays quiet + assert len(gh.comments[5]) == 2 and gh.issues[5]["labels"].count("needs-triage") == 1 + + +def test_cancelled_blockers_with_one_number_in_two_repositories_each_get_a_notice() -> None: + gh = FakeGitHub( + {5: {"labels": ["blocked"], "blockers": [(7, "CLOSED", "NOT_PLANNED"), (7, "CLOSED", "DUPLICATE", OTHER)]}} + ) + assert tool.run_sync(REPO, gh) == 0 + bodies = [c["body"] for c in gh.comments[5]] + assert len(bodies) == 2 and bodies[0].startswith("#7,") and bodies[1].startswith("HodeTech/Other#7,") + + +def test_an_endless_comment_thread_is_never_answered_with_a_repeated_notice(capsys: pytest.CaptureFixture[str]) -> None: + gh = FakeGitHub({}) + gh.comments[5] = [{"user": {"type": "User"}, "body": "chat"}] * (tool._PAGE_SIZE * tool._MAX_PAGES) + assert tool._already_noticed(gh, REPO, 5, "") is True + assert "too many comments" in capsys.readouterr().err + + +def test_an_earlier_notice_is_found_on_a_later_page_and_its_absence_is_reported_after_the_last() -> None: + gh = FakeGitHub({}) + chat = [{"user": {"type": "User"}, "body": "chat"}] * (tool._PAGE_SIZE + 50) + gh.comments[5] = chat + [{"user": {"type": "Bot"}, "body": ""}] + gh.comments[6] = chat + assert tool._already_noticed(gh, REPO, 5, "") is True + assert tool._already_noticed(gh, REPO, 6, "") is False + + +# --- re-running the pull-request check when it no longer matches ----------------------------------------------- + + +@pytest.mark.parametrize( + "blockers, last, rerun", + [ + ([(2, "OPEN", None)], run(77, "success"), True), # green, but the linked issue became blocked + ([(2, "OPEN", None)], run(77, "neutral"), True), # branch protection passes `neutral` too + ([(2, "CLOSED", "COMPLETED")], run(77, "failure"), True), # red, but its blocker is completed now + ([(2, "OPEN", None)], run(77, "failure"), False), # red and still blocked + ([(2, "CLOSED", "COMPLETED")], run(77, "success"), False), # green and free + ([(2, "CLOSED", "COMPLETED")], run(77, "skipped"), False), # `skipped` passes as well + ([(2, "CLOSED", "COMPLETED")], run(77, None, status="in_progress"), False), # still running: next sync looks + ([(2, "CLOSED", "COMPLETED")], run(77, "failure", started="2026-09-01T00:00:00Z"), False), # too old to re-run + ], +) +def test_the_sync_reruns_a_check_whose_result_no_longer_matches(blockers: list, last: dict, rerun: bool) -> None: + gh = FakeGitHub({6: {"labels": ["blocked"] if blockers[0][1] == "OPEN" else [], "blockers": blockers}}) + gh.pull(30, "Fixes #6") + gh.check_runs[SHA] = [run(70, "failure"), last] # the latest run is the one with the highest id + assert tool.run_sync(REPO, gh) == 0 + assert gh.reruns() == ([f"repos/{REPO}/actions/jobs/77/rerun"] if rerun else []) + + +def test_checks_from_another_app_and_pull_requests_that_link_nothing_are_left_alone() -> None: + gh = FakeGitHub({6: {"labels": ["blocked"], "blockers": [(2, "OPEN", None)]}}) + gh.pull(30, "Fixes #6") + gh.pull(31, "Docs only", sha="b" * 40) + gh.check_runs[SHA] = [run(77, "success", app="someone-else")] + gh.check_runs["b" * 40] = [run(78, "success")] + assert tool.run_sync(REPO, gh) == 0 + assert gh.reruns() == [] + + +def test_every_page_of_open_pull_requests_is_reconciled() -> None: + gh = FakeGitHub({6: {"labels": ["blocked"], "blockers": [(2, "OPEN", None)]}}) + for n in range(40): + gh.pull(100 + n, "Docs only", sha=f"{n:040x}") + gh.pull(999, "Fixes #6") + gh.check_runs[SHA] = [run(77, "success")] + assert tool.run_sync(REPO, gh) == 0 + assert gh.reruns() == [f"repos/{REPO}/actions/jobs/77/rerun"] + + +def test_a_failing_rerun_is_a_warning_not_a_failed_sync(capsys: pytest.CaptureFixture[str]) -> None: + gh = FakeGitHub({6: {"labels": ["blocked"], "blockers": [(2, "CLOSED", "COMPLETED")]}}) + gh.pull(30, "Fixes #6") + gh.check_runs[SHA] = [run(77, "failure")] + gh.fail = [("POST", f"repos/{REPO}/actions/jobs/77/rerun")] # the listing works; only the re-run fails + assert tool.run_sync(REPO, gh) == 0 + assert gh.issues[6]["labels"] == [] and "warning: could not reconcile" in capsys.readouterr().err + assert ("POST", f"repos/{REPO}/actions/jobs/77/rerun") in [(m, e) for m, e, _ in gh.calls] + + +def test_a_failing_read_of_the_open_issues_fails_the_sync() -> None: + gh = FakeGitHub({}) + gh.fail = [("POST", "query:open_issues")] + with pytest.raises(tool.GhError): + tool.run_sync(REPO, gh) + + +@pytest.mark.parametrize("field, limit", [("blockedBy", "more than 50 blockers"), ("labels", "more than 100 labels")]) +def test_more_links_or_labels_than_one_page_is_an_error_not_a_silent_truncation(field: str, limit: str) -> None: + node = FakeGitHub._node(5, {"labels": ["blocked"], "blockers": [(1, "OPEN", None)]}) + node[field]["totalCount"] = 101 + with pytest.raises(tool.GhError, match=limit): + tool._issue_state(node, True) + + +# --- check-pr -------------------------------------------------------------------------------------------------- + + +def test_check_pr_reads_the_pull_request_again_instead_of_trusting_the_event() -> None: + gh = FakeGitHub({5: {"labels": [], "blockers": [(1, "OPEN", None)]}, 1: {"labels": [], "blockers": []}}) + gh.pull(30, "Fixes #5", closing=[5]) + stale = {"pull_request": {"number": 30, "body": "Docs only", "labels": [{"name": "dependency-override"}]}} + assert tool.run_check_pr(stale, REPO, gh) == 3 + gh.pull(30, "Fixes #5", closing=[5], labels=["dependency-override"]) + gh.events[30] = [label_event("labeled", "cemililik")] + assert tool.run_check_pr(stale, REPO, gh) == 0 + + +def test_check_pr_ignores_an_override_label_a_contributor_set(capsys: pytest.CaptureFixture[str]) -> None: + gh = FakeGitHub({5: {"labels": [], "blockers": [(1, "OPEN", None)]}}) + gh.pull(30, "Fixes #5", labels=["dependency-override"]) + gh.events[30] = [label_event("labeled", "contributor")] + assert tool.run_check_pr({"pull_request": {"number": 30}}, REPO, gh) == 3 + assert "is ignored" in capsys.readouterr().out + + +def test_check_pr_passes_over_pull_requests_closed_issues_and_numbers_that_name_no_issue() -> None: + gh = FakeGitHub( + { + 5: {"labels": [], "blockers": [(1, "OPEN", None)], "type": "PullRequest"}, + 6: {"labels": [], "blockers": [(1, "OPEN", None)], "state": "CLOSED"}, + } + ) + gh.pull(30, "Refs #5, #6 and #404") # #404 names no issue: GitHub answers NOT_FOUND, not a failure + assert tool.run_check_pr({"pull_request": {"number": 30}}, REPO, gh) == 0 + + +def test_check_pr_prints_errors_as_annotations_with_numbers_only(capsys: pytest.CaptureFixture[str]) -> None: + gh = FakeGitHub({5: {"labels": [], "blockers": [(1, "OPEN", None)]}}) + gh.pull(30, "Fixes #5 ::set-output name=x::y") + assert tool.run_check_pr({"pull_request": {"number": 30}}, REPO, gh) == 3 + out = capsys.readouterr().out + assert out.startswith("::error::#5 is blocked by #1") and "set-output" not in out + + +# --- entry point and the GitHub call wrapper ------------------------------------------------------------------- + + +def test_main_rejects_a_missing_repository_a_missing_event_and_a_bad_command( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + gh = FakeGitHub({}) + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + assert tool.main(["sync"], gh) == 2 + monkeypatch.setenv("GITHUB_REPOSITORY", REPO) + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + assert tool.main(["check-pr"], gh) == 2 + event = tmp_path / "event.json" + event.write_text(json.dumps({"pull_request": {"number": "30"}}), encoding="utf-8") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event)) + assert tool.main(["check-pr"], gh) == 2 + event.write_text("{not json", encoding="utf-8") + assert tool.main(["check-pr"], gh) == 2 + assert tool.main(["frobnicate"], gh) == 2 + assert tool.main(["--help"], gh) == 0 + assert gh.calls == [] + + +def test_main_turns_an_api_failure_into_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + gh = FakeGitHub({}) + gh.fail = [("POST", "query:open_issues")] + monkeypatch.setenv("GITHUB_REPOSITORY", REPO) + assert tool.main(["sync"], gh) == 1 + + +@pytest.mark.parametrize( + "method, endpoint, payload", + [ + ("GET", "user", None), + ("PATCH", f"repos/{REPO}/issues/1", {}), + ("GET", f"repos/{REPO}/issues/1 --jq .", None), + ("POST", "graphql", {"query": "mutation { deleteIssue }"}), + ("GET", "graphql", {"query": tool.QUERIES["issue"]}), + ], +) +def test_gh_api_refuses_anything_outside_the_fixed_endpoints(method: str, endpoint: str, payload: Any) -> None: + with pytest.raises(tool.GhError, match="refusing"): + tool.gh_api(method, endpoint, payload) + + +class Proc: + def __init__(self, returncode: int, stdout: str = "", stderr: str = "") -> None: + self.returncode, self.stdout, self.stderr = returncode, stdout, stderr + + +def test_gh_api_runs_a_list_command_without_a_shell(monkeypatch: pytest.MonkeyPatch) -> None: + seen: Dict[str, Any] = {} + + def fake_run(command: List[str], **kwargs: Any) -> Proc: + seen.update(command=command, **kwargs) + return Proc(0, "[]") + + monkeypatch.setattr(tool.subprocess, "run", fake_run) + assert tool.gh_api("GET", f"repos/{REPO}/issues/1") == [] + assert seen["command"] == ["gh", "api", "--method", "GET", f"repos/{REPO}/issues/1"] and "shell" not in seen + + +def test_gh_api_failure_text_is_flattened_and_defanged(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tool.subprocess, "run", lambda *a, **k: Proc(1, "", "bad\n::error::injected")) + with pytest.raises(tool.GhError) as info: + tool.gh_api("GET", f"repos/{REPO}/issues/1") + assert "\n" not in str(info.value) and "::error::" not in str(info.value) + + +def test_gh_api_hands_a_graphql_answer_with_errors_to_the_caller_but_not_a_rest_one( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # `gh` exits 1 for a GraphQL answer with errors and prints the answer itself; graphql() decides what it accepts. + answer = {"data": {"repository": {"issueOrPullRequest": None}}, "errors": [NOT_FOUND]} + monkeypatch.setattr(tool.subprocess, "run", lambda *a, **k: Proc(1, json.dumps(answer), "gh: Could not resolve")) + assert tool.gh_api("POST", "graphql", {"query": tool.QUERIES["issue"], "variables": {}}) == answer + with pytest.raises(tool.GhError): + tool.gh_api("GET", f"repos/{REPO}/issues/1") + + +@pytest.mark.parametrize( + "errors, accepted", + [ + ([NOT_FOUND], True), + ([NOT_FOUND, NOT_FOUND], False), + ([{**NOT_FOUND, "path": ["repository"]}], False), # the repository itself is missing + ([{**NOT_FOUND, "type": "FORBIDDEN"}], False), + ], +) +def test_graphql_accepts_only_the_one_error_a_missing_issue_number_produces(errors: list, accepted: bool) -> None: + answer = {"data": {"repository": {"issueOrPullRequest": None}}, "errors": errors} + if accepted: + assert tool.graphql(lambda *a: answer, "issue", allow_missing=True, owner="o", name="n", number=1) + else: + with pytest.raises(tool.GhError): + tool.graphql(lambda *a: answer, "issue", allow_missing=True, owner="o", name="n", number=1) + with pytest.raises(tool.GhError, match="GraphQL issue failed"): # never without allow_missing + tool.graphql(lambda *a: answer, "issue", owner="o", name="n", number=1) + + +def test_the_maintainer_list_matches_the_assign_tool() -> None: + assert tool.MAINTAINER_LOGINS == assign_on_comment.MAINTAINER_LOGINS # that one is checked against MAINTAINERS.md + + +def test_the_links_in_the_notices_resolve_to_contributing_headings() -> None: + text = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") + headings = { + re.sub(r"[^\w\- ]", "", h).strip().lower().replace(" ", "-") for h in re.findall(r"^#+ (.+)$", text, re.M) + } + for anchor in ("dependencies-between-issues", "working-on-an-issue"): + assert anchor in headings + + +# --- the workflow files ---------------------------------------------------------------------------------------- + + +def load(name: str) -> Dict[Any, Any]: + return yaml.safe_load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8")) + + +def steps_of(workflow: Dict[Any, Any]) -> List[Dict[str, Any]]: + (job,) = workflow["jobs"].values() + return job["steps"] + + +@pytest.mark.parametrize("name", ["issue-dependencies.yml", "pr-dependencies.yml"]) +def test_two_steps_a_pinned_minimal_checkout_and_no_expression_in_a_shell(name: str) -> None: + steps = steps_of(load(name)) + assert len(steps) == 2 + checkout, script = steps + assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}", checkout["uses"]) + assert checkout["with"] == { + "persist-credentials": False, + "sparse-checkout": "tools/issue_dependencies.py", + "sparse-checkout-cone-mode": False, + } # no `ref`: the default branch, never a pull request's code + assert "uses" not in script and "${{" not in script["run"] + assert script["env"] == {"GH_TOKEN": "${{ secrets.GITHUB_TOKEN }}"} + + +def test_the_sync_workflow_triggers_permissions_and_concurrency() -> None: + wf = load("issue-dependencies.yml") + assert wf[True] == { + "issues": {"types": ["closed", "reopened"]}, + "schedule": [{"cron": "17 * * * *"}], + "workflow_dispatch": None, + } # YAML 1.1 reads the key `on` as a boolean + assert wf["permissions"] == {} + job = wf["jobs"]["sync"] + assert job["permissions"] == { + "contents": "read", + "issues": "write", + "pull-requests": "read", + "checks": "read", + "actions": "write", + } + assert wf["concurrency"] == {"group": "issue-dependencies", "cancel-in-progress": False} + assert job["if"] == "${{ github.repository == 'HodeTech/ForgeLM' && github.ref == 'refs/heads/development' }}" + assert steps_of(wf)[1]["run"] == "python3 tools/issue_dependencies.py sync" + + +def test_the_pull_request_check_takes_its_definition_from_the_default_branch_and_never_runs_pr_code() -> None: + wf = load("pr-dependencies.yml") + text = (ROOT / ".github/workflows/pr-dependencies.yml").read_text(encoding="utf-8") + # `pull_request` would take this file from the pull request, which could then rewrite the check that judges it. + assert list(wf[True]) == ["pull_request_target"] + assert wf[True]["pull_request_target"] == { + "types": ["opened", "edited", "reopened", "synchronize", "ready_for_review", "labeled", "unlabeled"] + } # no paths/branches filter: a required check must always report + assert wf["permissions"] == {} + assert wf["concurrency"] == { + "group": "pr-dependencies-${{ github.event.pull_request.number }}", + "cancel-in-progress": True, + } + job = wf["jobs"]["issue-dependencies"] + assert job["name"] == tool.CHECK_NAME == "Issue dependencies" # the required check's context + assert job["permissions"] == {"contents": "read", "issues": "read", "pull-requests": "read"} + code = "\n".join(line.split("#", 1)[0] for line in text.splitlines()) # the YAML without its comments + assert "head" not in code and code.count("github.event") == 1 # only the pull request's number, for concurrency + assert code.count("secrets.") == 1 # GITHUB_TOKEN only + assert steps_of(wf)[1]["run"] == "python3 tools/issue_dependencies.py check-pr" + + +def test_no_other_workflow_has_a_job_named_like_the_check() -> None: + # Branch protection cannot tell two Actions checks of one name apart; keep the name unique in this repository. + owners = [ + path.name + for path in sorted((ROOT / ".github/workflows").glob("*.yml")) + for job in (yaml.safe_load(path.read_text(encoding="utf-8")).get("jobs") or {}).values() + if isinstance(job, dict) and job.get("name") == tool.CHECK_NAME + ] + assert owners == ["pr-dependencies.yml"] diff --git a/tools/assign_on_comment.py b/tools/assign_on_comment.py index 277597c7..112e69ff 100644 --- a/tools/assign_on_comment.py +++ b/tools/assign_on_comment.py @@ -9,6 +9,9 @@ - only issues labelled ``good first issue`` are open to ``/assign``, and never one that also carries ``security``, ``needs-triage``, ``deferred``, ``epic`` or ``phase: 16``; +- a blocked issue is refused: one labelled ``blocked``, or one with a "Blocked by" issue that is still open or was + closed without being completed (the label follows the links within the hour, so reading them closes the gap; + ``tools/issue_dependencies.py``); - an issue that already has an assignee is refused, whoever the assignee is and however they were assigned: a later ``/assign`` never replaces, adds to or removes an existing assignment; - a contributor holds at most ``MAX_OPEN_ASSIGNMENTS`` open issues at a time (the maintainers in @@ -56,6 +59,7 @@ # lock the rest), or larger tasks that need a maintainer's answer first. ELIGIBLE_LABELS = frozenset({"good first issue"}) EXCLUDED_LABELS = frozenset({"security", "needs-triage", "deferred", "epic", "phase: 16"}) +BLOCKED_LABEL = "blocked" # set and removed by tools/issue_dependencies.py from the issue's "Blocked by" links # The maintainers, as MAINTAINERS.md lists them (tests/test_assign_on_comment.py fails if the two drift apart). # GitHub's own "author association" is not used: it also calls a read-only organisation member a MEMBER. MAINTAINER_LOGINS = frozenset({"cemililik"}) @@ -74,6 +78,12 @@ NOT_OPEN_MARKER = "" MSG_CLOSED = "This issue is closed, so it cannot be assigned." +MSG_BLOCKED = ( + "This issue is blocked: it waits for another issue (see **Blocked by** in the sidebar) to be completed, or for a " + "maintainer to re-plan a dependency whose blocker was closed without being completed, so it cannot be claimed " + "yet. The `blocked` label is removed automatically once that is done; check this issue again then. Until then, " + "pick another one from the [Front door view]({front_door})." +) MSG_NOT_OPEN = ( "Only `good first issue` issues can be claimed with `/assign`; this one needs a maintainer's decision. " "{mention}, could you take a look?\n\n" + NOT_OPEN_MARKER @@ -154,6 +164,7 @@ class IssueFacts: state: str labels: frozenset[str] assignees: tuple[str, ...] + unresolved_blockers: int = 0 # "Blocked by" issues still open or closed without being completed @dataclass(frozen=True) @@ -222,6 +233,8 @@ def decide(ctx: Context, issue: IssueFacts, count_open_assignments: Callable[[], return Decision("reply", MSG_NOT_YOURS) if issue.state != "open": return Decision("reply", MSG_CLOSED) + if BLOCKED_LABEL in issue.labels or issue.unresolved_blockers > 0: + return Decision("reply", MSG_BLOCKED.format(front_door=FRONT_DOOR_URL)) if issue.labels & EXCLUDED_LABELS or not issue.labels & ELIGIBLE_LABELS: return Decision("reply", MSG_NOT_OPEN.format(mention=MAINTAINER_MENTION), pings=True) if login in issue.assignees: @@ -302,15 +315,46 @@ def _logins(users: Any) -> tuple[str, ...]: raise GhError("an assignee has no login") from exc -def fetch_issue(gh: GhApi, repo: str, number: int) -> IssueFacts: +def fetch_issue(gh: GhApi, repo: str, number: int, read_blockers: bool = True) -> IssueFacts: + """The issue's facts. ``read_blockers`` is False for ``/unassign``, which never depends on them, so a failing + dependency read can never stop a contributor from giving an issue back.""" raw = gh("GET", f"repos/{repo}/issues/{number}", None) try: labels = frozenset(str(label["name"]) for label in raw["labels"]) - return IssueFacts(str(raw["state"]), labels, _logins(raw["assignees"])) - except (KeyError, TypeError) as exc: + unresolved = 0 + if read_blockers and BLOCKED_LABEL not in labels: # the label already refuses; no need to read further + summary = raw.get("issue_dependencies_summary") + # The summary's `blocked_by` counts only blockers that are still open. A blocker closed as not planned or + # as a duplicate blocks too, so when closed ones exist, or the summary is missing, read the list itself. + if not isinstance(summary, dict): + unresolved = count_unresolved_blockers(gh, repo, number) + else: + unresolved = int(summary.get("blocked_by") or 0) + if int(summary.get("total_blocked_by") or 0) > unresolved: + unresolved = count_unresolved_blockers(gh, repo, number) + return IssueFacts(str(raw["state"]), labels, _logins(raw["assignees"]), unresolved) + except (KeyError, TypeError, ValueError) as exc: raise GhError("the issue answer lacks labels, state or assignees") from exc +def count_unresolved_blockers(gh: GhApi, repo: str, number: int) -> int: + """The issue's "Blocked by" issues that are still open or closed without being completed (not planned, or a + duplicate), counted from the list itself rather than mixed with the summary.""" + found = 0 + for page in range(1, _MAX_PAGES + 1): + raw = gh("GET", f"repos/{repo}/issues/{number}/dependencies/blocked_by?per_page={_PAGE_SIZE}&page={page}", None) + if not isinstance(raw, list): + raise GhError("the blocked-by answer is not a list") + found += sum( + 1 + for b in raw + if isinstance(b, dict) and (b.get("state") != "closed" or b.get("state_reason") not in (None, "completed")) + ) + if len(raw) < _PAGE_SIZE: + break + return found + + def count_open_assignments(gh: GhApi, repo: str, login: str, stop_at: Optional[int] = None) -> int: """Open issues (not pull requests) assigned to ``login`` in this repository, across pages. @@ -388,7 +432,7 @@ def _settle_after_write(ctx: Context, gh: GhApi, assignees: tuple[str, ...], mes def _perform(ctx: Context, gh: GhApi) -> str: """Read, decide, change the assignment. Returns the reply text ('' for none).""" - issue = fetch_issue(gh, ctx.repo, ctx.number) + issue = fetch_issue(gh, ctx.repo, ctx.number, read_blockers=ctx.command == "/assign") counted: list[int] = [] def held() -> int: # asked for at most once per run, and only if the answer depends on it diff --git a/tools/issue_dependencies.py b/tools/issue_dependencies.py new file mode 100644 index 00000000..d4f64df9 --- /dev/null +++ b/tools/issue_dependencies.py @@ -0,0 +1,948 @@ +#!/usr/bin/env python3 +"""Keep the ``blocked`` label in step with GitHub's "Blocked by" links, and stop pull requests on blocked issues. + +Two workflows run this script: + +- ``.github/workflows/issue-dependencies.yml`` runs ``sync`` when an issue is closed or reopened, hourly, and on + demand. It brings the ``blocked`` label of every issue in line with its native "Blocked by" links, and re-runs the + "Issue dependencies" check of every open pull request whose last result no longer matches its linked issues. +- ``.github/workflows/pr-dependencies.yml`` runs ``check-pr`` on every pull-request change. It fails while the pull + request links an issue that is still blocked. + +Rules (documented in ``CONTRIBUTING.md``, "Dependencies between issues"): + +- A dependency is a native "Blocked by" link, set by a maintainer in the issue's sidebar. Prose such as "after #12" + is not one. +- A blocker is resolved only when it is closed as completed. While it is open the dependent waits. When it is closed + as not planned or as a duplicate, the work the dependent waits for was not done: the dependent stays blocked, gets + ``needs-triage`` and one comment asking a maintainer to re-plan it (remove the link, or link the issue that replaces + the blocker). +- ``blocked`` is derived and never set by hand: ``sync`` adds it to every open issue with an unresolved blocker and + removes it everywhere else, from a closed issue and from one where someone set it by hand. When it removes the + label from an open issue it posts one comment; it does not comment when an issue becomes blocked. +- ``check-pr`` fails while the pull request links a blocked issue — unless the same pull request closes every open + blocker, or a maintainer listed in ``MAINTAINER_LOGINS`` has labelled it ``dependency-override`` (the label set by + anyone else is ignored). It reads the links themselves, not the label, so it never trails behind ``sync``. + +Which issues a pull request links. Two sources, merged: + +- *What GitHub will close*: the pull request's ``closingIssuesReferences`` — GitHub's own list (the "Development" + sidebar), built from its closing keywords and manual links. Only this list can exempt a blocker as "closed by the + same pull request", and only when the pull request targets the default branch, where GitHub closes on merge. The + check never guesses closing syntax, so an exemption is never broader than what GitHub will actually do. +- *Everything else it mentions*: ``Fixes`` / ``Closes`` / ``Resolves`` / ``Refs`` references read leniently from the + title, the description and every commit message (a commit message can close an issue too). Reading too many links + can only make the check stricter. Fenced code, inline code and HTML comments are skipped (the template's own + examples live there). + +The check sees what a pull request declares: it cannot see work on an issue nothing links, so it guards against +honest mistakes, and a maintainer's review remains the backstop. At most ``_MAX_LINKED`` linked issues are read; more +fails the check without reading any, so a description cannot spend the repository's API budget. + +Trust boundary. Issue and pull-request text is written by anyone, so the script never executes or prints it: it +parses the text in linear time and writes only fixed messages and issue numbers to the log. The workflows pass +nothing through the shell; the script reads the event file itself. Every subprocess call is a list without a shell, +limited to fixed endpoints (``repos/`` paths built from integers, and ``graphql`` with a query constant from this +module). + +Exit codes (per ``tools/`` contract, not the public 0-6 surface of ``forgelm/``): + +- ``0`` — done: labels in step, or the pull request may merge. +- ``1`` — unexpected failure: an API call failed, or GitHub's answer was not what its API documents. ``sync`` keeps + going after one issue fails, reconciles the pull-request checks anyway, and exits 1 at the end. +- ``2`` — the event payload, the arguments or the environment are unusable. +- ``3`` — ``check-pr`` only: the pull request links an issue that is still blocked. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Any, Callable, Iterable, Mapping, Optional, Sequence +from urllib.parse import quote + +BLOCKED_LABEL = "blocked" +OVERRIDE_LABEL = "dependency-override" +TRIAGE_LABEL = "needs-triage" +CHECK_NAME = "Issue dependencies" # the job name in pr-dependencies.yml, and the required check's context +# Hidden markers that make a notice idempotent: only a bot's comment counts, so a person cannot forge one to silence +# a notice. `{blocker}` / `{blockers}` hold `owner/repo#N` references, so blockers in two repositories never collide. +NOT_COMPLETED_MARKER = "" +UNBLOCKED_MARKER = "" +# Conclusions branch protection accepts as a passing check. +PASSING_CONCLUSIONS = frozenset({"success", "neutral", "skipped"}) + +_EXIT_BLOCKED = 3 +_GH_TIMEOUT_SECONDS = 60 +_PAGE_SIZE = 100 +_MAX_PAGES = 30 # 3,000 items: far above the backlog; bounds every paginated read +_MAX_BLOCKERS = 50 # GitHub shows at most this many "Blocked by" links per issue +_MAX_LABELS = 100 # GitHub allows at most this many labels per issue +_MAX_LINKED = 50 # issues one pull request may link; each costs one API call +_MAX_COMMITS = 100 # commits one pull request may have and still be read in full +_RERUN_DAYS = 30 # GitHub re-runs a workflow run only within this many days +_ALLOWED_METHODS = frozenset({"GET", "POST", "DELETE"}) + +MSG_UNBLOCKED = ( + "This issue is no longer blocked: every issue it waited for is closed as completed ({blockers}). It can be " + "picked up now; see [Working on an issue]({contributing})." +) +MSG_UNBLOCKED_TRIAGE = ( + "This issue is no longer blocked by another issue ({blockers}). It still carries `needs-triage`, so a maintainer " + "has to finish triaging it before it can be picked up." +) +MSG_UNBLOCKED_NO_LINKS = ( + 'This issue is no longer blocked: it has no open "Blocked by" link. The `blocked` label follows those links ' + "and is set and removed automatically; see [Dependencies between issues]({contributing})." +) +MSG_NOT_COMPLETED = ( + "{blocker}, which this issue is blocked by, was closed as {reason}, not as completed, so the work this issue " + "waits for was not done and the issue stays blocked. {mention}, please re-plan it: remove the link if the " + "dependency no longer holds, or link the issue that replaces {blocker}." +) + +MAINTAINER_LOGINS = frozenset({"cemililik"}) # MAINTAINERS.md; tests/test_issue_dependencies.py keeps them equal +MAINTAINER_MENTION = " ".join(f"@{login}" for login in sorted(MAINTAINER_LOGINS)) + +# A reference after a keyword: `#12`, `owner/repo#12` or an issue URL. Every class is explicit ASCII (keywords, +# logins and repository names are ASCII; docs/standards/regex.md rule 1), and a number is a whole token, so `#5abc` +# and `#5` are not references. +_KEYWORD = r"(?Pclose[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|references)" +_OWNER, _NAME, _NUM = r"[A-Za-z0-9-]+", r"[A-Za-z0-9._-]+", r"[0-9]+(?![A-Za-z0-9_])" +_REF_RE = re.compile( + rf"(?:(?P{_OWNER})/(?P{_NAME}))?#(?P{_NUM})" + rf"|https?://github\.com/(?P{_OWNER})/(?P{_NAME})/issues/(?P{_NUM})" +) +_REF_PLAIN = rf"(?:(?:{_OWNER}/{_NAME})?#{_NUM}|https?://github\.com/{_OWNER}/{_NAME}/issues/{_NUM})" +# Horizontal whitespace only (rule 5), and the optional colon owns the whitespace after it, so no two quantifiers +# compete for the same characters (rule 4). +_LINK_RE = re.compile( + rf"(?[ \t]*(?::[ \t]*)?)" + rf"(?P{_REF_PLAIN}(?:[ \t]*(?:,|and|&)[ \t]*{_REF_PLAIN})*)", + re.IGNORECASE, +) +_FENCE_RE = re.compile(r" {0,3}(`{3,}|~{3,})") # matched at the start of one line by the walker below +_BACKTICK_RUN_RE = re.compile(r"`+") # one quantifier over one character: linear + +# Every GraphQL document this script may send. A query is chosen by name; only integer and cursor variables vary. +_ISSUE_FIELDS = """ + labels(first: 100) { totalCount nodes { name } } + blockedBy(first: 50) { totalCount nodes { number state stateReason repository { nameWithOwner } } }""" +_PULL_FIELDS = """ + number title body headRefOid baseRefName + labels(first: 100) { totalCount nodes { name } } + closingIssuesReferences(first: 50) { totalCount nodes { number repository { nameWithOwner } } } + commits(first: 100) { totalCount nodes { commit { message } } }""" +QUERIES = { + "open_issues": """ +query($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + issues(states: OPEN, first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + number""" + + _ISSUE_FIELDS + + """ + } + } + } +}""", + "issue": """ +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issueOrPullRequest(number: $number) { + __typename + ... on Issue { + number + state""" + + _ISSUE_FIELDS + + """ + } + } + } +}""", + "pull": """ +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name } + pullRequest(number: $number) {""" + + _PULL_FIELDS + + """ + } + } +}""", + "open_pulls": """ +query($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name } + pullRequests(states: OPEN, first: 25, after: $after) { + pageInfo { hasNextPage endCursor } + nodes {""" + + _PULL_FIELDS + + """ + } + } + } +}""", +} + + +class EventError(Exception): + """The event payload, the arguments or the environment are not usable.""" + + +class GhError(RuntimeError): + """A ``gh api`` call failed, or GitHub's answer was not what the API documents.""" + + +# (method, endpoint, optional JSON body) -> parsed JSON answer (None for an empty body). +GhApi = Callable[[str, str, Optional[Mapping[str, Any]]], Any] + + +@dataclass(frozen=True) +class Blocker: + """One "Blocked by" link: the issue it points at and how that issue stands.""" + + number: int + repo: str # owner/name of the blocker's repository + state: str # OPEN or CLOSED + reason: Optional[str] # COMPLETED, NOT_PLANNED, DUPLICATE, REOPENED or None + + @property + def status(self) -> str: + """``waiting`` (open), ``resolved`` (closed as completed) or ``not-completed`` (closed any other way). + + GitHub records a reason for every close since 2022; an older issue closed before that has none and counts as + completed, which is what closing meant then. + """ + if self.state != "CLOSED": + return "waiting" + if self.reason in (None, "COMPLETED"): + return "resolved" + return "not-completed" + + @property + def key(self) -> str: + """``owner/repo#N``, lower-cased: identifies the blocker across repositories.""" + return f"{self.repo.lower()}#{self.number}" + + def ref(self, home: str) -> str: + return f"#{self.number}" if self.repo.lower() == home.lower() else f"{self.repo}#{self.number}" + + +@dataclass(frozen=True) +class IssueState: + """What ``sync`` needs to know about one issue.""" + + number: int + is_open: bool + labels: frozenset[str] + blockers: tuple[Blocker, ...] + + +@dataclass(frozen=True) +class PullFacts: + """What the check needs to know about one pull request.""" + + number: int + head_sha: str + labels: frozenset[str] + texts: tuple[str, ...] # the title, the description and every commit message + closing: frozenset[str] # `owner/repo#N` keys GitHub will close on merge (empty off the default branch) + listed: frozenset[str] # every key in GitHub's closing list, whatever the base branch + complete: bool # False when GitHub's lists were longer than one page, so not everything could be read + + +@dataclass(frozen=True) +class Plan: + """What ``sync`` does to one issue. ``comment`` is the unblocked notice and ``marker`` its idempotency key; + ``not_completed`` lists blockers that were closed without being completed (each gets one notice and + ``needs-triage``).""" + + add_label: bool = False + remove_label: bool = False + comment: str = "" + marker: str = "" + not_completed: tuple[Blocker, ...] = () + + @property + def changes(self) -> bool: + return self.add_label or self.remove_label or bool(self.not_completed) + + +def contributing_url(repo: str, anchor: str = "dependencies-between-issues") -> str: + return f"https://github.com/{repo}/blob/development/CONTRIBUTING.md#{anchor}" + + +def is_blocked(blockers: Iterable[Blocker]) -> bool: + """Blocked while any blocker is not resolved: still open, or closed without being completed.""" + return any(b.status != "resolved" for b in blockers) + + +def plan_issue(issue: IssueState, repo: str) -> Plan: + """The rules for one issue: is it blocked, and what changes.""" + if not issue.is_open: + return Plan(remove_label=BLOCKED_LABEL in issue.labels) + has_label = BLOCKED_LABEL in issue.labels + if is_blocked(issue.blockers): + not_completed = tuple(b for b in issue.blockers if b.status == "not-completed") + return Plan(add_label=not has_label, not_completed=not_completed) + if not has_label: + return Plan() + if issue.blockers: + refs = ", ".join(b.ref(repo) for b in issue.blockers) + template = MSG_UNBLOCKED_TRIAGE if TRIAGE_LABEL in issue.labels else MSG_UNBLOCKED + message = template.format(blockers=refs, contributing=contributing_url(repo, "working-on-an-issue")) + marker = UNBLOCKED_MARKER.format(blockers=",".join(sorted(b.key for b in issue.blockers))) + else: + message = MSG_UNBLOCKED_NO_LINKS.format(contributing=contributing_url(repo)) + marker = UNBLOCKED_MARKER.format(blockers="none") + return Plan(remove_label=True, comment=f"{message}\n\n{marker}", marker=marker) + + +def not_completed_notice(blocker: Blocker, repo: str) -> str: + reason = "a duplicate" if blocker.reason == "DUPLICATE" else "not planned" + text = MSG_NOT_COMPLETED.format(blocker=blocker.ref(repo), reason=reason, mention=MAINTAINER_MENTION) + return f"{text}\n\n{NOT_COMPLETED_MARKER.format(blocker=blocker.key)}" + + +def _strip_fences(body: str) -> str: + """Drop fenced code blocks with a line walker (linear; docs/standards/regex.md rule 6). + + A fence opens with three or more backticks or tildes after at most three spaces (a backtick fence's info string + may not contain a backtick) and closes on a line holding the same character at least as many times and nothing + else. A fence that never closes runs to the end, as in CommonMark. Dropped lines are kept as blank lines, so a + code span is never paired across a fence. + """ + kept: list[str] = [] + open_fence: Optional[tuple[str, int]] = None + for line in body.splitlines(): + match = _FENCE_RE.match(line) + if open_fence is None: + if match and not (match.group(1)[0] == "`" and "`" in line[match.end() :]): + open_fence = (match.group(1)[0], len(match.group(1))) + kept.append("") + continue + kept.append(line) + continue + if ( + match + and match.group(1)[0] == open_fence[0] + and len(match.group(1)) >= open_fence[1] + and not line[match.end() :].strip(" \t") + ): + open_fence = None + kept.append("") + return "\n".join(kept) + + +def _strip_html_comments(text: str) -> str: + """Drop ```` with ``str.find`` (linear). A comment that never closes runs to the end.""" + out: list[str] = [] + start = 0 + while True: + opening = text.find("", opening + 4) + if closing < 0: + return "".join(out) + start = closing + 3 + + +def _strip_code_spans(paragraph: str) -> str: + """Drop CommonMark code spans from one paragraph: a run of N backticks up to the next run of exactly N. + + Linear: one pass collects the runs, a backward pass links each run to the next run of its length. An opener + without a matching run is literal text, as in CommonMark. + """ + runs = [(m.start(), m.end()) for m in _BACKTICK_RUN_RE.finditer(paragraph)] + next_same: list[Optional[int]] = [None] * len(runs) + last: dict[int, int] = {} + for idx in range(len(runs) - 1, -1, -1): + length = runs[idx][1] - runs[idx][0] + next_same[idx] = last.get(length) + last[length] = idx + out: list[str] = [] + pos = idx = 0 + while idx < len(runs): + close = next_same[idx] + if close is None: + idx += 1 + continue + out.append(paragraph[pos : runs[idx][0]] + " ") + pos, idx = runs[close][1], close + 1 + out.append(paragraph[pos:]) + return "".join(out) + + +def _strip_inline_code(text: str) -> str: + """Drop code spans paragraph by paragraph. A span never crosses a blank line, so a stray backtick cannot hide + the paragraphs after it, or the links in them.""" + kept: list[str] = [] + paragraph: list[str] = [] + for line in text.splitlines() + [""]: + if line.strip(): + paragraph.append(line) + continue + if paragraph: + kept.append(_strip_code_spans("\n".join(paragraph))) + paragraph = [] + kept.append("") + return "\n".join(kept) + + +def _strip_non_prose(body: str) -> str: + """Drop fenced code, HTML comments and inline code: the template's own examples live there.""" + return _strip_inline_code(_strip_html_comments(_strip_fences(body))) + + +def linked_issues(text: str, repo: str) -> frozenset[int]: + """Issue numbers of this repository that ``text`` references after a keyword, read leniently. + + A colon or a missing space after the keyword still counts, and so does every issue of a list (`Fixes #1, #2`): + these links can only make the check stricter. Whether GitHub *closes* an issue is never decided here — see + ``PullFacts.closing``. A reference to another repository is ignored: this repository's links cannot block on it. + """ + owner, name = repo.lower().split("/") + found: set[int] = set() + for match in _LINK_RE.finditer(_strip_non_prose(text or "")): + if not match.group("sep") and not match.group("refs").startswith("#"): + continue # `refsfoo/bar#1` is a word, not a keyword followed by a reference + for ref in _REF_RE.finditer(match.group("refs")): + ref_owner = (ref.group("owner") or ref.group("uowner") or owner).lower() + ref_repo = (ref.group("repo") or ref.group("urepo") or name).lower() + if (ref_owner, ref_repo) == (owner, name): + found.add(int(ref.group("num") or ref.group("unum"))) + return frozenset(found) + + +def pull_links(pull: PullFacts, repo: str) -> frozenset[int]: + """Every issue of this repository the pull request links: what its texts mention and what GitHub lists.""" + home = repo.lower() + linked: set[int] = set() + for text in pull.texts: + linked |= linked_issues(text, repo) + linked |= {int(key.rsplit("#", 1)[1]) for key in pull.listed if key.rsplit("#", 1)[0] == home} + return frozenset(linked) + + +@dataclass(frozen=True) +class Verdict: + """``check-pr``'s answer: may the pull request merge, and why not.""" + + ok: bool + lines: tuple[str, ...] + + +def check_pull_request( + override: Optional[bool], + pull: PullFacts, + blockers_of: Callable[[int], Optional[tuple[Blocker, ...]]], + repo: str, +) -> Verdict: + """The rules for one pull request. + + ``override`` is ``None`` without the override label, ``True`` when a maintainer set it and ``False`` when someone + else did (then it is ignored). ``blockers_of`` returns an open issue's links, or ``None`` for a pull request, a + closed issue or a number that does not exist (none of those can be blocked work); it may raise ``GhError``. + """ + linked = pull_links(pull, repo) + if len(linked) > _MAX_LINKED or not pull.complete: + why = ( + f"links {len(linked)} issues; the check reads at most {_MAX_LINKED}" + if len(linked) > _MAX_LINKED + else f"has more than {_MAX_COMMITS} commits or 50 closing references, so not all of its links can be read" + ) + if override: + return Verdict(True, (f"This pull request {why}; `{OVERRIDE_LABEL}` was set by a maintainer.",)) + return Verdict(False, (f"This pull request {why}. Split it, or ask a maintainer.",)) + notes: tuple[str, ...] = () + if override is False: + notes = (f"`{OVERRIDE_LABEL}` is ignored: only a maintainer listed in MAINTAINERS.md can set it.",) + problems: list[str] = [] + waiting = checked = 0 + for number in sorted(linked): + blockers = blockers_of(number) + if blockers is None: + continue + checked += 1 + for b in blockers: + if b.status == "resolved" or (b.status == "waiting" and b.key in pull.closing): + continue # completed, or closed by this pull request as well + if b.status == "waiting": + waiting += 1 + problems.append(f"#{number} is blocked by {b.ref(repo)}, which is still open.") + else: + problems.append( + f"#{number} is blocked by {b.ref(repo)}, which was closed without being completed: a maintainer " + "has to re-plan that dependency first (the issue is flagged `needs-triage`)." + ) + if override: + return Verdict( + True, + (f"`{OVERRIDE_LABEL}` was set by a maintainer, who accepts:",) + tuple(problems or ["nothing is blocked"]), + ) + if not problems: + return Verdict(True, (f"No linked issue is blocked ({checked} issue(s) checked).",) + notes) + hints = ( + "If this pull request does not work on that issue, remove the reference (title, description and commit " + "messages count).", + ) + if waiting: + hints = ( + "Wait until the blocker is closed as completed (this check is re-run within the hour), close it in this " + "pull request too (GitHub must list it under Development as closed by this pull request), or ask a " + "maintainer.", + ) + hints + return Verdict(False, tuple(problems) + notes + hints + (f"See {contributing_url(repo)}",)) + + +# ------------------------------------------------------------------------------------------------ GitHub access +def _clean(text: str, limit: int = 200) -> str: + """One line, bounded, with ``::`` defanged so it can never open a workflow command.""" + return " ".join(text.split())[:limit].replace("::", ": :") + + +def gh_api(method: str, endpoint: str, payload: Optional[Mapping[str, Any]] = None) -> Any: + """Call the GitHub API through the ``gh`` CLI (authenticated by ``GH_TOKEN``); no shell is involved. + + ``gh`` exits non-zero when a GraphQL answer carries errors, even partial ones; that answer is returned so that + ``graphql`` can decide which errors it accepts. + """ + if method not in _ALLOWED_METHODS or any(c.isspace() for c in endpoint): + raise GhError("refusing an API call outside the fixed endpoints") + if endpoint == "graphql": + if method != "POST" or not isinstance(payload, Mapping) or payload.get("query") not in QUERIES.values(): + raise GhError("refusing a GraphQL document that is not one of this module's queries") + elif not endpoint.startswith("repos/"): + raise GhError("refusing an API call outside the fixed endpoints") + command = ["gh", "api", "--method", method, endpoint] + stdin = None + if payload is not None: + command += ["--input", "-"] + stdin = json.dumps(payload) + label = f"{method} {endpoint.split('?')[0]}" + try: + # A list without a shell; the method and endpoint were validated above, and an endpoint is built only from + # integers, the repository name, a validated commit SHA and fixed path segments. + proc = subprocess.run( # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit + command, input=stdin, capture_output=True, text=True, timeout=_GH_TIMEOUT_SECONDS, check=False + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise GhError(f"{label} could not run: {type(exc).__name__}") from exc + answer: Any = None + if proc.stdout.strip(): + try: + answer = json.loads(proc.stdout) + except ValueError as exc: + if proc.returncode == 0: + raise GhError(f"{label} returned something that is not JSON") from exc + if proc.returncode != 0: + if endpoint == "graphql" and isinstance(answer, Mapping) and "errors" in answer: + return answer + raise GhError(f"{label} failed (exit {proc.returncode}): {_clean(proc.stderr)}") + return answer + + +def _only_missing_issue(errors: Any) -> bool: + """Exactly one NOT_FOUND error, for ``repository.issueOrPullRequest``: the number names no issue.""" + return ( + isinstance(errors, list) + and len(errors) == 1 + and isinstance(errors[0], Mapping) + and errors[0].get("type") == "NOT_FOUND" + and errors[0].get("path") == ["repository", "issueOrPullRequest"] + ) + + +def graphql(gh: GhApi, query: str, *, allow_missing: bool = False, **variables: Any) -> Mapping[str, Any]: + """Run one of ``QUERIES``. ``allow_missing`` accepts the one error a missing issue number produces; any other + error, including one for the repository itself, raises.""" + answer = gh("POST", "graphql", {"query": QUERIES[query], "variables": variables}) + errors = answer.get("errors") if isinstance(answer, Mapping) else None + if errors and allow_missing and _only_missing_issue(errors): + errors = None + if not isinstance(answer, Mapping) or errors or not isinstance(answer.get("data"), Mapping): + first = ( + errors[0].get("message", "") + if isinstance(errors, list) and errors and isinstance(errors[0], Mapping) + else "" + ) + raise GhError(f"GraphQL {query} failed: {_clean(str(first))}") + return answer["data"] + + +def _page(connection: Mapping[str, Any], limit: int, what: str, number: Any) -> list: + if connection["totalCount"] > len(connection["nodes"]): + raise GhError(f"#{number} has more than {limit} {what}") + return list(connection["nodes"]) + + +def _issue_state(node: Mapping[str, Any], is_open: bool) -> IssueState: + try: + number = int(node["number"]) + labels = frozenset(str(label["name"]) for label in _page(node["labels"], _MAX_LABELS, "labels", number)) + blockers = tuple( + Blocker(int(b["number"]), str(b["repository"]["nameWithOwner"]), str(b["state"]), b.get("stateReason")) + for b in _page(node["blockedBy"], _MAX_BLOCKERS, "blockers", number) + ) + return IssueState(number, is_open, labels, blockers) + except (KeyError, TypeError, ValueError) as exc: + raise GhError("an issue answer lacks a field") from exc + + +def fetch_open_issues(gh: GhApi, repo: str) -> tuple[list[IssueState], set[int]]: + """Every open issue, and the numbers of those whose links could not be read (each reported to stderr).""" + owner, name = repo.split("/") + out: list[IssueState] = [] + broken: set[int] = set() + after: Optional[str] = None + for _ in range(_MAX_PAGES): + data = graphql(gh, "open_issues", owner=owner, name=name, after=after) + try: + page = data["repository"]["issues"] + nodes, info = page["nodes"], page["pageInfo"] + except (KeyError, TypeError) as exc: + raise GhError("the open-issues answer lacks a field") from exc + for node in nodes: + try: + out.append(_issue_state(node, True)) + except GhError as exc: + broken.add(int(node.get("number") or 0)) + print(f"error: {_clean(str(exc))}", file=sys.stderr) + if not info.get("hasNextPage"): + return out, broken + after = str(info["endCursor"]) + raise GhError(f"more than {_MAX_PAGES} pages of open issues") + + +def fetch_closed_labelled(gh: GhApi, repo: str) -> list[int]: + """Closed issues that still carry ``blocked`` (a closed issue no longer waits for anything).""" + numbers: list[int] = [] + for page in range(1, _MAX_PAGES + 1): + raw = gh( + "GET", + f"repos/{repo}/issues?state=closed&labels={quote(BLOCKED_LABEL)}&per_page={_PAGE_SIZE}&page={page}", + None, + ) + if not isinstance(raw, list): + raise GhError("the closed-issues answer is not a list") + numbers += [int(i["number"]) for i in raw if isinstance(i, Mapping) and "pull_request" not in i] + if len(raw) < _PAGE_SIZE: + return numbers + raise GhError(f"more than {_MAX_PAGES} pages of closed issues") + + +def fetch_issue_state(gh: GhApi, repo: str, number: int) -> Optional[IssueState]: + """One issue as it stands now, or ``None`` when ``number`` is a pull request or names no issue.""" + owner, name = repo.split("/") + data = graphql(gh, "issue", allow_missing=True, owner=owner, name=name, number=number) + node = (data.get("repository") or {}).get("issueOrPullRequest") + if not isinstance(node, Mapping) or node.get("__typename") != "Issue": + return None + return _issue_state(node, node.get("state") == "OPEN") + + +def fetch_blockers_of(gh: GhApi, repo: str, number: int) -> Optional[tuple[Blocker, ...]]: + """An open issue's links, or ``None`` when ``number`` is a pull request, a closed issue or names no issue.""" + state = fetch_issue_state(gh, repo, number) + return state.blockers if state is not None and state.is_open else None + + +def _pull_facts(node: Mapping[str, Any], default_branch: str) -> PullFacts: + try: + number, sha = int(node["number"]), str(node["headRefOid"]) + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise GhError(f"pull request #{number} has a malformed head sha") + labels = frozenset(str(label["name"]) for label in _page(node["labels"], _MAX_LABELS, "labels", number)) + refs, commits = node["closingIssuesReferences"], node["commits"] + complete = refs["totalCount"] <= len(refs["nodes"]) and commits["totalCount"] <= len(commits["nodes"]) + listed = frozenset(f"{str(r['repository']['nameWithOwner']).lower()}#{int(r['number'])}" for r in refs["nodes"]) + texts = (str(node.get("title") or ""), str(node.get("body") or "")) + tuple( + str((c.get("commit") or {}).get("message") or "") for c in commits["nodes"] + ) + # GitHub closes on merge only into the default branch; elsewhere nothing is exempted. + closing = listed if node["baseRefName"] == default_branch else frozenset() + return PullFacts(number, sha, labels, texts, closing, listed, complete) + except (AttributeError, KeyError, TypeError, ValueError) as exc: + raise GhError("a pull-request answer lacks a field") from exc + + +def _default_branch(data: Mapping[str, Any]) -> str: + try: + return str(data["repository"]["defaultBranchRef"]["name"]) + except (KeyError, TypeError) as exc: + raise GhError("the answer lacks the default branch") from exc + + +def fetch_pull(gh: GhApi, repo: str, number: int) -> PullFacts: + owner, name = repo.split("/") + data = graphql(gh, "pull", owner=owner, name=name, number=number) + node = (data.get("repository") or {}).get("pullRequest") + if not isinstance(node, Mapping): + raise GhError(f"pull request #{number} was not found") + return _pull_facts(node, _default_branch(data)) + + +def override_authorized(gh: GhApi, repo: str, number: int) -> bool: + """True when the latest change of the override label on this pull request is a maintainer adding it. + + Who may *add* a label is a repository permission, not a maintainer decision, so the label alone proves nothing. + The issue events record who added or removed it; they are sorted by time rather than trusted to arrive in order. + """ + changes: list[Mapping[str, Any]] = [] + for page in range(1, _MAX_PAGES + 1): + raw = gh("GET", f"repos/{repo}/issues/{number}/events?per_page={_PAGE_SIZE}&page={page}", None) + if not isinstance(raw, list): + raise GhError("the events answer is not a list") + changes += [ + e + for e in raw + if isinstance(e, Mapping) + and e.get("event") in ("labeled", "unlabeled") + and (e.get("label") or {}).get("name") == OVERRIDE_LABEL + ] + if len(raw) < _PAGE_SIZE: + break + else: + raise GhError(f"more than {_MAX_PAGES} pages of events") + if not changes: + return False + last = max(changes, key=lambda e: (str(e.get("created_at") or ""), int(e.get("id") or 0))) + return ( + last.get("event") == "labeled" + and str((last.get("actor") or {}).get("login") or "").lower() in MAINTAINER_LOGINS + ) + + +def _already_noticed(gh: GhApi, repo: str, number: int, marker: str) -> bool: + """True when the bot has posted ``marker`` on the issue. Past the page bound it answers True (and says so), so a + very long thread cannot make the bot repeat a notice every hour.""" + for page in range(1, _MAX_PAGES + 1): + raw = gh("GET", f"repos/{repo}/issues/{number}/comments?per_page={_PAGE_SIZE}&page={page}", None) + if not isinstance(raw, list): + raise GhError("the comments answer is not a list") + for comment in raw: + user = comment.get("user") if isinstance(comment, Mapping) else None + if isinstance(user, Mapping) and user.get("type") == "Bot" and marker in str(comment.get("body")): + return True + if len(raw) < _PAGE_SIZE: + return False + print(f"warning: issue #{number}: too many comments to look for an earlier notice; not posting", file=sys.stderr) + return True + + +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.timezone.utc) + + +def _reconcile_one( + gh: GhApi, + repo: str, + pull: PullFacts, + blockers_of: Callable[[int], Optional[tuple[Blocker, ...]]], + dry_run: bool, +) -> bool: + """Re-run one pull request's dependency check if its last result no longer matches; True when it did.""" + override = override_authorized(gh, repo, pull.number) if OVERRIDE_LABEL in pull.labels else None + want_ok = check_pull_request(override, pull, blockers_of, repo).ok + runs = gh( + "GET", f"repos/{repo}/commits/{pull.head_sha}/check-runs?check_name={quote(CHECK_NAME)}&per_page=100", None + ) + candidates = [ + r for r in (runs or {}).get("check_runs") or [] if (r.get("app") or {}).get("slug") == "github-actions" + ] + if not candidates: + return False # the check has not run on this commit; the next pull-request event runs it + latest = max(candidates, key=lambda r: int(r["id"])) + if latest.get("status") != "completed": + return False # still running on what it read; the next sync compares again + if (latest.get("conclusion") in PASSING_CONCLUSIONS) == want_ok: + return False + started = datetime.datetime.fromisoformat(str(latest.get("started_at") or "").replace("Z", "+00:00")) + if _now() - started > datetime.timedelta(days=_RERUN_DAYS): + print( + f"warning: pull request #{pull.number}: its dependency check is too old to re-run; a push or an edit of the " + "pull request runs it again", + file=sys.stderr, + ) + return False + if not dry_run: + # An Actions check run's id is its job's id. + gh("POST", f"repos/{repo}/actions/jobs/{int(latest['id'])}/rerun", None) + print(f"{'would re-run' if dry_run else 'did re-run'} the dependency check of pull request #{pull.number}") + return True + + +def reconcile_pr_checks( + gh: GhApi, repo: str, open_issues: Mapping[int, IssueState], broken: Iterable[int] = (), dry_run: bool = False +) -> tuple[int, int]: + """Re-run the dependency check of every open pull request whose last result no longer matches its linked issues: + red although every blocker is now completed, or green although a linked issue became blocked. + + It compares state on every run instead of reacting to a transition, so a failed re-run, a check that was still + running, or a sync that stopped half-way is simply caught by the next run. Optional by design: a failure is a + warning, never a failed sync. Returns ``(pull requests looked at, checks re-run)``. + """ + unreadable = frozenset(broken) + + def blockers_of(number: int) -> Optional[tuple[Blocker, ...]]: + if number in unreadable: + raise GhError(f"the Blocked-by links of #{number} could not be read") + issue = open_issues.get(number) + return issue.blockers if issue is not None else None + + owner, name = repo.split("/") + seen = rerun = 0 + try: + after: Optional[str] = None + for _ in range(_MAX_PAGES): + data = graphql(gh, "open_pulls", owner=owner, name=name, after=after) + default_branch = _default_branch(data) + page = data["repository"]["pullRequests"] + for node in page["nodes"]: + seen += 1 + try: + rerun += _reconcile_one(gh, repo, _pull_facts(node, default_branch), blockers_of, dry_run) + except (GhError, AttributeError, KeyError, TypeError, ValueError) as exc: + print(f"warning: could not reconcile a pull request's check: {_clean(str(exc))}", file=sys.stderr) + if not page["pageInfo"].get("hasNextPage"): + break + after = str(page["pageInfo"]["endCursor"]) + else: + print(f"warning: more than {_MAX_PAGES} pages of open pull requests; the rest wait", file=sys.stderr) + except (GhError, AttributeError, KeyError, TypeError, ValueError) as exc: + print(f"warning: could not list the open pull requests: {_clean(str(exc))}", file=sys.stderr) + return seen, rerun + + +# ----------------------------------------------------------------------------------------------------- commands +def _sync_issue(gh: GhApi, repo: str, issue: IssueState, dry_run: bool) -> bool: + """Apply the rules to one issue; True when an open issue was unblocked.""" + plan = plan_issue(issue, repo) + if plan.remove_label and issue.is_open and not dry_run: + # Read the issue again right before acting on it: a blocker reopened since the snapshot must not lead to an + # "unblocked" notice that a later run cannot take back. + fresh = fetch_issue_state(gh, repo, issue.number) + if fresh is None: + print(f"skipped #{issue.number}: no longer an issue") + return False + issue, plan = fresh, plan_issue(fresh, repo) + verb = "would" if dry_run else "did" + if plan.add_label: + if not dry_run: + gh("POST", f"repos/{repo}/issues/{issue.number}/labels", {"labels": [BLOCKED_LABEL]}) + print(f"{verb} label #{issue.number} {BLOCKED_LABEL}") + if plan.remove_label: + if not dry_run: + # The notice goes first and is idempotent through its marker, and the label goes last: if anything fails in + # between, the label is still there and the next run finishes the job without a second notice. + if plan.comment and not _already_noticed(gh, repo, issue.number, plan.marker): + gh("POST", f"repos/{repo}/issues/{issue.number}/comments", {"body": plan.comment}) + gh("DELETE", f"repos/{repo}/issues/{issue.number}/labels/{quote(BLOCKED_LABEL)}", None) + print(f"{verb} unlabel #{issue.number} {BLOCKED_LABEL}" + (" and comment" if plan.comment else "")) + for b in plan.not_completed: + if dry_run: + print(f"would check #{issue.number}: blocker {b.ref(repo)} was not completed") + continue + if _already_noticed(gh, repo, issue.number, NOT_COMPLETED_MARKER.format(blocker=b.key)): + continue + gh("POST", f"repos/{repo}/issues/{issue.number}/labels", {"labels": [TRIAGE_LABEL]}) + gh("POST", f"repos/{repo}/issues/{issue.number}/comments", {"body": not_completed_notice(b, repo)}) + print(f"did flag #{issue.number}: blocker {b.ref(repo)} was not completed") + return plan.remove_label and issue.is_open + + +def run_sync(repo: str, gh: GhApi, dry_run: bool = False) -> int: + open_issues, broken = fetch_open_issues(gh, repo) + failures = len(broken) + try: + closed = [IssueState(n, False, frozenset({BLOCKED_LABEL}), ()) for n in fetch_closed_labelled(gh, repo)] + except GhError as exc: + failures += 1 + closed = [] + print(f"error: could not list closed issues that keep `{BLOCKED_LABEL}`: {_clean(str(exc))}", file=sys.stderr) + blocked = sum(1 for i in open_issues if is_blocked(i.blockers)) + unblocked = 0 + for issue in open_issues + closed: + # One issue that cannot be written (locked, at its label limit, ...) must not stop the others, nor the + # pull-request reconciliation below. + try: + unblocked += _sync_issue(gh, repo, issue, dry_run) + except GhError as exc: + failures += 1 + print(f"error: issue #{issue.number}: {_clean(str(exc))}", file=sys.stderr) + seen, rerun = reconcile_pr_checks(gh, repo, {i.number: i for i in open_issues}, broken, dry_run) + print( + f"open issues: {len(open_issues)}, blocked: {blocked}, unblocked now: {unblocked}, " + f"open pull requests: {seen}, dependency checks re-run: {rerun}, failures: {failures}" + ) + return 1 if failures else 0 + + +def run_check_pr(event: Mapping[str, Any], repo: str, gh: GhApi) -> int: + try: + number = event["pull_request"]["number"] + except (KeyError, TypeError) as exc: + raise EventError("event payload has no pull_request.number") from exc + if isinstance(number, bool) or not isinstance(number, int) or number < 1: + raise EventError("pull request number is not a positive integer") + # Read the pull request again rather than trust the event: a re-run replays the original payload, and the + # description, the labels or the commits may have changed since. + pull = fetch_pull(gh, repo, number) + override = override_authorized(gh, repo, number) if OVERRIDE_LABEL in pull.labels else None + verdict = check_pull_request(override, pull, lambda n: fetch_blockers_of(gh, repo, n), repo) + for line in verdict.lines: + print(line if verdict.ok else f"::error::{line}") + return 0 if verdict.ok else _EXIT_BLOCKED + + +def _repo_from_env() -> str: + repo = os.environ.get("GITHUB_REPOSITORY", "") + if not re.fullmatch(r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+", repo): + raise EventError("GITHUB_REPOSITORY must be set to owner/name") + return repo + + +def main(argv: Optional[Sequence[str]] = None, gh: GhApi = gh_api) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="command", required=True) + sync = sub.add_parser("sync", help="bring the blocked label in step with the Blocked-by links") + sync.add_argument("--dry-run", action="store_true", help="print what would change, change nothing") + sub.add_parser("check-pr", help="fail when the pull request links a blocked issue (reads GITHUB_EVENT_PATH)") + try: + args = parser.parse_args(argv) + except SystemExit as exc: # argparse exits 2 on a usage error and 0 on --help + return int(exc.code or 0) + try: + repo = _repo_from_env() + if args.command == "sync": + return run_sync(repo, gh, dry_run=args.dry_run) + event_path = os.environ.get("GITHUB_EVENT_PATH", "") + if not event_path: + raise EventError("GITHUB_EVENT_PATH must be set") + try: + with open(event_path, encoding="utf-8") as handle: + event = json.load(handle) + except (OSError, ValueError) as exc: + raise EventError(f"cannot read the event payload: {type(exc).__name__}") from exc + return run_check_pr(event, repo, gh) + except EventError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except GhError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())