From 90653855bab01804ca3a0c15b0c679d1e0ed48d7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 15:38:39 -0300 Subject: [PATCH 01/23] feat: add first merged contribution workflow Signed-off-by: Vitor Mattos --- docs/first-merged-contribution.md | 55 ++++++ workflow-catalog.json | 1 + .../first-merged-contribution.properties.json | 8 + ...erged-contribution.properties.json.license | 2 + .../first-merged-contribution.yml | 167 ++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 docs/first-merged-contribution.md create mode 100644 workflow-templates/first-merged-contribution.properties.json create mode 100644 workflow-templates/first-merged-contribution.properties.json.license create mode 100644 workflow-templates/first-merged-contribution.yml diff --git a/docs/first-merged-contribution.md b/docs/first-merged-contribution.md new file mode 100644 index 0000000..9376142 --- /dev/null +++ b/docs/first-merged-contribution.md @@ -0,0 +1,55 @@ + + +# First merged contribution workflow + +The `first-merged-contribution` workflow template thanks a contributor after the +first pull request they actually get merged in a repository. + +It is designed for `pull_request_target` and intentionally does not check out, +download, or execute pull-request content. The workflow only reads event +metadata, checks the contributor's merged pull-request history, and creates a +comment on the merged pull request. The comment carries an internal marker so +reruns are idempotent and do not create duplicate thank-you messages. + +## Repository variables + +All variables are optional: + +- `FIRST_MERGED_CONTRIBUTION_MESSAGE`: complete custom message template. + Supported placeholders are `{user}`, `{repository}`, `{pull_request}`, + `{survey_url}`, and `{community_url}`. +- `CONTRIBUTOR_SURVEY_URL`: survey base URL. The workflow appends + `source=github-first-merged-pr` and the repository name. +- `COMMUNITY_URL`: optional community link. + +When `FIRST_MERGED_CONTRIBUTION_MESSAGE` is unset, the workflow uses a short +generic thank-you message and appends the optional survey and community links. + +## Manual retry + +The workflow also supports `workflow_dispatch` with a required +`pull_request_number` input. This is intended for recovering from a failed +post-merge run or validating the installation against an already merged first +contribution. The same first-merge check and duplicate-comment guard are applied +before a comment can be created. + +## Permissions and security + +The workflow starts with `permissions: {}` and grants only: + +- `contents: read`; +- `pull-requests: write`. + +The write permission is required only to create the pull-request comment. + +The implementation uses the GitHub-maintained `actions/github-script` action +pinned to an immutable commit. It does not use a Docker action or build a +container image, so it does not inherit the obsolete Debian/Node container used +by the previous third-party first-interaction action. + +Because the workflow runs as `pull_request_target`, never add checkout or any +execution of code, scripts, artifacts, or configuration from the pull request +head to this workflow. diff --git a/workflow-catalog.json b/workflow-catalog.json index 24ff745..a1e9972 100644 --- a/workflow-catalog.json +++ b/workflow-catalog.json @@ -2,6 +2,7 @@ "templates": [ "appstore-build-publish", "block-unconventional-commits", + "first-merged-contribution", "lint-eslint", "lint-info-xml", "lint-php", diff --git a/workflow-templates/first-merged-contribution.properties.json b/workflow-templates/first-merged-contribution.properties.json new file mode 100644 index 0000000..f8f18cc --- /dev/null +++ b/workflow-templates/first-merged-contribution.properties.json @@ -0,0 +1,8 @@ +{ + "name": "First merged contribution", + "description": "Thank contributors after their first merged pull request, with optional survey and community links.", + "iconName": "octicon heart", + "categories": [ + "Code Review" + ] +} diff --git a/workflow-templates/first-merged-contribution.properties.json.license b/workflow-templates/first-merged-contribution.properties.json.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/workflow-templates/first-merged-contribution.properties.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/workflow-templates/first-merged-contribution.yml b/workflow-templates/first-merged-contribution.yml new file mode 100644 index 0000000..708062a --- /dev/null +++ b/workflow-templates/first-merged-contribution.yml @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: First merged contribution + +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + pull_request_number: + description: Pull request number to process or retry + required: true + type: string + +permissions: {} + +concurrency: + group: >- + first-merged-contribution-${{ + github.event.pull_request.number || + inputs.pull_request_number || + github.run_id + }} + cancel-in-progress: false + +jobs: + first-merged-contribution: + if: >- + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.merged == true && + github.event.pull_request.user.type != 'Bot' + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + contents: read + pull-requests: write + + env: + FIRST_MERGED_CONTRIBUTION_MESSAGE: ${{ vars.FIRST_MERGED_CONTRIBUTION_MESSAGE }} + CONTRIBUTOR_SURVEY_URL: ${{ vars.CONTRIBUTOR_SURVEY_URL }} + COMMUNITY_URL: ${{ vars.COMMUNITY_URL }} + MANUAL_PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number || '' }} + + steps: + # pull_request_target is intentionally used without checkout. No code, + # artifacts, scripts or other content from the pull request are executed. + - name: Thank first merged contributor + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo + let pr = context.payload.pull_request + + if (!pr) { + const pullNumber = Number(process.env.MANUAL_PULL_REQUEST_NUMBER) + if (!Number.isInteger(pullNumber) || pullNumber <= 0) { + core.setFailed('A valid pull_request_number is required for a manual run.') + return + } + + const response = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }) + pr = response.data + } + + if (!pr?.merged || pr.user?.type === 'Bot') { + core.info('Pull request is not a merged human contribution; skipping.') + return + } + + const query = [ + `repo:${owner}/${repo}`, + 'is:pr', + 'is:merged', + `author:${pr.user.login}`, + `closed:<=${pr.closed_at}`, + ].join(' ') + + const result = await github.rest.search.issuesAndPullRequests({ + q: query, + per_page: 2, + }) + + if (result.data.total_count !== 1) { + core.info( + `PR #${pr.number} is not the author's first merged pull request; found ${result.data.total_count} merged PRs up to this one.`, + ) + return + } + + const marker = '' + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }) + + if (comments.some((comment) => comment.body?.includes(marker))) { + core.info(`PR #${pr.number} already has a first-contribution message; skipping.`) + return + } + + const surveyBase = (process.env.CONTRIBUTOR_SURVEY_URL || '').trim() + const communityUrl = (process.env.COMMUNITY_URL || '').trim() + const customMessage = (process.env.FIRST_MERGED_CONTRIBUTION_MESSAGE || '').trim() + + let surveyUrl = '' + if (surveyBase) { + const separator = surveyBase.includes('?') ? '&' : '?' + surveyUrl = `${surveyBase}${separator}source=github-first-merged-pr&repository=${encodeURIComponent(repo)}` + } + + const replacements = { + '{user}': `@${pr.user.login}`, + '{repository}': repo, + '{pull_request}': String(pr.number), + '{survey_url}': surveyUrl, + '{community_url}': communityUrl, + } + + let message + if (customMessage) { + message = customMessage + for (const [placeholder, value] of Object.entries(replacements)) { + message = message.split(placeholder).join(value) + } + } else { + const lines = [ + `Hi @${pr.user.login}, your first pull request to ${repo} has been merged. 🎉`, + '', + 'Thank you for the time and knowledge you shared. You are very welcome to contribute again.', + ] + + if (surveyUrl) { + lines.push( + '', + `If you have a few minutes, we would appreciate your feedback: ${surveyUrl}. The survey is entirely optional.`, + ) + } + + if (communityUrl) { + lines.push('', `Community: ${communityUrl}`) + } + + message = lines.join('\n') + } + + if (!message.trim()) { + core.setFailed('The rendered first-contribution message is empty.') + return + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: `${marker}\n${message}`, + }) From 5544deed2ed7199a02508bd8f4a4cdf07c601389 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:16:03 -0300 Subject: [PATCH 02/23] feat: add generic first merged PR comment action Signed-off-by: Vitor Mattos --- actions/first-merged-pr-comment/action.yml | 45 ++++ .../first_merged_pr_comment.py | 214 ++++++++++++++++++ tests/test_first_merged_pr_comment_action.py | 112 +++++++++ 3 files changed, 371 insertions(+) create mode 100644 actions/first-merged-pr-comment/action.yml create mode 100755 actions/first-merged-pr-comment/first_merged_pr_comment.py create mode 100644 tests/test_first_merged_pr_comment_action.py diff --git a/actions/first-merged-pr-comment/action.yml b/actions/first-merged-pr-comment/action.yml new file mode 100644 index 0000000..d3efc83 --- /dev/null +++ b/actions/first-merged-pr-comment/action.yml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: First merged PR comment +description: Comment on a contributor's first merged pull request using a strict message template. + +inputs: + github-token: + description: Token used to inspect merged pull requests and create the comment. + required: true + message-template: + description: Message template rendered with built-in placeholders. + required: true + pull-request-number: + description: Pull request number. Required for manual retries; otherwise read from the event. + required: false + default: '' + +outputs: + is-first-merged: + description: Whether the pull request is the contributor's first merged pull request. + value: ${{ steps.comment.outputs.is-first-merged }} + comment-created: + description: Whether this invocation created the comment. + value: ${{ steps.comment.outputs.comment-created }} + contributor-login: + description: Contributor login resolved from the pull request. + value: ${{ steps.comment.outputs.contributor-login }} + pull-request-number: + description: Pull request number that was processed. + value: ${{ steps.comment.outputs.pull-request-number }} + +runs: + using: composite + steps: + - id: comment + name: Comment on first merged pull request + shell: bash + env: + FIRST_MERGED_PR_GITHUB_TOKEN: ${{ inputs.github-token }} + FIRST_MERGED_PR_MESSAGE_TEMPLATE: ${{ inputs.message-template }} + FIRST_MERGED_PR_NUMBER: ${{ inputs.pull-request-number }} + run: | + set -euo pipefail + python3 "${GITHUB_ACTION_PATH}/first_merged_pr_comment.py" diff --git a/actions/first-merged-pr-comment/first_merged_pr_comment.py b/actions/first-merged-pr-comment/first_merged_pr_comment.py new file mode 100755 index 0000000..b6f2647 --- /dev/null +++ b/actions/first-merged-pr-comment/first_merged_pr_comment.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +MARKER = "" +PLACEHOLDER = re.compile(r"\{([a-z][a-z0-9_]*)(?:\|([a-z][a-z0-9_]*))?\}") +ALLOWED_FILTERS = {"urlencode"} + + +class ActionError(RuntimeError): + pass + + +def render_template(template: str, context: dict[str, str]) -> str: + if not template.strip(): + raise ActionError("message template is empty") + + def replace(match: re.Match[str]) -> str: + name, filter_name = match.groups() + if name not in context: + raise ActionError(f"unknown placeholder: {name}") + value = context[name] + if filter_name is None: + return value + if filter_name not in ALLOWED_FILTERS: + raise ActionError(f"unknown placeholder filter: {filter_name}") + if filter_name == "urlencode": + return urllib.parse.quote(value, safe="") + raise AssertionError(filter_name) + + return PLACEHOLDER.sub(replace, template) + + +def build_context( + *, + pr: dict[str, Any], + repository: str, + server_url: str, + api_url: str, +) -> dict[str, str]: + owner, repository_name = repository.split("/", 1) + login = str(pr["user"]["login"]) + number = str(pr["number"]) + return { + "server_url": server_url.rstrip("/"), + "api_url": api_url.rstrip("/"), + "repository": repository, + "repository_owner": owner, + "repository_name": repository_name, + "repository_url": f"{server_url.rstrip('/')}/{repository}", + "pull_request_number": number, + "pull_request_url": str( + pr.get("html_url") + or f"{server_url.rstrip('/')}/{repository}/pull/{number}" + ), + "contributor_login": login, + "contributor_mention": f"@{login}", + "contributor_url": f"{server_url.rstrip('/')}/{login}", + "merge_commit_sha": str(pr.get("merge_commit_sha") or ""), + } + + +def api_request( + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, +) -> Any: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise ActionError(f"GitHub API request failed ({error.code}): {body}") from error + return json.loads(body) if body else None + + +def pull_request_from_event(event_path: str) -> dict[str, Any] | None: + if not event_path: + return None + payload = json.loads(Path(event_path).read_text(encoding="utf-8")) + pr = payload.get("pull_request") + return pr if isinstance(pr, dict) else None + + +def write_output(name: str, value: str) -> None: + path = os.environ.get("GITHUB_OUTPUT") + if not path: + return + with Path(path).open("a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def first_merged_query(repository: str, login: str, closed_at: str) -> str: + return " ".join( + ( + f"repo:{repository}", + "is:pr", + "is:merged", + f"author:{login}", + f"closed:<={closed_at}", + ) + ) + + +def main() -> int: + token = os.environ.get("FIRST_MERGED_PR_GITHUB_TOKEN", "") + template = os.environ.get("FIRST_MERGED_PR_MESSAGE_TEMPLATE", "") + manual_number = os.environ.get("FIRST_MERGED_PR_NUMBER", "").strip() + repository = os.environ.get("GITHUB_REPOSITORY", "") + api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/") + + if not token: + raise ActionError("github token is required") + if "/" not in repository: + raise ActionError("GITHUB_REPOSITORY must be in owner/name form") + + pr = pull_request_from_event(os.environ.get("GITHUB_EVENT_PATH", "")) + if pr is None: + if not manual_number.isdigit() or int(manual_number) <= 0: + raise ActionError("a valid pull-request-number is required for a manual run") + owner, repo = repository.split("/", 1) + pr = api_request( + "GET", + f"{api_url}/repos/{owner}/{repo}/pulls/{int(manual_number)}", + token, + ) + + write_output("contributor-login", str(pr["user"]["login"])) + write_output("pull-request-number", str(pr["number"])) + + if not pr.get("merged") or pr.get("user", {}).get("type") == "Bot": + write_output("is-first-merged", "false") + write_output("comment-created", "false") + print("Pull request is not a merged human contribution; skipping.") + return 0 + + login = str(pr["user"]["login"]) + closed_at = str(pr["closed_at"]) + query = first_merged_query(repository, login, closed_at) + encoded_query = urllib.parse.urlencode({"q": query, "per_page": 2}) + search = api_request("GET", f"{api_url}/search/issues?{encoded_query}", token) + total_count = int(search["total_count"]) + + if total_count != 1: + write_output("is-first-merged", "false") + write_output("comment-created", "false") + print( + f"PR #{pr['number']} is not the contributor's first merged pull request; " + f"found {total_count} merged pull requests up to this one." + ) + return 0 + + write_output("is-first-merged", "true") + + owner, repo = repository.split("/", 1) + comments = api_request( + "GET", + f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments?per_page=100", + token, + ) + if any(MARKER in str(comment.get("body") or "") for comment in comments): + write_output("comment-created", "false") + print(f"PR #{pr['number']} already has a first-merged comment; skipping.") + return 0 + + context = build_context( + pr=pr, + repository=repository, + server_url=server_url, + api_url=api_url, + ) + message = render_template(template, context).strip() + api_request( + "POST", + f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments", + token, + {"body": f"{MARKER}\n{message}"}, + ) + write_output("comment-created", "true") + print(f"Created first-merged contribution comment on PR #{pr['number']}.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ActionError, KeyError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"::error::{error}") + raise SystemExit(1) from error diff --git a/tests/test_first_merged_pr_comment_action.py b/tests/test_first_merged_pr_comment_action.py new file mode 100644 index 0000000..3fa4b98 --- /dev/null +++ b/tests/test_first_merged_pr_comment_action.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "actions" / "first-merged-pr-comment" / "first_merged_pr_comment.py" + +spec = importlib.util.spec_from_file_location("first_merged_pr_comment", SCRIPT) +assert spec is not None and spec.loader is not None +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class FirstMergedPrCommentTest(unittest.TestCase): + def setUp(self) -> None: + self.pr = { + "number": 42, + "html_url": "https://git.example/acme/project/pull/42", + "merged": True, + "closed_at": "2026-09-23T12:00:00Z", + "merge_commit_sha": "abc123", + "user": {"login": "alice", "type": "User"}, + } + + def test_build_context_is_generic(self) -> None: + context = module.build_context( + pr=self.pr, + repository="acme/project", + server_url="https://git.example", + api_url="https://git.example/api/v3", + ) + self.assertEqual(context["server_url"], "https://git.example") + self.assertEqual(context["repository"], "acme/project") + self.assertEqual(context["repository_owner"], "acme") + self.assertEqual(context["repository_name"], "project") + self.assertEqual(context["repository_url"], "https://git.example/acme/project") + self.assertEqual(context["pull_request_number"], "42") + self.assertEqual(context["pull_request_url"], "https://git.example/acme/project/pull/42") + self.assertEqual(context["contributor_login"], "alice") + self.assertEqual(context["contributor_mention"], "@alice") + self.assertEqual(context["contributor_url"], "https://git.example/alice") + self.assertEqual(context["merge_commit_sha"], "abc123") + + def test_render_template_composes_arbitrary_urls(self) -> None: + context = module.build_context( + pr=self.pr, + repository="acme/project", + server_url="https://git.example", + api_url="https://git.example/api/v3", + ) + template = ( + "Hello {contributor_mention}. " + "Docs: {repository_url}/docs. " + "Survey: https://survey.example/form?repo={repository|urlencode}" + "&user={contributor_login|urlencode}&pr={pull_request_number}." + ) + rendered = module.render_template(template, context) + self.assertEqual( + rendered, + "Hello @alice. Docs: https://git.example/acme/project/docs. " + "Survey: https://survey.example/form?repo=acme%2Fproject" + "&user=alice&pr=42.", + ) + + def test_render_template_rejects_unknown_placeholder(self) -> None: + with self.assertRaisesRegex(module.ActionError, "unknown placeholder: survey_url"): + module.render_template("{survey_url}", {"repository": "acme/project"}) + + def test_render_template_rejects_unknown_filter(self) -> None: + with self.assertRaisesRegex(module.ActionError, "unknown placeholder filter: shell"): + module.render_template("{repository|shell}", {"repository": "acme/project"}) + + def test_render_template_rejects_empty_message(self) -> None: + with self.assertRaisesRegex(module.ActionError, "message template is empty"): + module.render_template(" ", {}) + + def test_first_merged_query_is_historical_for_safe_retries(self) -> None: + query = module.first_merged_query( + "acme/project", + "alice", + "2026-09-23T12:00:00Z", + ) + self.assertEqual( + query, + "repo:acme/project is:pr is:merged author:alice " + "closed:<=2026-09-23T12:00:00Z", + ) + + def test_marker_is_stable_for_idempotency(self) -> None: + self.assertEqual( + module.MARKER, + "", + ) + + def test_action_contract_has_no_product_specific_inputs(self) -> None: + action = ( + ROOT / "actions" / "first-merged-pr-comment" / "action.yml" + ).read_text(encoding="utf-8") + self.assertIn("message-template:", action) + self.assertIn("pull-request-number:", action) + self.assertNotIn("survey", action.lower()) + self.assertNotIn("community", action.lower()) + self.assertNotIn("good first issue", action.lower()) + + +if __name__ == "__main__": + unittest.main() From b351a11aaa5a1fc5ac232e12c81aa2b7467e6f2d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:16:59 -0300 Subject: [PATCH 03/23] refactor: separate first merged PR comment logic Signed-off-by: Vitor Mattos --- .../first_merged_pr_comment.py | 175 ++++++++++++------ 1 file changed, 118 insertions(+), 57 deletions(-) diff --git a/actions/first-merged-pr-comment/first_merged_pr_comment.py b/actions/first-merged-pr-comment/first_merged_pr_comment.py index b6f2647..4002403 100755 --- a/actions/first-merged-pr-comment/first_merged_pr_comment.py +++ b/actions/first-merged-pr-comment/first_merged_pr_comment.py @@ -11,11 +11,12 @@ import urllib.parse import urllib.request from pathlib import Path -from typing import Any +from typing import Any, Callable MARKER = "" PLACEHOLDER = re.compile(r"\{([a-z][a-z0-9_]*)(?:\|([a-z][a-z0-9_]*))?\}") ALLOWED_FILTERS = {"urlencode"} +ApiRequest = Callable[[str, str, str, dict[str, Any] | None], Any] class ActionError(RuntimeError): @@ -35,9 +36,7 @@ def replace(match: re.Match[str]) -> str: return value if filter_name not in ALLOWED_FILTERS: raise ActionError(f"unknown placeholder filter: {filter_name}") - if filter_name == "urlencode": - return urllib.parse.quote(value, safe="") - raise AssertionError(filter_name) + return urllib.parse.quote(value, safe="") return PLACEHOLDER.sub(replace, template) @@ -52,21 +51,22 @@ def build_context( owner, repository_name = repository.split("/", 1) login = str(pr["user"]["login"]) number = str(pr["number"]) + clean_server_url = server_url.rstrip("/") return { - "server_url": server_url.rstrip("/"), + "server_url": clean_server_url, "api_url": api_url.rstrip("/"), "repository": repository, "repository_owner": owner, "repository_name": repository_name, - "repository_url": f"{server_url.rstrip('/')}/{repository}", + "repository_url": f"{clean_server_url}/{repository}", "pull_request_number": number, "pull_request_url": str( pr.get("html_url") - or f"{server_url.rstrip('/')}/{repository}/pull/{number}" + or f"{clean_server_url}/{repository}/pull/{number}" ), "contributor_login": login, "contributor_mention": f"@{login}", - "contributor_url": f"{server_url.rstrip('/')}/{login}", + "contributor_url": f"{clean_server_url}/{login}", "merge_commit_sha": str(pr.get("merge_commit_sha") or ""), } @@ -126,6 +126,94 @@ def first_merged_query(repository: str, login: str, closed_at: str) -> str: ) +def list_issue_comments( + *, + api_url: str, + repository: str, + pull_request_number: int, + token: str, + request: ApiRequest = api_request, +) -> list[dict[str, Any]]: + owner, repo = repository.split("/", 1) + comments: list[dict[str, Any]] = [] + page = 1 + while True: + batch = request( + "GET", + f"{api_url}/repos/{owner}/{repo}/issues/{pull_request_number}/comments" + f"?per_page=100&page={page}", + token, + None, + ) + comments.extend(batch) + if len(batch) < 100: + return comments + page += 1 + + +def process_pull_request( + *, + pr: dict[str, Any], + repository: str, + token: str, + api_url: str, + server_url: str, + template: str, + request: ApiRequest = api_request, +) -> dict[str, str]: + result = { + "is-first-merged": "false", + "comment-created": "false", + "contributor-login": str(pr["user"]["login"]), + "pull-request-number": str(pr["number"]), + } + + if not pr.get("merged") or pr.get("user", {}).get("type") == "Bot": + return result + + login = str(pr["user"]["login"]) + query = first_merged_query(repository, login, str(pr["closed_at"])) + encoded_query = urllib.parse.urlencode({"q": query, "per_page": 2}) + search = request( + "GET", + f"{api_url}/search/issues?{encoded_query}", + token, + None, + ) + if int(search["total_count"]) != 1: + return result + + result["is-first-merged"] = "true" + + comments = list_issue_comments( + api_url=api_url, + repository=repository, + pull_request_number=int(pr["number"]), + token=token, + request=request, + ) + if any(MARKER in str(comment.get("body") or "") for comment in comments): + return result + + context = build_context( + pr=pr, + repository=repository, + server_url=server_url, + api_url=api_url, + ) + message = render_template(template, context).strip() + + owner, repo = repository.split("/", 1) + request( + "POST", + f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments", + token, + {"body": f"{MARKER}\n{message}"}, + ) + result["comment-created"] = "true" + return result + + def main() -> int: token = os.environ.get("FIRST_MERGED_PR_GITHUB_TOKEN", "") template = os.environ.get("FIRST_MERGED_PR_MESSAGE_TEMPLATE", "") @@ -150,59 +238,32 @@ def main() -> int: token, ) - write_output("contributor-login", str(pr["user"]["login"])) - write_output("pull-request-number", str(pr["number"])) - - if not pr.get("merged") or pr.get("user", {}).get("type") == "Bot": - write_output("is-first-merged", "false") - write_output("comment-created", "false") - print("Pull request is not a merged human contribution; skipping.") - return 0 - - login = str(pr["user"]["login"]) - closed_at = str(pr["closed_at"]) - query = first_merged_query(repository, login, closed_at) - encoded_query = urllib.parse.urlencode({"q": query, "per_page": 2}) - search = api_request("GET", f"{api_url}/search/issues?{encoded_query}", token) - total_count = int(search["total_count"]) - - if total_count != 1: - write_output("is-first-merged", "false") - write_output("comment-created", "false") - print( - f"PR #{pr['number']} is not the contributor's first merged pull request; " - f"found {total_count} merged pull requests up to this one." - ) - return 0 - - write_output("is-first-merged", "true") - - owner, repo = repository.split("/", 1) - comments = api_request( - "GET", - f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments?per_page=100", - token, - ) - if any(MARKER in str(comment.get("body") or "") for comment in comments): - write_output("comment-created", "false") - print(f"PR #{pr['number']} already has a first-merged comment; skipping.") - return 0 - - context = build_context( + result = process_pull_request( pr=pr, repository=repository, - server_url=server_url, + token=token, api_url=api_url, + server_url=server_url, + template=template, ) - message = render_template(template, context).strip() - api_request( - "POST", - f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments", - token, - {"body": f"{MARKER}\n{message}"}, - ) - write_output("comment-created", "true") - print(f"Created first-merged contribution comment on PR #{pr['number']}.") + for name, value in result.items(): + write_output(name, value) + + if result["comment-created"] == "true": + print( + f"Created first-merged contribution comment on " + f"PR #{result['pull-request-number']}." + ) + elif result["is-first-merged"] == "true": + print( + f"PR #{result['pull-request-number']} already has a " + "first-merged contribution comment; skipping." + ) + else: + print( + f"PR #{result['pull-request-number']} is not the contributor's " + "first merged pull request; skipping." + ) return 0 From 77e0173ef70a380e32bb77fbca6e9b5d416e31df Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:17:02 -0300 Subject: [PATCH 04/23] test: cover first merged PR comment behavior Signed-off-by: Vitor Mattos --- tests/test_first_merged_pr_comment_action.py | 95 ++++++++++++++++++-- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/tests/test_first_merged_pr_comment_action.py b/tests/test_first_merged_pr_comment_action.py index 3fa4b98..17e7224 100644 --- a/tests/test_first_merged_pr_comment_action.py +++ b/tests/test_first_merged_pr_comment_action.py @@ -6,6 +6,7 @@ import importlib.util import unittest from pathlib import Path +from typing import Any ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "actions" / "first-merged-pr-comment" / "first_merged_pr_comment.py" @@ -16,6 +17,29 @@ spec.loader.exec_module(module) +class FakeApi: + def __init__(self, *, total_count: int = 1, comments: list[dict[str, Any]] | None = None) -> None: + self.total_count = total_count + self.comments = comments or [] + self.calls: list[tuple[str, str, dict[str, Any] | None]] = [] + + def __call__( + self, + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, + ) -> Any: + self.calls.append((method, url, payload)) + if "/search/issues?" in url: + return {"total_count": self.total_count} + if "/comments?" in url: + return self.comments + if method == "POST" and url.endswith("/comments"): + return {"id": 123} + raise AssertionError(f"unexpected API request: {method} {url}") + + class FirstMergedPrCommentTest(unittest.TestCase): def setUp(self) -> None: self.pr = { @@ -27,6 +51,17 @@ def setUp(self) -> None: "user": {"login": "alice", "type": "User"}, } + def process(self, api: FakeApi, *, pr: dict[str, Any] | None = None, template: str = "Thanks {contributor_mention}") -> dict[str, str]: + return module.process_pull_request( + pr=pr or self.pr, + repository="acme/project", + token="token", + api_url="https://git.example/api/v3", + server_url="https://git.example", + template=template, + request=api, + ) + def test_build_context_is_generic(self) -> None: context = module.build_context( pr=self.pr, @@ -35,6 +70,7 @@ def test_build_context_is_generic(self) -> None: api_url="https://git.example/api/v3", ) self.assertEqual(context["server_url"], "https://git.example") + self.assertEqual(context["api_url"], "https://git.example/api/v3") self.assertEqual(context["repository"], "acme/project") self.assertEqual(context["repository_owner"], "acme") self.assertEqual(context["repository_name"], "project") @@ -53,23 +89,23 @@ def test_render_template_composes_arbitrary_urls(self) -> None: server_url="https://git.example", api_url="https://git.example/api/v3", ) - template = ( + rendered = module.render_template( "Hello {contributor_mention}. " "Docs: {repository_url}/docs. " - "Survey: https://survey.example/form?repo={repository|urlencode}" - "&user={contributor_login|urlencode}&pr={pull_request_number}." + "Feedback: https://forms.example/respond?repo={repository|urlencode}" + "&user={contributor_login|urlencode}&pr={pull_request_number}.", + context, ) - rendered = module.render_template(template, context) self.assertEqual( rendered, "Hello @alice. Docs: https://git.example/acme/project/docs. " - "Survey: https://survey.example/form?repo=acme%2Fproject" + "Feedback: https://forms.example/respond?repo=acme%2Fproject" "&user=alice&pr=42.", ) def test_render_template_rejects_unknown_placeholder(self) -> None: - with self.assertRaisesRegex(module.ActionError, "unknown placeholder: survey_url"): - module.render_template("{survey_url}", {"repository": "acme/project"}) + with self.assertRaisesRegex(module.ActionError, "unknown placeholder: custom_url"): + module.render_template("{custom_url}", {"repository": "acme/project"}) def test_render_template_rejects_unknown_filter(self) -> None: with self.assertRaisesRegex(module.ActionError, "unknown placeholder filter: shell"): @@ -79,6 +115,51 @@ def test_render_template_rejects_empty_message(self) -> None: with self.assertRaisesRegex(module.ActionError, "message template is empty"): module.render_template(" ", {}) + def test_first_merged_pr_creates_comment(self) -> None: + api = FakeApi() + result = self.process( + api, + template=( + "Thanks {contributor_mention}. " + "{server_url}/{repository}/issues?author={contributor_login|urlencode}" + ), + ) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "true") + post = [call for call in api.calls if call[0] == "POST"] + self.assertEqual(len(post), 1) + self.assertIn(module.MARKER, post[0][2]["body"]) + self.assertIn("Thanks @alice.", post[0][2]["body"]) + + def test_second_merged_pr_does_not_create_comment(self) -> None: + api = FakeApi(total_count=2) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "false") + self.assertEqual(result["comment-created"], "false") + self.assertFalse(any(call[0] == "POST" for call in api.calls)) + + def test_closed_unmerged_pr_does_not_call_api(self) -> None: + api = FakeApi() + pr = dict(self.pr, merged=False) + result = self.process(api, pr=pr) + self.assertEqual(result["is-first-merged"], "false") + self.assertEqual(result["comment-created"], "false") + self.assertEqual(api.calls, []) + + def test_bot_pr_does_not_call_api(self) -> None: + api = FakeApi() + pr = dict(self.pr, user={"login": "renovate[bot]", "type": "Bot"}) + result = self.process(api, pr=pr) + self.assertEqual(result["comment-created"], "false") + self.assertEqual(api.calls, []) + + def test_existing_marker_makes_retry_idempotent(self) -> None: + api = FakeApi(comments=[{"body": f"{module.MARKER}\nAlready sent"}]) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "false") + self.assertFalse(any(call[0] == "POST" for call in api.calls)) + def test_first_merged_query_is_historical_for_safe_retries(self) -> None: query = module.first_merged_query( "acme/project", From 607c4cc4819b68bf2cf05b4e84edb5f6d02702df Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:17:33 -0300 Subject: [PATCH 05/23] feat: add generic first merged PR workflow template\n\nSigned-off-by: Vitor Mattos --- .../first-merged-pr-comment.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 workflow-templates/first-merged-pr-comment.yml diff --git a/workflow-templates/first-merged-pr-comment.yml b/workflow-templates/first-merged-pr-comment.yml new file mode 100644 index 0000000..ba65363 --- /dev/null +++ b/workflow-templates/first-merged-pr-comment.yml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: First merged PR comment + +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + pull_request_number: + description: Pull request number to process or retry + required: true + type: string + +permissions: {} + +concurrency: + group: >- + first-merged-pr-comment-${{ + github.event.pull_request.number || + inputs.pull_request_number || + github.run_id + }} + cancel-in-progress: false + +jobs: + first-merged-pr-comment: + if: >- + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.merged == true && + github.event.pull_request.user.type != 'Bot' + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + pull-requests: write + + env: + FIRST_MERGED_PR_MESSAGE: >- + ${{ vars.FIRST_MERGED_PR_MESSAGE || + 'Thanks {contributor_mention}! Your first pull request to {repository_name} has been merged.' }} + + steps: + # pull_request_target is intentionally used without checkout. Nothing + # from the pull request head is downloaded or executed. + - name: Comment on first merged pull request + uses: LibreCodeCoop/github-workflows/actions/first-merged-pr-comment@77e0173ef70a380e32bb77fbca6e9b5d416e31df + with: + github-token: ${{ github.token }} + pull-request-number: >- + ${{ github.event.pull_request.number || inputs.pull_request_number }} + message-template: ${{ env.FIRST_MERGED_PR_MESSAGE }} From 6cf27093e2f165384fc9b5151298d351724eae9b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:17:35 -0300 Subject: [PATCH 06/23] feat: add first merged PR workflow metadata\n\nSigned-off-by: Vitor Mattos --- .../first-merged-pr-comment.properties.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 workflow-templates/first-merged-pr-comment.properties.json diff --git a/workflow-templates/first-merged-pr-comment.properties.json b/workflow-templates/first-merged-pr-comment.properties.json new file mode 100644 index 0000000..11da31a --- /dev/null +++ b/workflow-templates/first-merged-pr-comment.properties.json @@ -0,0 +1,8 @@ +{ + "name": "First merged PR comment", + "description": "Comment on a contributor's first merged pull request with a repository-defined message template.", + "iconName": "octicon comment", + "categories": [ + "Code Review" + ] +} From 35083453d7e1c33f74a34f7bc767f2240f407b15 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:17:38 -0300 Subject: [PATCH 07/23] chore: license first merged PR workflow metadata\n\nSigned-off-by: Vitor Mattos --- .../first-merged-pr-comment.properties.json.license | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 workflow-templates/first-merged-pr-comment.properties.json.license diff --git a/workflow-templates/first-merged-pr-comment.properties.json.license b/workflow-templates/first-merged-pr-comment.properties.json.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/workflow-templates/first-merged-pr-comment.properties.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later From e9c02d3abb1547ca7dd3c01d6869e7891176c292 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:17:41 -0300 Subject: [PATCH 08/23] docs: document first merged PR comment action\n\nSigned-off-by: Vitor Mattos --- docs/first-merged-pr-comment.md | 112 ++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/first-merged-pr-comment.md diff --git a/docs/first-merged-pr-comment.md b/docs/first-merged-pr-comment.md new file mode 100644 index 0000000..896ae27 --- /dev/null +++ b/docs/first-merged-pr-comment.md @@ -0,0 +1,112 @@ + + +# First merged PR comment + +The `first-merged-pr-comment` action detects a contributor's first merged pull +request and creates one idempotent comment from a repository-defined template. + +The action is intentionally generic. It has no concept of surveys, community +links, documentation URLs, labels, or any other product-specific destination. +The consumer composes the complete message, including arbitrary URLs and query +strings, from built-in placeholders. + +## Action contract + +Required inputs: + +- `github-token`: token used for the GitHub API; +- `message-template`: complete Markdown message template. + +Optional input: + +- `pull-request-number`: used by manual retries. Event-driven executions read + the pull request from the event payload. + +Outputs: + +- `is-first-merged`; +- `comment-created`; +- `contributor-login`; +- `pull-request-number`. + +## Built-in placeholders + +The message renderer exposes only GitHub-derived values: + +- `{server_url}` +- `{api_url}` +- `{repository}` +- `{repository_owner}` +- `{repository_name}` +- `{repository_url}` +- `{pull_request_number}` +- `{pull_request_url}` +- `{contributor_login}` +- `{contributor_mention}` +- `{contributor_url}` +- `{merge_commit_sha}` + +Any placeholder may use the `urlencode` filter when it must be embedded in a +URL component: + +```text +https://example.org/form?repo={repository|urlencode}&pr={pull_request_number|urlencode} +``` + +The renderer performs textual substitution only. It does not evaluate shell, +Python, JavaScript, GitHub expressions, Jinja, Handlebars, or template +functions. Unknown placeholders and unknown filters fail the action instead of +publishing a partially rendered message. + +## Repository configuration + +The organization workflow template reads the optional repository variable +`FIRST_MERGED_PR_MESSAGE`. If it is absent, the installed workflow uses a +minimal generic message. + +A consumer can put its entire Markdown message in that single variable. For +example: + +```text +Thanks {contributor_mention}! Your first pull request to {repository_name} has been merged. + +Repository: {repository_url} + +Feedback: https://feedback.example/respond?repository={repository|urlencode}&contributor={contributor_login|urlencode}&pr={pull_request_number|urlencode} +``` + +The URLs above are examples only. The action does not know or assign meaning to +them. + +## Retry and idempotency + +The workflow template supports `workflow_dispatch` with a pull request number. +The action evaluates the contribution at the historical close time of that pull +request, so a retry remains valid even after the contributor has additional +merged pull requests. + +Successful comments include an internal HTML marker. A retry against a pull +request that already received the message exits successfully without creating a +duplicate. + +## Security model + +The workflow uses `pull_request_target` because the comment requires write +permission after a pull request from a fork is merged. The privileged workflow +must therefore never execute untrusted pull-request content. + +The provided template: + +- starts from `permissions: {}`; +- grants only `pull-requests: write` to the job; +- does not check out repository or pull-request content; +- does not download or execute pull-request artifacts; +- calls the LibreCode composite action at an immutable commit; +- uses Python's standard library only; +- does not build or run a Docker image; +- does not install runtime dependencies. + +Do not add checkout or execution of pull-request-head content to this workflow. From f8786a951128f2ed010d81ed8f4226f3473054eb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:17:56 -0300 Subject: [PATCH 09/23] refactor: rename first merged PR workflow\n\nSigned-off-by: Vitor Mattos --- .../first-merged-contribution.yml | 167 ------------------ 1 file changed, 167 deletions(-) delete mode 100644 workflow-templates/first-merged-contribution.yml diff --git a/workflow-templates/first-merged-contribution.yml b/workflow-templates/first-merged-contribution.yml deleted file mode 100644 index 708062a..0000000 --- a/workflow-templates/first-merged-contribution.yml +++ /dev/null @@ -1,167 +0,0 @@ -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -name: First merged contribution - -on: - pull_request_target: - types: [closed] - workflow_dispatch: - inputs: - pull_request_number: - description: Pull request number to process or retry - required: true - type: string - -permissions: {} - -concurrency: - group: >- - first-merged-contribution-${{ - github.event.pull_request.number || - inputs.pull_request_number || - github.run_id - }} - cancel-in-progress: false - -jobs: - first-merged-contribution: - if: >- - github.event_name == 'workflow_dispatch' || - ( - github.event.pull_request.merged == true && - github.event.pull_request.user.type != 'Bot' - ) - runs-on: ubuntu-latest - timeout-minutes: 5 - - permissions: - contents: read - pull-requests: write - - env: - FIRST_MERGED_CONTRIBUTION_MESSAGE: ${{ vars.FIRST_MERGED_CONTRIBUTION_MESSAGE }} - CONTRIBUTOR_SURVEY_URL: ${{ vars.CONTRIBUTOR_SURVEY_URL }} - COMMUNITY_URL: ${{ vars.COMMUNITY_URL }} - MANUAL_PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number || '' }} - - steps: - # pull_request_target is intentionally used without checkout. No code, - # artifacts, scripts or other content from the pull request are executed. - - name: Thank first merged contributor - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo - let pr = context.payload.pull_request - - if (!pr) { - const pullNumber = Number(process.env.MANUAL_PULL_REQUEST_NUMBER) - if (!Number.isInteger(pullNumber) || pullNumber <= 0) { - core.setFailed('A valid pull_request_number is required for a manual run.') - return - } - - const response = await github.rest.pulls.get({ - owner, - repo, - pull_number: pullNumber, - }) - pr = response.data - } - - if (!pr?.merged || pr.user?.type === 'Bot') { - core.info('Pull request is not a merged human contribution; skipping.') - return - } - - const query = [ - `repo:${owner}/${repo}`, - 'is:pr', - 'is:merged', - `author:${pr.user.login}`, - `closed:<=${pr.closed_at}`, - ].join(' ') - - const result = await github.rest.search.issuesAndPullRequests({ - q: query, - per_page: 2, - }) - - if (result.data.total_count !== 1) { - core.info( - `PR #${pr.number} is not the author's first merged pull request; found ${result.data.total_count} merged PRs up to this one.`, - ) - return - } - - const marker = '' - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: pr.number, - per_page: 100, - }) - - if (comments.some((comment) => comment.body?.includes(marker))) { - core.info(`PR #${pr.number} already has a first-contribution message; skipping.`) - return - } - - const surveyBase = (process.env.CONTRIBUTOR_SURVEY_URL || '').trim() - const communityUrl = (process.env.COMMUNITY_URL || '').trim() - const customMessage = (process.env.FIRST_MERGED_CONTRIBUTION_MESSAGE || '').trim() - - let surveyUrl = '' - if (surveyBase) { - const separator = surveyBase.includes('?') ? '&' : '?' - surveyUrl = `${surveyBase}${separator}source=github-first-merged-pr&repository=${encodeURIComponent(repo)}` - } - - const replacements = { - '{user}': `@${pr.user.login}`, - '{repository}': repo, - '{pull_request}': String(pr.number), - '{survey_url}': surveyUrl, - '{community_url}': communityUrl, - } - - let message - if (customMessage) { - message = customMessage - for (const [placeholder, value] of Object.entries(replacements)) { - message = message.split(placeholder).join(value) - } - } else { - const lines = [ - `Hi @${pr.user.login}, your first pull request to ${repo} has been merged. 🎉`, - '', - 'Thank you for the time and knowledge you shared. You are very welcome to contribute again.', - ] - - if (surveyUrl) { - lines.push( - '', - `If you have a few minutes, we would appreciate your feedback: ${surveyUrl}. The survey is entirely optional.`, - ) - } - - if (communityUrl) { - lines.push('', `Community: ${communityUrl}`) - } - - message = lines.join('\n') - } - - if (!message.trim()) { - core.setFailed('The rendered first-contribution message is empty.') - return - } - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body: `${marker}\n${message}`, - }) From 3b65868e74c2c2d6ae020dbdcef20af73d41a22a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:17:59 -0300 Subject: [PATCH 10/23] refactor: rename first merged PR workflow\n\nSigned-off-by: Vitor Mattos --- .../first-merged-contribution.properties.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 workflow-templates/first-merged-contribution.properties.json diff --git a/workflow-templates/first-merged-contribution.properties.json b/workflow-templates/first-merged-contribution.properties.json deleted file mode 100644 index f8f18cc..0000000 --- a/workflow-templates/first-merged-contribution.properties.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "First merged contribution", - "description": "Thank contributors after their first merged pull request, with optional survey and community links.", - "iconName": "octicon heart", - "categories": [ - "Code Review" - ] -} From 9f51181e8631ec9c4145a537ddee7e8e387ad1d1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:18:01 -0300 Subject: [PATCH 11/23] refactor: rename first merged PR workflow\n\nSigned-off-by: Vitor Mattos --- .../first-merged-contribution.properties.json.license | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 workflow-templates/first-merged-contribution.properties.json.license diff --git a/workflow-templates/first-merged-contribution.properties.json.license b/workflow-templates/first-merged-contribution.properties.json.license deleted file mode 100644 index 1ce4e0c..0000000 --- a/workflow-templates/first-merged-contribution.properties.json.license +++ /dev/null @@ -1,2 +0,0 @@ -SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -SPDX-License-Identifier: AGPL-3.0-or-later From e9c7455d163118a016fe6587ffc15e2c38971cb5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:18:04 -0300 Subject: [PATCH 12/23] refactor: rename first merged PR workflow\n\nSigned-off-by: Vitor Mattos --- docs/first-merged-contribution.md | 55 ------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 docs/first-merged-contribution.md diff --git a/docs/first-merged-contribution.md b/docs/first-merged-contribution.md deleted file mode 100644 index 9376142..0000000 --- a/docs/first-merged-contribution.md +++ /dev/null @@ -1,55 +0,0 @@ - - -# First merged contribution workflow - -The `first-merged-contribution` workflow template thanks a contributor after the -first pull request they actually get merged in a repository. - -It is designed for `pull_request_target` and intentionally does not check out, -download, or execute pull-request content. The workflow only reads event -metadata, checks the contributor's merged pull-request history, and creates a -comment on the merged pull request. The comment carries an internal marker so -reruns are idempotent and do not create duplicate thank-you messages. - -## Repository variables - -All variables are optional: - -- `FIRST_MERGED_CONTRIBUTION_MESSAGE`: complete custom message template. - Supported placeholders are `{user}`, `{repository}`, `{pull_request}`, - `{survey_url}`, and `{community_url}`. -- `CONTRIBUTOR_SURVEY_URL`: survey base URL. The workflow appends - `source=github-first-merged-pr` and the repository name. -- `COMMUNITY_URL`: optional community link. - -When `FIRST_MERGED_CONTRIBUTION_MESSAGE` is unset, the workflow uses a short -generic thank-you message and appends the optional survey and community links. - -## Manual retry - -The workflow also supports `workflow_dispatch` with a required -`pull_request_number` input. This is intended for recovering from a failed -post-merge run or validating the installation against an already merged first -contribution. The same first-merge check and duplicate-comment guard are applied -before a comment can be created. - -## Permissions and security - -The workflow starts with `permissions: {}` and grants only: - -- `contents: read`; -- `pull-requests: write`. - -The write permission is required only to create the pull-request comment. - -The implementation uses the GitHub-maintained `actions/github-script` action -pinned to an immutable commit. It does not use a Docker action or build a -container image, so it does not inherit the obsolete Debian/Node container used -by the previous third-party first-interaction action. - -Because the workflow runs as `pull_request_target`, never add checkout or any -execution of code, scripts, artifacts, or configuration from the pull request -head to this workflow. From 69839ddd5856bf5a29006e3a95cef18a5f23fec3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:18:06 -0300 Subject: [PATCH 13/23] refactor: publish first merged PR comment template\n\nSigned-off-by: Vitor Mattos --- workflow-catalog.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workflow-catalog.json b/workflow-catalog.json index a1e9972..3c312c4 100644 --- a/workflow-catalog.json +++ b/workflow-catalog.json @@ -2,7 +2,7 @@ "templates": [ "appstore-build-publish", "block-unconventional-commits", - "first-merged-contribution", + "first-merged-pr-comment", "lint-eslint", "lint-info-xml", "lint-php", @@ -18,4 +18,4 @@ "reuse", "sync-workflow-templates" ] -} +}\n \ No newline at end of file From f6eeb5e8e91c4c4d9beffa41cc8df1bbe20df9af Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:18:19 -0300 Subject: [PATCH 14/23] test: validate generic workflow template contract\n\nSigned-off-by: Vitor Mattos --- tests/test_first_merged_pr_comment_action.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_first_merged_pr_comment_action.py b/tests/test_first_merged_pr_comment_action.py index 17e7224..d523ead 100644 --- a/tests/test_first_merged_pr_comment_action.py +++ b/tests/test_first_merged_pr_comment_action.py @@ -188,6 +188,17 @@ def test_action_contract_has_no_product_specific_inputs(self) -> None: self.assertNotIn("community", action.lower()) self.assertNotIn("good first issue", action.lower()) + def test_workflow_template_only_exposes_message_configuration(self) -> None: + workflow = ( + ROOT / "workflow-templates" / "first-merged-pr-comment.yml" + ).read_text(encoding="utf-8") + self.assertIn("vars.FIRST_MERGED_PR_MESSAGE", workflow) + self.assertIn("message-template:", workflow) + self.assertNotIn("survey", workflow.lower()) + self.assertNotIn("community", workflow.lower()) + self.assertNotIn("good first issue", workflow.lower()) + self.assertNotIn("checkout", workflow.lower()) + if __name__ == "__main__": unittest.main() From 4bf6207040e0051cbf6d3096f9f3cb2e0ee59a3d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:18:35 -0300 Subject: [PATCH 15/23] test: check workflow does not execute checkout\n\nSigned-off-by: Vitor Mattos --- tests/test_first_merged_pr_comment_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_first_merged_pr_comment_action.py b/tests/test_first_merged_pr_comment_action.py index d523ead..a239b8e 100644 --- a/tests/test_first_merged_pr_comment_action.py +++ b/tests/test_first_merged_pr_comment_action.py @@ -197,7 +197,7 @@ def test_workflow_template_only_exposes_message_configuration(self) -> None: self.assertNotIn("survey", workflow.lower()) self.assertNotIn("community", workflow.lower()) self.assertNotIn("good first issue", workflow.lower()) - self.assertNotIn("checkout", workflow.lower()) + self.assertNotIn("uses: actions/checkout@", workflow) if __name__ == "__main__": From a763ae3a06c6edbf7f13f892f7994ade69db93d7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:19:20 -0300 Subject: [PATCH 16/23] fix: restore valid workflow catalog JSON\n\nSigned-off-by: Vitor Mattos --- workflow-catalog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflow-catalog.json b/workflow-catalog.json index 3c312c4..0b3d8e0 100644 --- a/workflow-catalog.json +++ b/workflow-catalog.json @@ -18,4 +18,4 @@ "reuse", "sync-workflow-templates" ] -}\n \ No newline at end of file +} From 99b443b3951d272229165b74ade6fe4c01a872ac Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:22:40 -0300 Subject: [PATCH 17/23] fix: avoid first-merge search indexing race\n\nSigned-off-by: Vitor Mattos --- .../first_merged_pr_comment.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/actions/first-merged-pr-comment/first_merged_pr_comment.py b/actions/first-merged-pr-comment/first_merged_pr_comment.py index 4002403..b0275c3 100755 --- a/actions/first-merged-pr-comment/first_merged_pr_comment.py +++ b/actions/first-merged-pr-comment/first_merged_pr_comment.py @@ -114,14 +114,14 @@ def write_output(name: str, value: str) -> None: handle.write(f"{name}={value}\n") -def first_merged_query(repository: str, login: str, closed_at: str) -> str: +def previous_merged_query(repository: str, login: str, closed_at: str) -> str: return " ".join( ( f"repo:{repository}", "is:pr", "is:merged", f"author:{login}", - f"closed:<={closed_at}", + f"closed:<{closed_at}", ) ) @@ -172,15 +172,18 @@ def process_pull_request( return result login = str(pr["user"]["login"]) - query = first_merged_query(repository, login, str(pr["closed_at"])) - encoded_query = urllib.parse.urlencode({"q": query, "per_page": 2}) + query = previous_merged_query(repository, login, str(pr["closed_at"])) + encoded_query = urllib.parse.urlencode({"q": query, "per_page": 1}) search = request( "GET", f"{api_url}/search/issues?{encoded_query}", token, None, ) - if int(search["total_count"]) != 1: + # Search only for earlier merged PRs. Do not require the current PR to + # have reached the search index yet; the closed event can arrive before + # search indexing catches up. + if int(search["total_count"]) != 0: return result result["is-first-merged"] = "true" From 2898f306856653dab81f774a61d0287f417be5a2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:22:43 -0300 Subject: [PATCH 18/23] test: cover first-merge search indexing race\n\nSigned-off-by: Vitor Mattos --- tests/test_first_merged_pr_comment_action.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/test_first_merged_pr_comment_action.py b/tests/test_first_merged_pr_comment_action.py index a239b8e..9b229d5 100644 --- a/tests/test_first_merged_pr_comment_action.py +++ b/tests/test_first_merged_pr_comment_action.py @@ -18,7 +18,7 @@ class FakeApi: - def __init__(self, *, total_count: int = 1, comments: list[dict[str, Any]] | None = None) -> None: + def __init__(self, *, total_count: int = 0, comments: list[dict[str, Any]] | None = None) -> None: self.total_count = total_count self.comments = comments or [] self.calls: list[tuple[str, str, dict[str, Any] | None]] = [] @@ -132,7 +132,7 @@ def test_first_merged_pr_creates_comment(self) -> None: self.assertIn("Thanks @alice.", post[0][2]["body"]) def test_second_merged_pr_does_not_create_comment(self) -> None: - api = FakeApi(total_count=2) + api = FakeApi(total_count=1) result = self.process(api) self.assertEqual(result["is-first-merged"], "false") self.assertEqual(result["comment-created"], "false") @@ -160,8 +160,8 @@ def test_existing_marker_makes_retry_idempotent(self) -> None: self.assertEqual(result["comment-created"], "false") self.assertFalse(any(call[0] == "POST" for call in api.calls)) - def test_first_merged_query_is_historical_for_safe_retries(self) -> None: - query = module.first_merged_query( + def test_previous_merged_query_excludes_current_pr(self) -> None: + query = module.previous_merged_query( "acme/project", "alice", "2026-09-23T12:00:00Z", @@ -169,9 +169,15 @@ def test_first_merged_query_is_historical_for_safe_retries(self) -> None: self.assertEqual( query, "repo:acme/project is:pr is:merged author:alice " - "closed:<=2026-09-23T12:00:00Z", + "closed:<2026-09-23T12:00:00Z", ) + def test_first_merge_does_not_depend_on_current_pr_search_indexing(self) -> None: + api = FakeApi(total_count=0) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "true") + def test_marker_is_stable_for_idempotency(self) -> None: self.assertEqual( module.MARKER, From d176eb20ff38baf42ce3e0eb034ee2ade7c8228d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:23:12 -0300 Subject: [PATCH 19/23] fix: pin template to indexing-safe action revision\n\nSigned-off-by: Vitor Mattos --- workflow-templates/first-merged-pr-comment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflow-templates/first-merged-pr-comment.yml b/workflow-templates/first-merged-pr-comment.yml index ba65363..e96aa71 100644 --- a/workflow-templates/first-merged-pr-comment.yml +++ b/workflow-templates/first-merged-pr-comment.yml @@ -47,7 +47,7 @@ jobs: # pull_request_target is intentionally used without checkout. Nothing # from the pull request head is downloaded or executed. - name: Comment on first merged pull request - uses: LibreCodeCoop/github-workflows/actions/first-merged-pr-comment@77e0173ef70a380e32bb77fbca6e9b5d416e31df + uses: LibreCodeCoop/github-workflows/actions/first-merged-pr-comment@99b443b3951d272229165b74ade6fe4c01a872ac with: github-token: ${{ github.token }} pull-request-number: >- From 5c26716fb3e541ff7a501a391aecec5ff8ec7b92 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:26:22 -0300 Subject: [PATCH 20/23] fix: harden first merged PR API handling\n\nSigned-off-by: Vitor Mattos --- .../first_merged_pr_comment.py | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/actions/first-merged-pr-comment/first_merged_pr_comment.py b/actions/first-merged-pr-comment/first_merged_pr_comment.py index b0275c3..8d0a924 100755 --- a/actions/first-merged-pr-comment/first_merged_pr_comment.py +++ b/actions/first-merged-pr-comment/first_merged_pr_comment.py @@ -71,12 +71,12 @@ def build_context( } -def api_request( +def build_api_request( method: str, url: str, token: str, payload: dict[str, Any] | None = None, -) -> Any: +) -> urllib.request.Request: data = None if payload is None else json.dumps(payload).encode("utf-8") request = urllib.request.Request( url, @@ -84,11 +84,24 @@ def api_request( method=method, headers={ "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", }, ) + # Keep credentials off redirected requests. urllib forwards normal headers + # across redirects, which could otherwise disclose the GitHub token if an + # API endpoint ever redirected to a different origin. + request.add_unredirected_header("Authorization", f"Bearer {token}") + return request + + +def api_request( + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, +) -> Any: + request = build_api_request(method, url, token, payload) try: with urllib.request.urlopen(request, timeout=30) as response: body = response.read().decode("utf-8") @@ -126,16 +139,15 @@ def previous_merged_query(repository: str, login: str, closed_at: str) -> str: ) -def list_issue_comments( +def has_action_marker_comment( *, api_url: str, repository: str, pull_request_number: int, token: str, request: ApiRequest = api_request, -) -> list[dict[str, Any]]: +) -> bool: owner, repo = repository.split("/", 1) - comments: list[dict[str, Any]] = [] page = 1 while True: batch = request( @@ -145,9 +157,15 @@ def list_issue_comments( token, None, ) - comments.extend(batch) + for comment in batch: + author = comment.get("user") or {} + if ( + author.get("type") == "Bot" + and MARKER in str(comment.get("body") or "") + ): + return True if len(batch) < 100: - return comments + return False page += 1 @@ -188,14 +206,13 @@ def process_pull_request( result["is-first-merged"] = "true" - comments = list_issue_comments( + if has_action_marker_comment( api_url=api_url, repository=repository, pull_request_number=int(pr["number"]), token=token, request=request, - ) - if any(MARKER in str(comment.get("body") or "") for comment in comments): + ): return result context = build_context( From 86a56e32b7f5e14d91150fb6743ea73f34223d80 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:26:32 -0300 Subject: [PATCH 21/23] test: cover redirect and marker hardening\n\nSigned-off-by: Vitor Mattos --- tests/test_first_merged_pr_comment_action.py | 36 ++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/test_first_merged_pr_comment_action.py b/tests/test_first_merged_pr_comment_action.py index 9b229d5..afa40a1 100644 --- a/tests/test_first_merged_pr_comment_action.py +++ b/tests/test_first_merged_pr_comment_action.py @@ -153,13 +153,33 @@ def test_bot_pr_does_not_call_api(self) -> None: self.assertEqual(result["comment-created"], "false") self.assertEqual(api.calls, []) - def test_existing_marker_makes_retry_idempotent(self) -> None: - api = FakeApi(comments=[{"body": f"{module.MARKER}\nAlready sent"}]) + def test_existing_bot_marker_makes_retry_idempotent(self) -> None: + api = FakeApi( + comments=[ + { + "body": f"{module.MARKER}\nAlready sent", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ] + ) result = self.process(api) self.assertEqual(result["is-first-merged"], "true") self.assertEqual(result["comment-created"], "false") self.assertFalse(any(call[0] == "POST" for call in api.calls)) + def test_user_cannot_suppress_comment_by_copying_marker(self) -> None: + api = FakeApi( + comments=[ + { + "body": f"{module.MARKER}\nSpoofed", + "user": {"login": "alice", "type": "User"}, + } + ] + ) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "true") + def test_previous_merged_query_excludes_current_pr(self) -> None: query = module.previous_merged_query( "acme/project", @@ -178,6 +198,18 @@ def test_first_merge_does_not_depend_on_current_pr_search_indexing(self) -> None self.assertEqual(result["is-first-merged"], "true") self.assertEqual(result["comment-created"], "true") + def test_authorization_header_is_not_forwarded_on_redirects(self) -> None: + request = module.build_api_request( + "GET", + "https://api.github.com/repos/acme/project", + "secret-token", + ) + self.assertNotIn("Authorization", request.headers) + self.assertEqual( + request.unredirected_hdrs["Authorization"], + "Bearer secret-token", + ) + def test_marker_is_stable_for_idempotency(self) -> None: self.assertEqual( module.MARKER, From 9119457b8bdd54d76b6feb8dce35802415cd748d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:26:42 -0300 Subject: [PATCH 22/23] fix: pin template to hardened action revision\n\nSigned-off-by: Vitor Mattos --- workflow-templates/first-merged-pr-comment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflow-templates/first-merged-pr-comment.yml b/workflow-templates/first-merged-pr-comment.yml index e96aa71..7e6a3dc 100644 --- a/workflow-templates/first-merged-pr-comment.yml +++ b/workflow-templates/first-merged-pr-comment.yml @@ -47,7 +47,7 @@ jobs: # pull_request_target is intentionally used without checkout. Nothing # from the pull request head is downloaded or executed. - name: Comment on first merged pull request - uses: LibreCodeCoop/github-workflows/actions/first-merged-pr-comment@99b443b3951d272229165b74ade6fe4c01a872ac + uses: LibreCodeCoop/github-workflows/actions/first-merged-pr-comment@5c26716fb3e541ff7a501a391aecec5ff8ec7b92 with: github-token: ${{ github.token }} pull-request-number: >- From 5023b5e053faa825d316993b6dbc5f73845baddb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 23 Sep 2026 16:26:53 -0300 Subject: [PATCH 23/23] docs: document contributor workflow security controls\n\nSigned-off-by: Vitor Mattos --- docs/first-merged-pr-comment.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/first-merged-pr-comment.md b/docs/first-merged-pr-comment.md index 896ae27..706882f 100644 --- a/docs/first-merged-pr-comment.md +++ b/docs/first-merged-pr-comment.md @@ -106,7 +106,18 @@ The provided template: - does not download or execute pull-request artifacts; - calls the LibreCode composite action at an immutable commit; - uses Python's standard library only; +- keeps the GitHub authorization header off redirected requests; +- only trusts the idempotency marker when it appears in a bot-authored comment; - does not build or run a Docker image; - does not install runtime dependencies. Do not add checkout or execution of pull-request-head content to this workflow. + + +### pull_request_target policy + +GitHub treats `pull_request_target` as a privileged event. Repositories or +organizations that restrict this event must explicitly allow this workflow. +The workflow is designed for that privileged model: it never checks out or +executes pull-request-head content and requests only the permission needed to +create the pull-request comment.