diff --git a/.github/tools/closing_issues.py b/.github/tools/closing_issues.py new file mode 100644 index 0000000..bd302c6 --- /dev/null +++ b/.github/tools/closing_issues.py @@ -0,0 +1,168 @@ +"""Find the issues that merged pull requests resolve, for the develop -> main release flow. + +GitHub only acts on closing keywords ("Closes #12") in a pull request that merges into the +DEFAULT branch (main). Work here merges into develop, so the keywords are recorded but never +fire, and the release PR that later carries the fix to main has no keywords of its own. This +script reads the keywords back out of the merged pull requests (descriptions and commit +messages) so they can be: + +* ``release``: re-stated in the release PR body, so GitHub closes the issues when the release + merges into main. +* ``develop``: announced on each still-open issue as soon as its fix merges into develop. + +Matching follows GitHub's rules: a keyword (close/closes/closed, fix/fixes/fixed, +resolve/resolves/resolved, optional colon) directly before ONE reference — ``#12``, +``owner/repo#12`` or an issue URL in this repository. "Closes #1, #2" closes only #1, as on +GitHub. HTML comments (the PR template's hints) and code are ignored. + +Usage (from the repo root, with ``gh`` authenticated):: + + python3 .github/tools/closing_issues.py release --since v2.5.0 + python3 .github/tools/closing_issues.py develop --sha [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys + +KEYWORD_REGEX = re.compile( + r"(?[\w.-]+/[\w.-]+)?#(?P\d+)" + r"|https://github\.com/(?P[\w.-]+/[\w.-]+)/issues/(?P\d+))\b", + re.IGNORECASE, +) +# Text GitHub does not scan for keywords: HTML comments, fenced code blocks (including ones +# indented inside list items), and inline code. +IGNORED_REGEX = re.compile( + r"|^[ \t]*(?P```|~~~).*?^[ \t]*(?P=fence)|`[^`\n]*`", re.DOTALL | re.MULTILINE +) +MERGE_SUBJECT_REGEX = re.compile(r"^Merge pull request #(\d+) from ") +# Marks comments this script posted, so a re-run never posts the same note twice. +MARKER = "" + + +def gh(*args: str) -> str: + return subprocess.run(["gh", *args], check=True, capture_output=True, text=True).stdout + + +def referenced_issues(text: str, repo: str) -> list[int]: + """Issue numbers in ``repo`` that ``text`` closes, in order of first mention.""" + text = IGNORED_REGEX.sub(" ", text) + numbers: dict[int, None] = {} + for m in KEYWORD_REGEX.finditer(text): + ref_repo = m["repo"] or m["url_repo"] or repo + if ref_repo.lower() == repo.lower(): + numbers[int(m["number"] or m["url_number"])] = None + return list(numbers) + + +def pull_request_text(repo: str, pr: int) -> str: + """The PR description plus every commit message in it.""" + body = gh("api", f"repos/{repo}/pulls/{pr}", "--jq", '.body // ""') + messages = gh("api", "--paginate", f"repos/{repo}/pulls/{pr}/commits", "--jq", ".[].commit.message") + return f"{body}\n\n{messages}" + + +def is_open_issue(repo: str, number: int) -> bool: + """True for an open issue; False for a closed one, a pull request, or a missing number.""" + try: + issue = json.loads(gh("api", f"repos/{repo}/issues/{number}")) + except subprocess.CalledProcessError: + return False + return issue["state"] == "open" and "pull_request" not in issue + + +def issues_by_pr(repo: str, prs: list[int]) -> dict[int, list[int]]: + """Map each still-open issue to the merged PRs that resolve it.""" + found: dict[int, list[int]] = {} + for pr in prs: + for number in referenced_issues(pull_request_text(repo, pr), repo): + found.setdefault(number, []).append(pr) + return {n: found[n] for n in sorted(found) if is_open_issue(repo, n)} + + +def refs(prs: list[int]) -> str: + return ", ".join(f"#{pr}" for pr in prs) + + +def release(repo: str, since: str) -> int: + """Print the release PR section that re-states the closing keywords merged since ``since``.""" + subjects = subprocess.run( + ["git", "log", "--merges", "--format=%s", f"{since}..HEAD"], check=True, capture_output=True, text=True + ).stdout.splitlines() + prs = sorted({int(m[1]) for s in subjects if (m := MERGE_SUBJECT_REGEX.match(s))}) + found = issues_by_pr(repo, prs) + if not found: + print(f"_No open issues are referenced by the {len(prs)} pull requests merged since {since}._") + return 0 + # One keyword per line: GitHub closes only the first reference after each keyword. + print("### Issues resolved by this release\n") + print("Merging this PR into `main` closes:\n") + for number, by in found.items(): + print(f"- Closes #{number} (via {refs(by)})") + return 0 + + +def develop(repo: str, sha: str, dry_run: bool) -> int: + """Comment on each open issue resolved by the PR(s) that ``sha`` merged into develop.""" + prs = [ + int(n) + for n in gh( + "api", + f"repos/{repo}/commits/{sha}/pulls", + "--jq", + '.[] | select(.merged_at != null and .base.ref == "develop") | .number', + ).split() + ] + if not prs: + print(f"{sha[:7]} is not a pull request merge into develop; nothing to do.") + return 0 + summary = [] + for number, by in issues_by_pr(repo, prs).items(): + for pr in by: + marker = MARKER.format(pr=pr) + existing = gh("api", "--paginate", f"repos/{repo}/issues/{number}/comments", "--jq", ".[].body") + if marker in existing: + summary.append(f"- #{number}: already noted for #{pr}") + continue + body = ( + f"{marker}\nAddressed by #{pr}, which is now merged into `develop`. This issue will close" + " automatically when that change is released to `main`." + ) + if not dry_run: + gh("api", "-X", "POST", f"repos/{repo}/issues/{number}/comments", "-f", f"body={body}") + summary.append(f"- #{number}: {'would comment' if dry_run else 'commented'} (resolved by #{pr})") + report = "\n".join(summary) or f"No open issues are referenced by {refs(prs)}." + print(report) + if path := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(path, "a", encoding="utf-8") as fh: + fh.write(f"## Issues addressed by {refs(prs)}\n\n{report}\n") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Carry closing keywords from develop merges through to main.") + sub = parser.add_subparsers(dest="command", required=True) + rel = sub.add_parser("release", help="print the release PR's 'Closes #N' section") + rel.add_argument("--since", required=True, help="the last release tag") + dev = sub.add_parser("develop", help="comment on open issues resolved by a merge into develop") + dev.add_argument("--sha", required=True, help="the merge commit pushed to develop") + dev.add_argument("--dry-run", action="store_true", help="report without commenting") + args = parser.parse_args() + + repo = os.environ.get("GITHUB_REPOSITORY") or gh( + "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner" + ) + repo = repo.strip() + if args.command == "release": + return release(repo, args.since) + return develop(repo, args.sha, args.dry_run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/issue-fixed-in-develop.yml b/.github/workflows/issue-fixed-in-develop.yml new file mode 100644 index 0000000..93ecb0c --- /dev/null +++ b/.github/workflows/issue-fixed-in-develop.yml @@ -0,0 +1,41 @@ +name: note issues fixed in develop + +# When a pull request merges into develop, comment on each still-open issue it resolves +# (via "Closes #12" / "Fixes #12" in its description or commit messages). +# +# GitHub only closes issues for PRs merged into the default branch (main), so an issue fixed +# on develop stays open with no sign of progress until the next release. This note fills that +# gap; prepare-release then restates the same keywords in the release PR, and GitHub closes +# the issue when the release merges into main. See .github/tools/closing_issues.py. +# +# Triggered by `push` rather than `pull_request_target`: a push to develop happens only after a +# maintainer merges, runs entirely in this repository's context, and never executes or checks +# out code from a fork — yet still gets a token that can comment on issues, fork PRs included. + +on: + push: + branches: [develop] + +permissions: {} + +concurrency: + group: issue-fixed-in-develop-${{ github.sha }} + cancel-in-progress: false + +jobs: + note: + name: Comment on resolved issues + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Comment on open issues this merge resolves + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/tools/closing_issues.py develop --sha "${GITHUB_SHA}" diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 318b3d4..8935480 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -23,6 +23,9 @@ on: permissions: contents: write + # Read merged PRs and their issues to restate closing keywords (closing_issues.py). + pull-requests: read + issues: read jobs: prepare: @@ -71,6 +74,20 @@ jobs: - name: Regenerate CHANGELOG.md run: uv run git-changelog --bump '${{ inputs.bump }}' + # Closing keywords ("Closes #12") only fire for PRs merged into the default branch, so + # the ones in PRs merged into develop never close anything. Restate them in the release + # PR, which does merge into main, so the issues close when the release lands. + - name: Collect the issues this release closes + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Newest release tag, not `git describe`: until the post-release back-merge lands, + # develop cannot reach the latest tag and describe would fall back to an older one. + since="$(git tag --list 'v*' --sort=-v:refname | head -n1)" + python3 .github/tools/closing_issues.py release --since "${since}" > "${RUNNER_TEMP}/closes.md" + cat "${RUNNER_TEMP}/closes.md" + - name: Write the release PR text id: pr_text run: | @@ -89,7 +106,8 @@ jobs: printf 'Automated release prep for **%s**. CHANGELOG.md regenerated via git-changelog.\n\n' "${tag}" printf 'Merging promotes `develop` -> `main` with the changelog already in place.\n' printf 'Afterwards, run the **release** workflow on `main` to build, publish, and tag %s.\n\n' "${tag}" - printf -- '---\n\n' + cat "${RUNNER_TEMP}/closes.md" + printf '\n---\n\n' printf '%s\n' "${notes}" } > "${body_file}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b966cab..67d5343 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,12 +130,14 @@ comlink-python/ │ │ ├── documentation.yml # Documentation issue template │ │ └── feature_request.yml # Feature request template │ ├── release-pr/ # Release PR bodies written by prepare-release.yml +│ ├── tools/ # Python helpers the workflows run (release + issue bookkeeping) │ ├── workflows/ │ │ ├── backmerge.yml # Stages the main → develop back-merge after a release │ │ ├── ci.yml # CI pipeline (lint, type-check, test, docs, build) │ │ ├── commitlint.yml # Commit message validation (runs the pre-commit hook) │ │ ├── docs.yml # Publishes the docs site to GitHub Pages from main │ │ ├── integration.yml # Integration tests against live comlink services +│ │ ├── issue-fixed-in-develop.yml # Comments on issues a merge into develop resolves │ │ ├── labeler.yml # Auto-label PRs by file path │ │ ├── prepare-release.yml # Regenerates CHANGELOG.md and stages a release branch │ │ └── release.yml # Build → PyPI → tag + GitHub Release (version via hatch-vcs) @@ -478,6 +480,12 @@ commit would be left out of the changelog entirely. Commit Lint rejects `!` befo - How you tested the change - A short checklist mirroring the CI checks + **Linking issues:** use a closing keyword (`Closes #51`, `Fixes #51`, `Resolves #51`), with one + keyword per issue — `Closes #1, #2` only closes #1. Because PRs merge into `develop` rather than + the default branch, GitHub won't close the issue at merge time. Instead, a bot comment on the + issue notes that the fix is in `develop`, and the issue closes automatically when the next + release merges into `main`. + 6. **After submitting:** CI checks (lint, type check, tests on Python 3.10–3.14, docs build, package build, commit message validation, and integration tests) run automatically. @@ -538,7 +546,7 @@ Never paste your comlink `access_key` / `secret_key` into an issue. Releases are handled by the maintainer through GitHub Actions. Contributors don't need to manage versioning or releases, but here's how it works for reference: 1. **prepare release** (`prepare-release.yml`) is dispatched on `develop`. It computes the next version from the Conventional Commits since the last tag (or a chosen `patch`/`minor`/`major` bump), regenerates `CHANGELOG.md`, and pushes a `release/vX.Y.Z` branch. If the computed version is a major, the job summary lists the commit lines that triggered it, and an `auto` bump stops unless one is a real `BREAKING CHANGE:` footer (see [Commit Messages](#commit-messages)). -2. The maintainer opens the release PR from that branch into `main` (a link is in the workflow summary). CI runs as normal and the PR is merged. +2. The maintainer opens the release PR from that branch into `main` (a link is in the workflow summary). Its body restates every `Closes #N` from the PRs merged since the last release, so GitHub closes those issues when it merges. CI runs as normal and the PR is merged. 3. **comlink-python release** (`release.yml`) is dispatched on `main`. It reads the version from `CHANGELOG.md`, builds the package — [hatch-vcs](https://github.com/ofek/hatch-vcs) stamps that version, so **no version string is committed to the repo** — and publishes to [PyPI](https://pypi.org/project/swgoh-comlink/) via trusted publishing. 4. Only after a successful publish is the Git tag created and a GitHub Release cut, with release notes auto-generated from the PR history. 5. **back-merge main into develop** (`backmerge.yml`) then stages a branch so `main` can be merged back into `develop`.