diff --git a/README.md b/README.md index 1e3f7dc..a3c644e 100644 --- a/README.md +++ b/README.md @@ -361,10 +361,6 @@ path, fragment directory, and Markdown or RST format from committed Towncrier co `read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and formatting must all succeed. -After the Towncrier build, the Action runs release-creator's step that adds a "New Contributors" subsection to the new -changelog section with `release-creator contributors`, which release-creator 3.1.0 adds. The caller's `release-prepare` -group needs release-creator 3.1.0 or later. - ```yaml - id: prepare uses: tox-dev/github-workflows/prepare-towncrier@SHA @@ -531,6 +527,9 @@ the asset recheck. - `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's commit, used as the target when the Action creates the Release. +- Before it writes a draft, the Action asks GitHub to generate notes for the range from the previous tag in the notes' + `Full Changelog` link to `tag`, and adds GitHub's first-time contributors, bots excluded, as a `New Contributors` + section above the trailing links. The changelog file never carries this list. A published Release is left as it is. - `assets-directory`: Optional directory of the assets to attach. The Action compares each filename and SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After publication it verifies GitHub's release attestation for each local asset. diff --git a/changelog.d/+release-new-contributors.change.md b/changelog.d/+release-new-contributors.change.md new file mode 100644 index 0000000..037bf78 --- /dev/null +++ b/changelog.d/+release-new-contributors.change.md @@ -0,0 +1,3 @@ +`publish-github-release` credits first-time contributors on the GitHub Release, taking the list from GitHub's generated +notes for the range since the previous tag and placing it above the package and comparison links. `prepare-towncrier` no +longer adds a New Contributors subsection to the changelog. diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 46b1556..490c55f 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -39,7 +39,7 @@ runs: set -euo pipefail if [[ "$PHASE" == finalize ]]; then echo "Release version: $VERSION (from the version input in the finalize phase)" - echo 'Fragment linking, changelog build and contributors skipped: the build phase did them' + echo 'Fragment linking and changelog build skipped: the build phase did them' printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" exit 0 fi @@ -77,17 +77,6 @@ runs: set -euo pipefail echo "Changelog version: $CHANGELOG_PREFIX${VERSION#v} (tag $VERSION${CHANGELOG_PREFIX:+, changelog-prefix $CHANGELOG_PREFIX})" towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" - - name: ๐Ÿ“ Add new contributors to the changelog - if: inputs.phase != 'finalize' - shell: bash - env: - VERSION: ${{ steps.version.outputs.version }} - CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }} - CHANGELOG: ${{ steps.config.outputs.changelog }} - REPOSITORY: ${{ github.repository }} - GH_TOKEN: ${{ github.token }} - run: >- - release-creator contributors --changelog "$CHANGELOG" --version "$CHANGELOG_PREFIX${VERSION#v}" --repository "$REPOSITORY" - name: ๐Ÿงน Format changelog if: inputs.phase != 'build' shell: bash diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index 790d1ee..6caedd8 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -75,6 +75,30 @@ runs: echo "GitHub Release $TAG is published and immutable with the expected assets; nothing to upload or publish" fi printf 'immutable=%s\n' "$immutable" >>"$GITHUB_OUTPUT" + - name: ๐Ÿ‘‹ Credit new contributors + shell: bash + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + COMMIT: ${{ inputs.commit }} + NOTES_FILE: ${{ inputs.notes-file }} + run: | + set -euo pipefail + if jq -e '.[0].draft == false' "$RUNNER_TEMP/release-existing.json" >/dev/null; then + echo "New Contributors skipped: GitHub Release $TAG is already published" + cp "$NOTES_FILE" "$RUNNER_TEMP/release-body.md" + exit 0 + fi + # The comparison link that release-creator renders names the previous tag GitHub should count from. + previous=$(grep -oP "/compare/\K[^)]+(?=\.\.\.\Q$TAG\E\))" "$NOTES_FILE" | head -n 1 || true) + request=(-f "tag_name=$TAG" -f "target_commitish=$COMMIT") + if [[ -n "$previous" ]]; then + request+=(-f "previous_tag_name=$previous") + fi + echo "Generated notes range: ${previous:-first release}...$TAG" + gh api -X POST "repos/$GH_REPO/releases/generate-notes" "${request[@]}" --jq .body >"$RUNNER_TEMP/generated-notes.md" + python3 "$GITHUB_ACTION_PATH/../tools/new_contributors.py" \ + "$NOTES_FILE" "$RUNNER_TEMP/generated-notes.md" "$RUNNER_TEMP/release-body.md" - name: ๐Ÿš€ Create or update the draft shell: bash env: @@ -82,23 +106,22 @@ runs: TAG: ${{ inputs.tag }} COMMIT: ${{ inputs.commit }} TITLE: ${{ inputs.title }} - NOTES_FILE: ${{ inputs.notes-file }} PRERELEASE: ${{ inputs.prerelease }} run: | set -euo pipefail if jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \ - --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \ '{tag_name: $tag, target_commitish: $commit, name: $title, body: $body, draft: true, prerelease: $prerelease}' >"$RUNNER_TEMP/release-request.json" gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \ >"$RUNNER_TEMP/release-created.json" jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id" echo "GitHub Release $TAG: created draft $(<"$RUNNER_TEMP/release-id") for $COMMIT (prerelease $PRERELEASE)" - elif jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + elif jq -e --arg title "$TITLE" --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \ '.[0].draft and (.[0].name != $title or .[0].body != $body or .[0].prerelease != $prerelease)' \ "$RUNNER_TEMP/release-existing.json" >/dev/null; then - jq -n --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \ + jq -n --arg title "$TITLE" --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \ '{name: $title, body: $body, prerelease: $prerelease}' >"$RUNNER_TEMP/release-update.json" gh api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \ --input "$RUNNER_TEMP/release-update.json" \ diff --git a/tests/conftest.py b/tests/conftest.py index 25c5908..e7d396d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ import json import os -import re import shutil import ssl import subprocess @@ -139,45 +138,29 @@ def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], mo def graphql(request: Request) -> Response: query = request.get_json()["query"] - if 'object(oid: "' in query: - commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:]) - data: dict[str, object] = { - "repository": { - f"c{index}": { - "associatedPullRequests": { - "nodes": [ - { - "number": int(environment.get("TEST_PULL_NUMBER", "2")), - "mergedAt": "2026-10-22T00:00:00Z", - "mergeCommit": { - "oid": commit, - "authors": { - "nodes": [ - {"name": "Release test", "email": "a@example.org", "user": author} - ] - }, + commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:]) + data: dict[str, object] = { + "repository": { + f"c{index}": { + "associatedPullRequests": { + "nodes": [ + { + "number": int(environment.get("TEST_PULL_NUMBER", "2")), + "mergedAt": "2026-10-22T00:00:00Z", + "mergeCommit": { + "oid": commit, + "authors": { + "nodes": [{"name": "Release test", "email": "a@example.org", "user": author}] }, - "author": author, - } - ] - } + }, + "author": author, + } + ] } - for index, commit in enumerate(commits) } + for index, commit in enumerate(commits) } - elif "pullRequest(number: " in query: - numbers = re.findall(r"p(\d+): pullRequest", query) - data = { - "repository": { - f"p{number}": {"number": int(number), "mergedAt": "2026-10-22T00:00:00Z", "author": author} - for number in numbers - } - } - else: - data = { - alias: {"issueCount": int(environment.get("TEST_PRIOR_PULLS", "0"))} - for alias in re.findall(r"(s\d+): search", query) - } + } return Response(json.dumps({"data": data}), mimetype="application/json") server.expect_request("/api/graphql", method="POST").respond_with_handler(graphql) diff --git a/tests/new_contributors/test_new_contributors.py b/tests/new_contributors/test_new_contributors.py new file mode 100644 index 0000000..c6efe1a --- /dev/null +++ b/tests/new_contributors/test_new_contributors.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +import pytest + +_TOOL = Path(__file__).resolve().parents[2] / "tools/new_contributors.py" + + +def test_new_contributors_lead_a_body_of_links_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + notes, generated, output = tmp_path / "notes.md", tmp_path / "generated.md", tmp_path / "body.md" + notes.write_text("[๐Ÿ“œ Full Changelog](https://github.com/tox-dev/fixture/commits/0.1.0)\n") + generated.write_text("## New Contributors\n* @alice made their first contribution in https://example.org/pull/1\n") + monkeypatch.setattr(sys, "argv", ["new_contributors.py", str(notes), str(generated), str(output)]) + + runpy.run_path(str(_TOOL), run_name="__main__") + + assert output.read_text() == ( + "### New Contributors\n\n- @alice made their first contribution in https://example.org/pull/1\n\n" + "[๐Ÿ“œ Full Changelog](https://github.com/tox-dev/fixture/commits/0.1.0)\n" + ) + assert capsys.readouterr().out == "New Contributors: @alice\n" + + +def test_new_contributors_module_imports_without_running() -> None: + assert runpy.run_path(str(_TOOL))["__all__"] == ["main"] diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 2ddb853..7c4fd57 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -98,7 +98,6 @@ def test_prepare_custom_fragment_directory( def test_prepare_renders_github_notes( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: - github_api[1]["TEST_PRIOR_PULLS"] = "1" result = action( repository, tmp_path, @@ -171,7 +170,6 @@ def test_prepare_preserves_prefixed_changelog_heading(repository: Path, tmp_path def test_prepare_links_named_fragments( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: - github_api[1]["TEST_PRIOR_PULLS"] = "1" result = action( repository, tmp_path, @@ -322,20 +320,12 @@ def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) - return environment -@pytest.mark.parametrize( - ("prior_pulls", "credited"), [pytest.param("0", True, id="first-pull"), pytest.param("1", False, id="returning")] -) -def test_prepare_adds_new_contributors_after_building( - repository: Path, - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - prior_pulls: str, - credited: bool, +def test_prepare_leaves_new_contributors_out_of_the_changelog( + repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: - github_api[1]["TEST_PRIOR_PULLS"] = prior_pulls - - result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") + result = action( + repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build", **github_api[1] + ) assert result.returncode == 0, result.stderr - assert ("### New Contributors" in (repository / "CHANGELOG.md").read_text()) is credited + assert "New Contributors" not in (repository / "CHANGELOG.md").read_text() diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index b4e8b94..a0e6912 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -9,6 +9,9 @@ import pytest from conftest import action, run from pytest_httpserver import HTTPServer +from werkzeug import Request + +_GENERATE_NOTES = "/api/v3/repos/tox-dev/fixture/releases/generate-notes" @pytest.fixture @@ -42,6 +45,16 @@ def release_cli(tmp_path: Path) -> dict[str, str]: } +def _expect_generated_notes(server: HTTPServer) -> None: + server.expect_ordered_request(_GENERATE_NOTES, method="POST").respond_with_json( + {"body": "## What's Changed\n* Fix it by @gaborbernat in https://github.com/tox-dev/fixture/pull/1\n"} + ) + + +def _release_requests(server: HTTPServer) -> list[Request]: + return [request for request, _ in server.log if request.path != _GENERATE_NOTES] + + def _release_calls(tmp_path: Path) -> list[tuple[str, ...]]: path = tmp_path / "release-cli-arguments" return ( @@ -139,7 +152,7 @@ def test_existing_published_release_is_verified_without_writes( assets=assets if with_asset else None, ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] + assert [request.method for request in _release_requests(server)] == ["GET"] assert _release_calls(tmp_path) == ( [("release", "verify-asset", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] if with_asset @@ -170,7 +183,7 @@ def test_immutable_release_with_changed_assets_fails( ).respond_with_json([release]) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets) assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] + assert [request.method for request in _release_requests(server)] == ["GET"] assert not _release_calls(tmp_path) @@ -198,12 +211,13 @@ def test_rerun_updates_changed_draft( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft | {changed_field: value}] ) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(draft) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="stage") assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "PATCH", "GET"] - assert server.log[1][0].get_json() == {"name": "0.1.0", "body": notes.read_text(), "prerelease": False} + assert [request.method for request in _release_requests(server)] == ["GET", "PATCH", "GET"] + assert _release_requests(server)[1].get_json() == {"name": "0.1.0", "body": notes.read_text(), "prerelease": False} def test_mutable_published_release_is_rechecked( @@ -223,7 +237,7 @@ def test_mutable_published_release_is_rechecked( server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET"] + assert [request.method for request in _release_requests(server)] == ["GET", "GET"] assert not _release_calls(tmp_path) @@ -255,13 +269,14 @@ def test_staged_release_publishes_without_reupload( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(draft | {"draft": False}) result = _request( repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets, mode="publish" ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET", "PATCH"] + assert [request.method for request in _release_requests(server)] == ["GET", "GET", "PATCH"] assert _release_calls(tmp_path) == [ ("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") ] @@ -288,6 +303,7 @@ def test_missing_release_is_created_then_optionally_published( draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) if mode != "stage": @@ -296,14 +312,14 @@ def test_missing_release_is_created_then_optionally_published( ) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode=mode) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == [ + assert [request.method for request in _release_requests(server)] == [ "GET", "POST", "GET", *(["PATCH"] if mode != "stage" else []), ] assert not _release_calls(tmp_path) - request = next(request for request, _ in server.log if request.method == "POST") + request = next(request for request in _release_requests(server) if request.method == "POST") assert request.get_json() == { "tag_name": "0.1.0", "target_commitish": commit, @@ -346,6 +362,7 @@ def test_draft_release_uploads_missing_asset( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) if mode == "complete": @@ -371,7 +388,7 @@ def test_draft_release_uploads_missing_asset( else [] ), ] - assert [request.method for request, _ in server.log] == [ + assert [request.method for request in _release_requests(server)] == [ "GET", "GET", "GET", @@ -412,6 +429,7 @@ def test_draft_release_replaces_incomplete_asset( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [incomplete] ) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({}) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(incomplete | {"assets": []}) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(uploaded) @@ -427,7 +445,7 @@ def test_draft_release_replaces_incomplete_asset( ) assert result.returncode == 0, result.stderr assert ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture") in _release_calls(tmp_path) - assert [request.method for request, _ in server.log] == ["GET", "DELETE", "GET", "GET"] + assert [request.method for request in _release_requests(server)] == ["GET", "DELETE", "GET", "GET"] def test_changed_draft_is_updated_before_upload( @@ -455,6 +473,7 @@ def test_changed_draft_is_updated_before_upload( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft | {"body": "Human-edited notes.\n"}] ) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(draft) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) @@ -462,7 +481,7 @@ def test_changed_draft_is_updated_before_upload( repository, tmp_path, action_scripts, notes, commit, environment | release_cli, assets=assets, mode="stage" ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "PATCH", "GET", "GET"] + assert [request.method for request in _release_requests(server)] == ["GET", "PATCH", "GET", "GET"] assert _release_calls(tmp_path) == [("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] @@ -498,6 +517,7 @@ def test_failed_upload_deletes_partial_asset_before_retry( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) + _expect_generated_notes(server) for release in (draft, partial, partial): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release) server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({}) @@ -515,7 +535,7 @@ def test_failed_upload_deletes_partial_asset_before_retry( mode="stage", ) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET"] * 4 + ["DELETE", "GET"] + assert [request.method for request in _release_requests(server)] == ["GET"] * 4 + ["DELETE", "GET"] assert _release_calls(tmp_path) == [ ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"), @@ -540,6 +560,7 @@ def test_failed_asset_lookup_stops_retry( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) + _expect_generated_notes(server) for _ in range(2): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_data(status=500) @@ -574,6 +595,7 @@ def test_failed_asset_upload_leaves_draft_unpublished( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) + _expect_generated_notes(server) for _ in range(14): server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) result = _request( @@ -586,7 +608,7 @@ def test_failed_asset_upload_leaves_draft_unpublished( assets=assets, ) assert result.returncode != 0 - assert [request.method for request, _ in server.log] == ["GET"] * 13 + assert [request.method for request in _release_requests(server)] == ["GET"] * 13 assert ( _release_calls(tmp_path) == [("release", "upload", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] * 6 @@ -617,6 +639,7 @@ def test_upload_failure_recovers_when_asset_is_present( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [draft] ) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete) @@ -680,7 +703,7 @@ def test_failed_asset_attestation_is_reported( ) assert result.returncode != 0 assert "release asset attestation could not be verified" in result.stderr - assert [request.method for request, _ in server.log] == ["GET"] + assert [request.method for request in _release_requests(server)] == ["GET"] assert ( _release_calls(tmp_path) == [("release", "verify-asset", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")] * 6 @@ -701,11 +724,12 @@ def test_duplicate_releases_use_the_first( server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json( [first, first | {"id": 43}] ) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(first) server.expect_ordered_request(f"{prefix}/releases/42", method="PATCH").respond_with_json(first | {"draft": False}) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli) assert result.returncode == 0, result.stderr - assert [request.method for request, _ in server.log] == ["GET", "GET", "PATCH"] + assert [request.method for request in _release_requests(server)] == ["GET", "GET", "PATCH"] def test_missing_release_explains_created_draft( @@ -720,6 +744,7 @@ def test_missing_release_explains_created_draft( draft = _release(notes, draft=True) prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) + _expect_generated_notes(server) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) @@ -728,8 +753,52 @@ def test_missing_release_explains_created_draft( assert result.stdout.splitlines() == [ "Release assets: none (assets-directory input is empty)", "Existing GitHub Release 0.1.0: none", + "Generated notes range: first release...0.1.0", + "New Contributors: none in GitHub's generated notes", f"GitHub Release 0.1.0: created draft 42 for {commit} (prerelease false)", "Asset upload skipped: assets-directory input is empty", "GitHub Release 0.1.0: 0 uploaded assets match the expected set", "Publish skipped: mode stage leaves the release as a draft", ] + + +def test_new_release_credits_first_time_contributors_above_the_links( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], +) -> None: + server, environment = github_api + _, commit = _notes_and_commit(repository, tmp_path) + notes = tmp_path / "notes.md" + links = ( + "[๐Ÿ PyPI release](https://pypi.org/project/fixture/0.1.0/)\n\n" + "[๐Ÿ“œ Full Changelog](https://github.com/tox-dev/fixture/compare/0.0.9...0.1.0)\n" + ) + notes.write_text(f"### Bug fixes\n\n- Fix it.\n\n{links}") + credited = ( + "### Bug fixes\n\n- Fix it.\n\n### New Contributors\n\n" + "- @alice made their first contribution in https://github.com/tox-dev/fixture/pull/3\n\n" + f"{links}" + ) + draft = _release(notes, draft=True) | {"body": credited} + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) + server.expect_ordered_request(_GENERATE_NOTES, method="POST").respond_with_json( + { + "body": "## New Contributors\n" + "* @alice made their first contribution in https://github.com/tox-dev/fixture/pull/3\n" + "* @dependabot[bot] made their first contribution in https://github.com/tox-dev/fixture/pull/4\n\n" + "**Full Changelog**: https://github.com/tox-dev/fixture/compare/0.0.9...0.1.0\n" + } + ) + server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) + server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) + + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="stage") + + assert result.returncode == 0, result.stderr + generate, create = (request.get_json() for request, _ in server.log if request.method == "POST") + assert generate == {"tag_name": "0.1.0", "target_commitish": commit, "previous_tag_name": "0.0.9"} + assert create["body"] == credited diff --git a/tools/new_contributors.py b/tools/new_contributors.py new file mode 100644 index 0000000..7fe465a --- /dev/null +++ b/tools/new_contributors.py @@ -0,0 +1,40 @@ +"""Insert the New Contributors list from GitHub's generated notes ahead of the trailing link rows of a release body.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Final + +_FIRST_CONTRIBUTION: Final = re.compile(r"^\* (@\S+) made their first contribution in (\S+)$") +_LINK_ROW: Final = re.compile(r"^\[[^\]]+\]\(\S+\)(, \[[^\]]+\]\(\S+\))*$") + + +def main() -> None: + notes, generated, output = (Path(argument) for argument in sys.argv[1:4]) + credits = [ + f"- {match[1]} made their first contribution in {match[2]}" + for line in generated.read_text(encoding="utf-8").splitlines() + if (match := _FIRST_CONTRIBUTION.match(line.strip())) and not match[1].endswith("[bot]") + ] + body = notes.read_text(encoding="utf-8") + if credits: + paragraphs = body.strip().split("\n\n") + links = len(paragraphs) + while links and _LINK_ROW.match(paragraphs[links - 1].strip()): + links -= 1 + section = "### New Contributors\n\n" + "\n".join(credits) + body = "\n\n".join([*paragraphs[:links], section, *paragraphs[links:]]) + "\n" + output.write_text(body, encoding="utf-8") + names = ", ".join(credit.split()[1] for credit in credits) + print(f"New Contributors: {names}" if credits else "New Contributors: none in GitHub's generated notes") + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +]