Skip to content

Commit db491a5

Browse files
authored
refactor: extract pull request release notes action (#174)
Signed-off-by: Vitor Mattos <vitor@php.rio>
1 parent a612212 commit db491a5

3 files changed

Lines changed: 547 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
name: Release notes from pull requests
5+
description: Build a Markdown change list from commits, preferring associated merged pull requests.
6+
7+
inputs:
8+
github-token:
9+
description: Token used to resolve pull requests associated with commits.
10+
required: true
11+
repository:
12+
description: Repository in owner/name form.
13+
required: true
14+
branch:
15+
description: Preferred pull request base branch.
16+
required: true
17+
working-directory:
18+
description: Git working tree used to enumerate commits.
19+
required: false
20+
default: '.'
21+
from-ref:
22+
description: Optional lower-bound Git ref. When empty, fallback-limit commits are used.
23+
required: false
24+
default: ''
25+
to-ref:
26+
description: Upper-bound Git ref.
27+
required: false
28+
default: 'HEAD'
29+
fallback-limit:
30+
description: Number of commits to inspect when from-ref is empty.
31+
required: false
32+
default: '10'
33+
34+
outputs:
35+
changes-file:
36+
description: Path to the generated Markdown change list.
37+
value: ${{ steps.generate.outputs.changes-file }}
38+
change-count:
39+
description: Number of rendered change entries.
40+
value: ${{ steps.generate.outputs.change-count }}
41+
pull-request-count:
42+
description: Number of unique pull request entries.
43+
value: ${{ steps.generate.outputs.pull-request-count }}
44+
commit-fallback-count:
45+
description: Number of direct commit fallback entries.
46+
value: ${{ steps.generate.outputs.commit-fallback-count }}
47+
48+
runs:
49+
using: composite
50+
steps:
51+
- id: generate
52+
name: Generate release note changes
53+
shell: bash
54+
env:
55+
RELEASE_NOTES_GITHUB_TOKEN: ${{ inputs.github-token }}
56+
RELEASE_NOTES_REPOSITORY: ${{ inputs.repository }}
57+
RELEASE_NOTES_BRANCH: ${{ inputs.branch }}
58+
RELEASE_NOTES_WORKING_DIRECTORY: ${{ inputs.working-directory }}
59+
RELEASE_NOTES_FROM_REF: ${{ inputs.from-ref }}
60+
RELEASE_NOTES_TO_REF: ${{ inputs.to-ref }}
61+
RELEASE_NOTES_FALLBACK_LIMIT: ${{ inputs.fallback-limit }}
62+
run: |
63+
set -euo pipefail
64+
python3 "${GITHUB_ACTION_PATH}/generate.py"
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
from __future__ import annotations
6+
7+
import json
8+
import os
9+
import subprocess
10+
import tempfile
11+
import urllib.error
12+
import urllib.request
13+
from pathlib import Path
14+
from typing import Any, Callable
15+
16+
ApiRequest = Callable[[str], Any]
17+
18+
19+
class ActionError(RuntimeError):
20+
pass
21+
22+
23+
def sanitize_markdown_text(value: str) -> str:
24+
normalized = " ".join(value.replace("\r", "\n").splitlines()).strip()
25+
for character in ("\\", "`", "*", "_", "{", "}", "[", "]", "<", ">"):
26+
normalized = normalized.replace(character, f"\\{character}")
27+
# PR titles and commit subjects can be contributor-controlled. Keep their
28+
# visible text while preventing them from creating GitHub @mentions.
29+
return normalized.replace("@", "@\u200b")
30+
31+
32+
def choose_pull_request(
33+
pull_requests: list[dict[str, Any]],
34+
preferred_branch: str,
35+
) -> dict[str, Any] | None:
36+
merged = [
37+
pr
38+
for pr in pull_requests
39+
if pr.get("merged_at") and isinstance(pr.get("number"), int)
40+
]
41+
if not merged:
42+
return None
43+
44+
preferred = [
45+
pr
46+
for pr in merged
47+
if isinstance(pr.get("base"), dict)
48+
and pr["base"].get("ref") == preferred_branch
49+
]
50+
candidates = preferred or merged
51+
return min(candidates, key=lambda pr: int(pr["number"]))
52+
53+
54+
def build_api_request(url: str, token: str) -> urllib.request.Request:
55+
request = urllib.request.Request(
56+
url,
57+
method="GET",
58+
headers={
59+
"Accept": "application/vnd.github+json",
60+
"X-GitHub-Api-Version": "2022-11-28",
61+
},
62+
)
63+
request.add_unredirected_header("Authorization", f"Bearer {token}")
64+
return request
65+
66+
67+
def github_api_get(url: str, token: str) -> Any:
68+
request = build_api_request(url, token)
69+
try:
70+
with urllib.request.urlopen(request, timeout=30) as response:
71+
body = response.read().decode("utf-8")
72+
except urllib.error.HTTPError as error:
73+
body = error.read().decode("utf-8", errors="replace")
74+
raise ActionError(
75+
f"GitHub API request failed ({error.code}): {body}"
76+
) from error
77+
return json.loads(body)
78+
79+
80+
def git_lines(working_directory: Path, *args: str) -> list[str]:
81+
result = subprocess.run(
82+
["git", *args],
83+
cwd=working_directory,
84+
check=True,
85+
stdout=subprocess.PIPE,
86+
stderr=subprocess.PIPE,
87+
text=True,
88+
)
89+
return [line for line in result.stdout.splitlines() if line]
90+
91+
92+
def enumerate_commits(
93+
working_directory: Path,
94+
from_ref: str,
95+
to_ref: str,
96+
fallback_limit: int,
97+
) -> list[str]:
98+
if from_ref:
99+
return git_lines(
100+
working_directory,
101+
"rev-list",
102+
"--reverse",
103+
f"{from_ref}..{to_ref}",
104+
)
105+
return git_lines(
106+
working_directory,
107+
"rev-list",
108+
"--reverse",
109+
f"--max-count={fallback_limit}",
110+
to_ref,
111+
)
112+
113+
114+
def commit_subject(working_directory: Path, sha: str) -> str:
115+
lines = git_lines(working_directory, "show", "-s", "--format=%s", sha)
116+
if not lines:
117+
raise ActionError(f"cannot resolve subject for commit {sha}")
118+
return sanitize_markdown_text(lines[0])
119+
120+
121+
def generate_changes(
122+
*,
123+
commits: list[str],
124+
repository: str,
125+
branch: str,
126+
server_url: str,
127+
api_url: str,
128+
token: str,
129+
subject_lookup: Callable[[str], str],
130+
request: Callable[[str, str], Any] = github_api_get,
131+
) -> tuple[list[str], int, int]:
132+
seen_pull_requests: set[int] = set()
133+
lines: list[str] = []
134+
pull_request_count = 0
135+
commit_fallback_count = 0
136+
137+
owner, repo = repository.split("/", 1)
138+
clean_server_url = server_url.rstrip("/")
139+
clean_api_url = api_url.rstrip("/")
140+
141+
for sha in commits:
142+
pull_requests = request(
143+
f"{clean_api_url}/repos/{owner}/{repo}/commits/{sha}/pulls",
144+
token,
145+
)
146+
if not isinstance(pull_requests, list):
147+
raise ActionError(
148+
f"unexpected pull request response for commit {sha}"
149+
)
150+
151+
pull_request = choose_pull_request(pull_requests, branch)
152+
if pull_request is not None:
153+
number = int(pull_request["number"])
154+
if number in seen_pull_requests:
155+
continue
156+
seen_pull_requests.add(number)
157+
title = sanitize_markdown_text(str(pull_request.get("title") or ""))
158+
if not title:
159+
title = f"Pull request #{number}"
160+
url = f"{clean_server_url}/{repository}/pull/{number}"
161+
lines.append(f"- {title} ([#{number}]({url}))")
162+
pull_request_count += 1
163+
continue
164+
165+
subject = subject_lookup(sha)
166+
lines.append(f"- {subject} (`{sha[:7]}`)")
167+
commit_fallback_count += 1
168+
169+
return lines, pull_request_count, commit_fallback_count
170+
171+
172+
def write_output(name: str, value: str) -> None:
173+
output = os.environ.get("GITHUB_OUTPUT")
174+
if not output:
175+
return
176+
with Path(output).open("a", encoding="utf-8") as handle:
177+
handle.write(f"{name}={value}\n")
178+
179+
180+
def main() -> int:
181+
token = os.environ.get("RELEASE_NOTES_GITHUB_TOKEN", "")
182+
repository = os.environ.get("RELEASE_NOTES_REPOSITORY", "")
183+
branch = os.environ.get("RELEASE_NOTES_BRANCH", "")
184+
working_directory = Path(
185+
os.environ.get("RELEASE_NOTES_WORKING_DIRECTORY", ".")
186+
).resolve()
187+
from_ref = os.environ.get("RELEASE_NOTES_FROM_REF", "").strip()
188+
to_ref = os.environ.get("RELEASE_NOTES_TO_REF", "HEAD").strip() or "HEAD"
189+
fallback_limit_raw = os.environ.get("RELEASE_NOTES_FALLBACK_LIMIT", "10")
190+
server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
191+
api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com")
192+
193+
if not token:
194+
raise ActionError("github token is required")
195+
if repository.count("/") != 1:
196+
raise ActionError("repository must be in owner/name form")
197+
if not branch:
198+
raise ActionError("branch is required")
199+
if not working_directory.is_dir():
200+
raise ActionError(f"working directory does not exist: {working_directory}")
201+
202+
try:
203+
fallback_limit = int(fallback_limit_raw)
204+
except ValueError as error:
205+
raise ActionError("fallback-limit must be an integer") from error
206+
if fallback_limit <= 0:
207+
raise ActionError("fallback-limit must be greater than zero")
208+
209+
commits = enumerate_commits(
210+
working_directory,
211+
from_ref,
212+
to_ref,
213+
fallback_limit,
214+
)
215+
lines, pull_request_count, commit_fallback_count = generate_changes(
216+
commits=commits,
217+
repository=repository,
218+
branch=branch,
219+
server_url=server_url,
220+
api_url=api_url,
221+
token=token,
222+
subject_lookup=lambda sha: commit_subject(working_directory, sha),
223+
)
224+
225+
runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir()))
226+
runner_temp.mkdir(parents=True, exist_ok=True)
227+
with tempfile.NamedTemporaryFile(
228+
mode="w",
229+
encoding="utf-8",
230+
prefix="release-note-changes-",
231+
suffix=".md",
232+
dir=runner_temp,
233+
delete=False,
234+
) as handle:
235+
for line in lines:
236+
handle.write(f"{line}\n")
237+
changes_file = Path(handle.name)
238+
239+
write_output("changes-file", str(changes_file))
240+
write_output("change-count", str(len(lines)))
241+
write_output("pull-request-count", str(pull_request_count))
242+
write_output("commit-fallback-count", str(commit_fallback_count))
243+
244+
print(
245+
f"Generated {len(lines)} change entries "
246+
f"({pull_request_count} pull requests, "
247+
f"{commit_fallback_count} direct commits)."
248+
)
249+
return 0
250+
251+
252+
if __name__ == "__main__":
253+
try:
254+
raise SystemExit(main())
255+
except (
256+
ActionError,
257+
OSError,
258+
subprocess.CalledProcessError,
259+
json.JSONDecodeError,
260+
) as error:
261+
print(f"::error::{error}")
262+
raise SystemExit(1) from error

0 commit comments

Comments
 (0)