Skip to content

Commit 5544dee

Browse files
committed
feat: add generic first merged PR comment action
Signed-off-by: Vitor Mattos <vitor@php.rio>
1 parent 9065385 commit 5544dee

3 files changed

Lines changed: 371 additions & 0 deletions

File tree

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: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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
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+
20+
21+
class ActionError(RuntimeError):
22+
pass
23+
24+
25+
def render_template(template: str, context: dict[str, str]) -> str:
26+
if not template.strip():
27+
raise ActionError("message template is empty")
28+
29+
def replace(match: re.Match[str]) -> str:
30+
name, filter_name = match.groups()
31+
if name not in context:
32+
raise ActionError(f"unknown placeholder: {name}")
33+
value = context[name]
34+
if filter_name is None:
35+
return value
36+
if filter_name not in ALLOWED_FILTERS:
37+
raise ActionError(f"unknown placeholder filter: {filter_name}")
38+
if filter_name == "urlencode":
39+
return urllib.parse.quote(value, safe="")
40+
raise AssertionError(filter_name)
41+
42+
return PLACEHOLDER.sub(replace, template)
43+
44+
45+
def build_context(
46+
*,
47+
pr: dict[str, Any],
48+
repository: str,
49+
server_url: str,
50+
api_url: str,
51+
) -> dict[str, str]:
52+
owner, repository_name = repository.split("/", 1)
53+
login = str(pr["user"]["login"])
54+
number = str(pr["number"])
55+
return {
56+
"server_url": server_url.rstrip("/"),
57+
"api_url": api_url.rstrip("/"),
58+
"repository": repository,
59+
"repository_owner": owner,
60+
"repository_name": repository_name,
61+
"repository_url": f"{server_url.rstrip('/')}/{repository}",
62+
"pull_request_number": number,
63+
"pull_request_url": str(
64+
pr.get("html_url")
65+
or f"{server_url.rstrip('/')}/{repository}/pull/{number}"
66+
),
67+
"contributor_login": login,
68+
"contributor_mention": f"@{login}",
69+
"contributor_url": f"{server_url.rstrip('/')}/{login}",
70+
"merge_commit_sha": str(pr.get("merge_commit_sha") or ""),
71+
}
72+
73+
74+
def api_request(
75+
method: str,
76+
url: str,
77+
token: str,
78+
payload: dict[str, Any] | None = None,
79+
) -> Any:
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+
"Authorization": f"Bearer {token}",
88+
"Content-Type": "application/json",
89+
"X-GitHub-Api-Version": "2022-11-28",
90+
},
91+
)
92+
try:
93+
with urllib.request.urlopen(request, timeout=30) as response:
94+
body = response.read().decode("utf-8")
95+
except urllib.error.HTTPError as error:
96+
body = error.read().decode("utf-8", errors="replace")
97+
raise ActionError(f"GitHub API request failed ({error.code}): {body}") from error
98+
return json.loads(body) if body else None
99+
100+
101+
def pull_request_from_event(event_path: str) -> dict[str, Any] | None:
102+
if not event_path:
103+
return None
104+
payload = json.loads(Path(event_path).read_text(encoding="utf-8"))
105+
pr = payload.get("pull_request")
106+
return pr if isinstance(pr, dict) else None
107+
108+
109+
def write_output(name: str, value: str) -> None:
110+
path = os.environ.get("GITHUB_OUTPUT")
111+
if not path:
112+
return
113+
with Path(path).open("a", encoding="utf-8") as handle:
114+
handle.write(f"{name}={value}\n")
115+
116+
117+
def first_merged_query(repository: str, login: str, closed_at: str) -> str:
118+
return " ".join(
119+
(
120+
f"repo:{repository}",
121+
"is:pr",
122+
"is:merged",
123+
f"author:{login}",
124+
f"closed:<={closed_at}",
125+
)
126+
)
127+
128+
129+
def main() -> int:
130+
token = os.environ.get("FIRST_MERGED_PR_GITHUB_TOKEN", "")
131+
template = os.environ.get("FIRST_MERGED_PR_MESSAGE_TEMPLATE", "")
132+
manual_number = os.environ.get("FIRST_MERGED_PR_NUMBER", "").strip()
133+
repository = os.environ.get("GITHUB_REPOSITORY", "")
134+
api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
135+
server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/")
136+
137+
if not token:
138+
raise ActionError("github token is required")
139+
if "/" not in repository:
140+
raise ActionError("GITHUB_REPOSITORY must be in owner/name form")
141+
142+
pr = pull_request_from_event(os.environ.get("GITHUB_EVENT_PATH", ""))
143+
if pr is None:
144+
if not manual_number.isdigit() or int(manual_number) <= 0:
145+
raise ActionError("a valid pull-request-number is required for a manual run")
146+
owner, repo = repository.split("/", 1)
147+
pr = api_request(
148+
"GET",
149+
f"{api_url}/repos/{owner}/{repo}/pulls/{int(manual_number)}",
150+
token,
151+
)
152+
153+
write_output("contributor-login", str(pr["user"]["login"]))
154+
write_output("pull-request-number", str(pr["number"]))
155+
156+
if not pr.get("merged") or pr.get("user", {}).get("type") == "Bot":
157+
write_output("is-first-merged", "false")
158+
write_output("comment-created", "false")
159+
print("Pull request is not a merged human contribution; skipping.")
160+
return 0
161+
162+
login = str(pr["user"]["login"])
163+
closed_at = str(pr["closed_at"])
164+
query = first_merged_query(repository, login, closed_at)
165+
encoded_query = urllib.parse.urlencode({"q": query, "per_page": 2})
166+
search = api_request("GET", f"{api_url}/search/issues?{encoded_query}", token)
167+
total_count = int(search["total_count"])
168+
169+
if total_count != 1:
170+
write_output("is-first-merged", "false")
171+
write_output("comment-created", "false")
172+
print(
173+
f"PR #{pr['number']} is not the contributor's first merged pull request; "
174+
f"found {total_count} merged pull requests up to this one."
175+
)
176+
return 0
177+
178+
write_output("is-first-merged", "true")
179+
180+
owner, repo = repository.split("/", 1)
181+
comments = api_request(
182+
"GET",
183+
f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments?per_page=100",
184+
token,
185+
)
186+
if any(MARKER in str(comment.get("body") or "") for comment in comments):
187+
write_output("comment-created", "false")
188+
print(f"PR #{pr['number']} already has a first-merged comment; skipping.")
189+
return 0
190+
191+
context = build_context(
192+
pr=pr,
193+
repository=repository,
194+
server_url=server_url,
195+
api_url=api_url,
196+
)
197+
message = render_template(template, context).strip()
198+
api_request(
199+
"POST",
200+
f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments",
201+
token,
202+
{"body": f"{MARKER}\n{message}"},
203+
)
204+
write_output("comment-created", "true")
205+
print(f"Created first-merged contribution comment on PR #{pr['number']}.")
206+
return 0
207+
208+
209+
if __name__ == "__main__":
210+
try:
211+
raise SystemExit(main())
212+
except (ActionError, KeyError, OSError, ValueError, json.JSONDecodeError) as error:
213+
print(f"::error::{error}")
214+
raise SystemExit(1) from error
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
from __future__ import annotations
5+
6+
import importlib.util
7+
import unittest
8+
from pathlib import Path
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
SCRIPT = ROOT / "actions" / "first-merged-pr-comment" / "first_merged_pr_comment.py"
12+
13+
spec = importlib.util.spec_from_file_location("first_merged_pr_comment", SCRIPT)
14+
assert spec is not None and spec.loader is not None
15+
module = importlib.util.module_from_spec(spec)
16+
spec.loader.exec_module(module)
17+
18+
19+
class FirstMergedPrCommentTest(unittest.TestCase):
20+
def setUp(self) -> None:
21+
self.pr = {
22+
"number": 42,
23+
"html_url": "https://git.example/acme/project/pull/42",
24+
"merged": True,
25+
"closed_at": "2026-09-23T12:00:00Z",
26+
"merge_commit_sha": "abc123",
27+
"user": {"login": "alice", "type": "User"},
28+
}
29+
30+
def test_build_context_is_generic(self) -> None:
31+
context = module.build_context(
32+
pr=self.pr,
33+
repository="acme/project",
34+
server_url="https://git.example",
35+
api_url="https://git.example/api/v3",
36+
)
37+
self.assertEqual(context["server_url"], "https://git.example")
38+
self.assertEqual(context["repository"], "acme/project")
39+
self.assertEqual(context["repository_owner"], "acme")
40+
self.assertEqual(context["repository_name"], "project")
41+
self.assertEqual(context["repository_url"], "https://git.example/acme/project")
42+
self.assertEqual(context["pull_request_number"], "42")
43+
self.assertEqual(context["pull_request_url"], "https://git.example/acme/project/pull/42")
44+
self.assertEqual(context["contributor_login"], "alice")
45+
self.assertEqual(context["contributor_mention"], "@alice")
46+
self.assertEqual(context["contributor_url"], "https://git.example/alice")
47+
self.assertEqual(context["merge_commit_sha"], "abc123")
48+
49+
def test_render_template_composes_arbitrary_urls(self) -> None:
50+
context = module.build_context(
51+
pr=self.pr,
52+
repository="acme/project",
53+
server_url="https://git.example",
54+
api_url="https://git.example/api/v3",
55+
)
56+
template = (
57+
"Hello {contributor_mention}. "
58+
"Docs: {repository_url}/docs. "
59+
"Survey: https://survey.example/form?repo={repository|urlencode}"
60+
"&user={contributor_login|urlencode}&pr={pull_request_number}."
61+
)
62+
rendered = module.render_template(template, context)
63+
self.assertEqual(
64+
rendered,
65+
"Hello @alice. Docs: https://git.example/acme/project/docs. "
66+
"Survey: https://survey.example/form?repo=acme%2Fproject"
67+
"&user=alice&pr=42.",
68+
)
69+
70+
def test_render_template_rejects_unknown_placeholder(self) -> None:
71+
with self.assertRaisesRegex(module.ActionError, "unknown placeholder: survey_url"):
72+
module.render_template("{survey_url}", {"repository": "acme/project"})
73+
74+
def test_render_template_rejects_unknown_filter(self) -> None:
75+
with self.assertRaisesRegex(module.ActionError, "unknown placeholder filter: shell"):
76+
module.render_template("{repository|shell}", {"repository": "acme/project"})
77+
78+
def test_render_template_rejects_empty_message(self) -> None:
79+
with self.assertRaisesRegex(module.ActionError, "message template is empty"):
80+
module.render_template(" ", {})
81+
82+
def test_first_merged_query_is_historical_for_safe_retries(self) -> None:
83+
query = module.first_merged_query(
84+
"acme/project",
85+
"alice",
86+
"2026-09-23T12:00:00Z",
87+
)
88+
self.assertEqual(
89+
query,
90+
"repo:acme/project is:pr is:merged author:alice "
91+
"closed:<=2026-09-23T12:00:00Z",
92+
)
93+
94+
def test_marker_is_stable_for_idempotency(self) -> None:
95+
self.assertEqual(
96+
module.MARKER,
97+
"<!-- librecode:first-merged-pr-comment -->",
98+
)
99+
100+
def test_action_contract_has_no_product_specific_inputs(self) -> None:
101+
action = (
102+
ROOT / "actions" / "first-merged-pr-comment" / "action.yml"
103+
).read_text(encoding="utf-8")
104+
self.assertIn("message-template:", action)
105+
self.assertIn("pull-request-number:", action)
106+
self.assertNotIn("survey", action.lower())
107+
self.assertNotIn("community", action.lower())
108+
self.assertNotIn("good first issue", action.lower())
109+
110+
111+
if __name__ == "__main__":
112+
unittest.main()

0 commit comments

Comments
 (0)