Skip to content

Commit 6816f21

Browse files
authored
feat: add generic first merged PR comment action (#172)
Signed-off-by: Vitor Mattos <vitor@php.rio>
1 parent 44cabff commit 6816f21

8 files changed

Lines changed: 771 additions & 0 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
name: First merged PR comment
5+
description: Comment on a contributor's first merged pull request using a strict message template.
6+
7+
inputs:
8+
github-token:
9+
description: Token used to inspect merged pull requests and create the comment.
10+
required: true
11+
message-template:
12+
description: Message template rendered with built-in placeholders.
13+
required: true
14+
pull-request-number:
15+
description: Pull request number. Required for manual retries; otherwise read from the event.
16+
required: false
17+
default: ''
18+
19+
outputs:
20+
is-first-merged:
21+
description: Whether the pull request is the contributor's first merged pull request.
22+
value: ${{ steps.comment.outputs.is-first-merged }}
23+
comment-created:
24+
description: Whether this invocation created the comment.
25+
value: ${{ steps.comment.outputs.comment-created }}
26+
contributor-login:
27+
description: Contributor login resolved from the pull request.
28+
value: ${{ steps.comment.outputs.contributor-login }}
29+
pull-request-number:
30+
description: Pull request number that was processed.
31+
value: ${{ steps.comment.outputs.pull-request-number }}
32+
33+
runs:
34+
using: composite
35+
steps:
36+
- id: comment
37+
name: Comment on first merged pull request
38+
shell: bash
39+
env:
40+
FIRST_MERGED_PR_GITHUB_TOKEN: ${{ inputs.github-token }}
41+
FIRST_MERGED_PR_MESSAGE_TEMPLATE: ${{ inputs.message-template }}
42+
FIRST_MERGED_PR_NUMBER: ${{ inputs.pull-request-number }}
43+
run: |
44+
set -euo pipefail
45+
python3 "${GITHUB_ACTION_PATH}/first_merged_pr_comment.py"
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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 re
10+
import urllib.error
11+
import urllib.parse
12+
import urllib.request
13+
from pathlib import Path
14+
from typing import Any, Callable
15+
16+
MARKER = "<!-- librecode:first-merged-pr-comment -->"
17+
PLACEHOLDER = re.compile(r"\{([a-z][a-z0-9_]*)(?:\|([a-z][a-z0-9_]*))?\}")
18+
ALLOWED_FILTERS = {"urlencode"}
19+
ApiRequest = Callable[[str, str, str, dict[str, Any] | None], Any]
20+
21+
22+
class ActionError(RuntimeError):
23+
pass
24+
25+
26+
def render_template(template: str, context: dict[str, str]) -> str:
27+
if not template.strip():
28+
raise ActionError("message template is empty")
29+
30+
def replace(match: re.Match[str]) -> str:
31+
name, filter_name = match.groups()
32+
if name not in context:
33+
raise ActionError(f"unknown placeholder: {name}")
34+
value = context[name]
35+
if filter_name is None:
36+
return value
37+
if filter_name not in ALLOWED_FILTERS:
38+
raise ActionError(f"unknown placeholder filter: {filter_name}")
39+
return urllib.parse.quote(value, safe="")
40+
41+
return PLACEHOLDER.sub(replace, template)
42+
43+
44+
def build_context(
45+
*,
46+
pr: dict[str, Any],
47+
repository: str,
48+
server_url: str,
49+
api_url: str,
50+
) -> dict[str, str]:
51+
owner, repository_name = repository.split("/", 1)
52+
login = str(pr["user"]["login"])
53+
number = str(pr["number"])
54+
clean_server_url = server_url.rstrip("/")
55+
return {
56+
"server_url": clean_server_url,
57+
"api_url": api_url.rstrip("/"),
58+
"repository": repository,
59+
"repository_owner": owner,
60+
"repository_name": repository_name,
61+
"repository_url": f"{clean_server_url}/{repository}",
62+
"pull_request_number": number,
63+
"pull_request_url": str(
64+
pr.get("html_url")
65+
or f"{clean_server_url}/{repository}/pull/{number}"
66+
),
67+
"contributor_login": login,
68+
"contributor_mention": f"@{login}",
69+
"contributor_url": f"{clean_server_url}/{login}",
70+
"merge_commit_sha": str(pr.get("merge_commit_sha") or ""),
71+
}
72+
73+
74+
def build_api_request(
75+
method: str,
76+
url: str,
77+
token: str,
78+
payload: dict[str, Any] | None = None,
79+
) -> urllib.request.Request:
80+
data = None if payload is None else json.dumps(payload).encode("utf-8")
81+
request = urllib.request.Request(
82+
url,
83+
data=data,
84+
method=method,
85+
headers={
86+
"Accept": "application/vnd.github+json",
87+
"Content-Type": "application/json",
88+
"X-GitHub-Api-Version": "2022-11-28",
89+
},
90+
)
91+
# Keep credentials off redirected requests. urllib forwards normal headers
92+
# across redirects, which could otherwise disclose the GitHub token if an
93+
# API endpoint ever redirected to a different origin.
94+
request.add_unredirected_header("Authorization", f"Bearer {token}")
95+
return request
96+
97+
98+
def api_request(
99+
method: str,
100+
url: str,
101+
token: str,
102+
payload: dict[str, Any] | None = None,
103+
) -> Any:
104+
request = build_api_request(method, url, token, payload)
105+
try:
106+
with urllib.request.urlopen(request, timeout=30) as response:
107+
body = response.read().decode("utf-8")
108+
except urllib.error.HTTPError as error:
109+
body = error.read().decode("utf-8", errors="replace")
110+
raise ActionError(f"GitHub API request failed ({error.code}): {body}") from error
111+
return json.loads(body) if body else None
112+
113+
114+
def pull_request_from_event(event_path: str) -> dict[str, Any] | None:
115+
if not event_path:
116+
return None
117+
payload = json.loads(Path(event_path).read_text(encoding="utf-8"))
118+
pr = payload.get("pull_request")
119+
return pr if isinstance(pr, dict) else None
120+
121+
122+
def write_output(name: str, value: str) -> None:
123+
path = os.environ.get("GITHUB_OUTPUT")
124+
if not path:
125+
return
126+
with Path(path).open("a", encoding="utf-8") as handle:
127+
handle.write(f"{name}={value}\n")
128+
129+
130+
def previous_merged_query(repository: str, login: str, closed_at: str) -> str:
131+
return " ".join(
132+
(
133+
f"repo:{repository}",
134+
"is:pr",
135+
"is:merged",
136+
f"author:{login}",
137+
f"closed:<{closed_at}",
138+
)
139+
)
140+
141+
142+
def has_action_marker_comment(
143+
*,
144+
api_url: str,
145+
repository: str,
146+
pull_request_number: int,
147+
token: str,
148+
request: ApiRequest = api_request,
149+
) -> bool:
150+
owner, repo = repository.split("/", 1)
151+
page = 1
152+
while True:
153+
batch = request(
154+
"GET",
155+
f"{api_url}/repos/{owner}/{repo}/issues/{pull_request_number}/comments"
156+
f"?per_page=100&page={page}",
157+
token,
158+
None,
159+
)
160+
for comment in batch:
161+
author = comment.get("user") or {}
162+
if (
163+
author.get("type") == "Bot"
164+
and MARKER in str(comment.get("body") or "")
165+
):
166+
return True
167+
if len(batch) < 100:
168+
return False
169+
page += 1
170+
171+
172+
def process_pull_request(
173+
*,
174+
pr: dict[str, Any],
175+
repository: str,
176+
token: str,
177+
api_url: str,
178+
server_url: str,
179+
template: str,
180+
request: ApiRequest = api_request,
181+
) -> dict[str, str]:
182+
result = {
183+
"is-first-merged": "false",
184+
"comment-created": "false",
185+
"contributor-login": str(pr["user"]["login"]),
186+
"pull-request-number": str(pr["number"]),
187+
}
188+
189+
if not pr.get("merged") or pr.get("user", {}).get("type") == "Bot":
190+
return result
191+
192+
login = str(pr["user"]["login"])
193+
query = previous_merged_query(repository, login, str(pr["closed_at"]))
194+
encoded_query = urllib.parse.urlencode({"q": query, "per_page": 1})
195+
search = request(
196+
"GET",
197+
f"{api_url}/search/issues?{encoded_query}",
198+
token,
199+
None,
200+
)
201+
# Search only for earlier merged PRs. Do not require the current PR to
202+
# have reached the search index yet; the closed event can arrive before
203+
# search indexing catches up.
204+
if int(search["total_count"]) != 0:
205+
return result
206+
207+
result["is-first-merged"] = "true"
208+
209+
if has_action_marker_comment(
210+
api_url=api_url,
211+
repository=repository,
212+
pull_request_number=int(pr["number"]),
213+
token=token,
214+
request=request,
215+
):
216+
return result
217+
218+
context = build_context(
219+
pr=pr,
220+
repository=repository,
221+
server_url=server_url,
222+
api_url=api_url,
223+
)
224+
message = render_template(template, context).strip()
225+
226+
owner, repo = repository.split("/", 1)
227+
request(
228+
"POST",
229+
f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments",
230+
token,
231+
{"body": f"{MARKER}\n{message}"},
232+
)
233+
result["comment-created"] = "true"
234+
return result
235+
236+
237+
def main() -> int:
238+
token = os.environ.get("FIRST_MERGED_PR_GITHUB_TOKEN", "")
239+
template = os.environ.get("FIRST_MERGED_PR_MESSAGE_TEMPLATE", "")
240+
manual_number = os.environ.get("FIRST_MERGED_PR_NUMBER", "").strip()
241+
repository = os.environ.get("GITHUB_REPOSITORY", "")
242+
api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
243+
server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/")
244+
245+
if not token:
246+
raise ActionError("github token is required")
247+
if "/" not in repository:
248+
raise ActionError("GITHUB_REPOSITORY must be in owner/name form")
249+
250+
pr = pull_request_from_event(os.environ.get("GITHUB_EVENT_PATH", ""))
251+
if pr is None:
252+
if not manual_number.isdigit() or int(manual_number) <= 0:
253+
raise ActionError("a valid pull-request-number is required for a manual run")
254+
owner, repo = repository.split("/", 1)
255+
pr = api_request(
256+
"GET",
257+
f"{api_url}/repos/{owner}/{repo}/pulls/{int(manual_number)}",
258+
token,
259+
)
260+
261+
result = process_pull_request(
262+
pr=pr,
263+
repository=repository,
264+
token=token,
265+
api_url=api_url,
266+
server_url=server_url,
267+
template=template,
268+
)
269+
for name, value in result.items():
270+
write_output(name, value)
271+
272+
if result["comment-created"] == "true":
273+
print(
274+
f"Created first-merged contribution comment on "
275+
f"PR #{result['pull-request-number']}."
276+
)
277+
elif result["is-first-merged"] == "true":
278+
print(
279+
f"PR #{result['pull-request-number']} already has a "
280+
"first-merged contribution comment; skipping."
281+
)
282+
else:
283+
print(
284+
f"PR #{result['pull-request-number']} is not the contributor's "
285+
"first merged pull request; skipping."
286+
)
287+
return 0
288+
289+
290+
if __name__ == "__main__":
291+
try:
292+
raise SystemExit(main())
293+
except (ActionError, KeyError, OSError, ValueError, json.JSONDecodeError) as error:
294+
print(f"::error::{error}")
295+
raise SystemExit(1) from error

0 commit comments

Comments
 (0)