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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions .github/tools/closing_issues.py
Original file line number Diff line number Diff line change
@@ -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 <merge commit> [--dry-run]
"""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys

KEYWORD_REGEX = re.compile(
r"(?<![\w/#-])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+"
r"(?:(?P<repo>[\w.-]+/[\w.-]+)?#(?P<number>\d+)"
r"|https://github\.com/(?P<url_repo>[\w.-]+/[\w.-]+)/issues/(?P<url_number>\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<fence>```|~~~).*?^[ \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 = "<!-- closing-issues: pr={pr} -->"


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())
41 changes: 41 additions & 0 deletions .github/workflows/issue-fixed-in-develop.yml
Original file line number Diff line number Diff line change
@@ -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}"
20 changes: 19 additions & 1 deletion .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: |
Expand All @@ -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}"

Expand Down
10 changes: 9 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
Loading