From 0612a0b0be706c44a93054e55392de20904c8560 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 15:49:48 +0200 Subject: [PATCH] feat: add reusable Telegram notification workflows --- .../prepare-telegram-issue-message/action.yml | 17 ++ .../prepare-telegram-issue-message/prepare.py | 59 +++++ .../prepare-telegram-pr-message/action.yml | 19 ++ .../prepare-telegram-pr-message/prepare.py | 136 +++++++++++ .../actions/send-telegram-message/action.yml | 31 +++ .../notify-telegram-issue-shared.yml | 25 ++ .../workflows/notify-telegram-pr-shared.yml | 29 +++ .github/workflows/notify-telegram-pr.yml | 13 ++ .../workflows/notify-telegram-unreleased.yml | 9 - AGENTS.md | 17 +- README.md | 62 ++++- test/test_telegram_notifications.py | 215 ++++++++++++++++++ 12 files changed, 616 insertions(+), 16 deletions(-) create mode 100644 .github/actions/prepare-telegram-issue-message/action.yml create mode 100644 .github/actions/prepare-telegram-issue-message/prepare.py create mode 100644 .github/actions/prepare-telegram-pr-message/action.yml create mode 100644 .github/actions/prepare-telegram-pr-message/prepare.py create mode 100644 .github/actions/send-telegram-message/action.yml create mode 100644 .github/workflows/notify-telegram-issue-shared.yml create mode 100644 .github/workflows/notify-telegram-pr-shared.yml create mode 100644 .github/workflows/notify-telegram-pr.yml delete mode 100644 .github/workflows/notify-telegram-unreleased.yml create mode 100644 test/test_telegram_notifications.py diff --git a/.github/actions/prepare-telegram-issue-message/action.yml b/.github/actions/prepare-telegram-issue-message/action.yml new file mode 100644 index 0000000..bb30731 --- /dev/null +++ b/.github/actions/prepare-telegram-issue-message/action.yml @@ -0,0 +1,17 @@ +name: Prepare Telegram issue message +description: Prepare a Telegram message for a closed issue. +outputs: + message: + description: Prepared message. + value: ${{ steps.prepare.outputs.message }} +runs: + using: composite + steps: + - id: prepare + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + ACTOR: ${{ github.actor }} + run: python3 "${{ github.action_path }}/prepare.py" diff --git a/.github/actions/prepare-telegram-issue-message/prepare.py b/.github/actions/prepare-telegram-issue-message/prepare.py new file mode 100644 index 0000000..6d85fde --- /dev/null +++ b/.github/actions/prepare-telegram-issue-message/prepare.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import json +import os +import subprocess +import uuid + +ISSUE_BODY_LIMIT = 500 + + +def gh_json(*arguments): + result = subprocess.run(["gh", *arguments], stdout=subprocess.PIPE, text=True, check=True) + return json.loads(result.stdout) + + +def truncate(value, limit): + value = value.strip() + if len(value) <= limit: + return value + return value[: limit - 3].rstrip() + "..." + + +def format_closed(repository, issue, actor): + lines = [ + f"๐ŸŸข {repository} โ€” issue closed", + f"#{issue['number']} {issue['title']}", + ] + if body := truncate(issue.get("body") or "", ISSUE_BODY_LIMIT): + lines.append(body) + lines.append(f"{actor} ยท {issue['url']}") + return "\n".join(lines) + + +def write_output(message): + delimiter = f"ghdelim_{uuid.uuid4().hex}" + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + print(f"message<<{delimiter}", file=output) + print(message, file=output) + print(delimiter, file=output) + + +def main(): + repository = os.environ["REPOSITORY"] + number = os.environ["NUMBER"] + actor = os.environ["ACTOR"] + issue = gh_json( + "issue", + "view", + number, + "--repo", + repository, + "--json", + "number,title,url,body", + ) + write_output(format_closed(repository, issue, actor)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/actions/prepare-telegram-pr-message/action.yml b/.github/actions/prepare-telegram-pr-message/action.yml new file mode 100644 index 0000000..1cee25d --- /dev/null +++ b/.github/actions/prepare-telegram-pr-message/action.yml @@ -0,0 +1,19 @@ +name: Prepare Telegram PR message +description: Prepare a Telegram message for the current pull request event or digest. +outputs: + message: + description: Prepared message, or an empty string when no message is needed. + value: ${{ steps.prepare.outputs.message }} +runs: + using: composite + steps: + - id: prepare + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + NUMBER: ${{ github.event.pull_request.number }} + MERGED: ${{ github.event.pull_request.merged }} + run: python3 "${{ github.action_path }}/prepare.py" diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py new file mode 100644 index 0000000..02715d7 --- /dev/null +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +import json +import os +import subprocess +import uuid +from datetime import datetime, timezone + +DIGEST_PR_LIMIT = 10 +TELEGRAM_MESSAGE_LIMIT = 4096 + + +def gh_json(*arguments): + result = subprocess.run(["gh", *arguments], stdout=subprocess.PIPE, text=True, check=True) + return json.loads(result.stdout) + + +def author_login(pull_request): + return (pull_request.get("author") or {}).get("login") or "ghost" + + +def format_opened(repository, pull_request, event_action): + if pull_request.get("isDraft"): + return "" + + events = { + "ready_for_review": ("๐Ÿ†—", "PR ready for review"), + "reopened": ("๐Ÿ†™", "PR reopened"), + } + emoji, event = events.get(event_action, ("๐Ÿ†•", "PR opened")) + return ( + f"{emoji} {repository} โ€” {event}\n" + f"#{pull_request['number']} {pull_request['title']}\n" + f"{author_login(pull_request)} ยท {pull_request['url']}" + ) + + +def format_merged(repository, pull_request): + return ( + f"๐Ÿ”€ {repository} โ€” PR merged\n" + f"#{pull_request['number']} {pull_request['title']}\n" + f"{author_login(pull_request)} ยท {pull_request['url']}" + ) + + +def parse_github_time(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def format_digest(repository, pull_requests, now=None): + pull_requests = [pull_request for pull_request in pull_requests if not pull_request.get("isDraft")] + if not pull_requests: + return "" + + now = now or datetime.now(timezone.utc) + header = f"๐Ÿ”  {repository} โ€” {len(pull_requests)} open PR(s)" + lines = [] + for pull_request in pull_requests[:DIGEST_PR_LIMIT]: + age = max(0, (now - parse_github_time(pull_request["createdAt"])).days) + line = ( + f"#{pull_request['number']} {pull_request['title']}\n" + f"{author_login(pull_request)} ยท {age}d ยท {pull_request['url']}" + ) + remaining = len(pull_requests) - len(lines) - 1 + suffix = f"...and {remaining} more ยท https://github.com/{repository}/pulls" if remaining else "" + candidate = "\n".join((header, *lines, line, suffix)).rstrip() + if len(candidate) > TELEGRAM_MESSAGE_LIMIT: + break + lines.append(line) + + remaining = len(pull_requests) - len(lines) + if remaining: + lines.append(f"...and {remaining} more ยท https://github.com/{repository}/pulls") + + return "\n".join((header, *lines)) + + +def write_output(message): + delimiter = f"ghdelim_{uuid.uuid4().hex}" + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + print(f"message<<{delimiter}", file=output) + print(message, file=output) + print(delimiter, file=output) + + +def main(): + repository = os.environ["REPOSITORY"] + event_name = os.environ["EVENT_NAME"] + event_action = os.environ.get("EVENT_ACTION") or None + number = os.environ.get("NUMBER") or None + merged = os.environ.get("MERGED") == "true" + + if event_name in ("schedule", "workflow_dispatch"): + pull_requests = gh_json( + "pr", + "list", + "--repo", + repository, + "--state", + "open", + "--limit", + "100", + "--json", + "number,title,author,isDraft,url,createdAt", + ) + message = format_digest(repository, pull_requests) + elif event_name == "pull_request_target" and event_action in ("opened", "ready_for_review", "reopened"): + pull_request = gh_json( + "pr", + "view", + number, + "--repo", + repository, + "--json", + "number,title,author,url,isDraft", + ) + message = format_opened(repository, pull_request, event_action) + elif event_name == "pull_request_target" and event_action == "closed" and merged: + pull_request = gh_json( + "pr", + "view", + number, + "--repo", + repository, + "--json", + "number,title,author,url", + ) + message = format_merged(repository, pull_request) + else: + raise ValueError(f"Unsupported pull request notification event: {event_name}/{event_action}") + + write_output(message) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/actions/send-telegram-message/action.yml b/.github/actions/send-telegram-message/action.yml new file mode 100644 index 0000000..55910ce --- /dev/null +++ b/.github/actions/send-telegram-message/action.yml @@ -0,0 +1,31 @@ +name: Send Telegram message +description: Send a plain-text message to a Telegram chat. +inputs: + message: + description: Message text. + required: true + telegram-bot-token: + description: Telegram bot token. + required: true + telegram-chat-id: + description: Telegram chat ID. + required: true +runs: + using: composite + steps: + - shell: bash + env: + MESSAGE: ${{ inputs.message }} + BOT_TOKEN: ${{ inputs.telegram-bot-token }} + CHAT_ID: ${{ inputs.telegram-chat-id }} + run: | + set -euo pipefail + curl -fsSL \ + --output /dev/null \ + -X POST \ + "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg c "$CHAT_ID" \ + --arg t "$MESSAGE" \ + '{chat_id: $c, text: $t, link_preview_options: {is_disabled: true}}')" diff --git a/.github/workflows/notify-telegram-issue-shared.yml b/.github/workflows/notify-telegram-issue-shared.yml new file mode 100644 index 0000000..41878d5 --- /dev/null +++ b/.github/workflows/notify-telegram-issue-shared.yml @@ -0,0 +1,25 @@ +name: Notify Telegram issue (shared) +on: + workflow_call: + secrets: + TELEGRAM_BOT_TOKEN: + required: true + TELEGRAM_CHAT_ID: + required: true +jobs: + notify-closed: + if: >- + github.event_name == 'issues' && + github.event.action == 'closed' + runs-on: ubuntu-latest + permissions: + issues: read + steps: + - id: prepare + uses: $/.github/actions/prepare-telegram-issue-message + - name: Send + uses: $/.github/actions/send-telegram-message + with: + message: ${{ steps.prepare.outputs.message }} + telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} diff --git a/.github/workflows/notify-telegram-pr-shared.yml b/.github/workflows/notify-telegram-pr-shared.yml new file mode 100644 index 0000000..1fa9062 --- /dev/null +++ b/.github/workflows/notify-telegram-pr-shared.yml @@ -0,0 +1,29 @@ +name: Notify Telegram PR (shared) +on: + workflow_call: + secrets: + TELEGRAM_BOT_TOKEN: + required: true + TELEGRAM_CHAT_ID: + required: true +jobs: + notify: + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request_target' && + (contains(fromJSON('["opened","ready_for_review","reopened"]'), github.event.action) || + (github.event.action == 'closed' && github.event.pull_request.merged == true))) + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - id: prepare + uses: $/.github/actions/prepare-telegram-pr-message + - name: Send + if: steps.prepare.outputs.message != '' + uses: $/.github/actions/send-telegram-message + with: + message: ${{ steps.prepare.outputs.message }} + telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} diff --git a/.github/workflows/notify-telegram-pr.yml b/.github/workflows/notify-telegram-pr.yml new file mode 100644 index 0000000..599090e --- /dev/null +++ b/.github/workflows/notify-telegram-pr.yml @@ -0,0 +1,13 @@ +name: Notify Telegram PR +on: + pull_request_target: + types: [opened, ready_for_review, reopened, closed] + schedule: + - cron: "0 10 * * *" + workflow_dispatch: +jobs: + notify: + uses: ./.github/workflows/notify-telegram-pr-shared.yml + secrets: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} diff --git a/.github/workflows/notify-telegram-unreleased.yml b/.github/workflows/notify-telegram-unreleased.yml deleted file mode 100644 index bc618f6..0000000 --- a/.github/workflows/notify-telegram-unreleased.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: Notify Telegram unreleased commits -on: - schedule: - - cron: "0 8 * * *" - workflow_dispatch: -jobs: - notify: - uses: rubykatzen/releaser/.github/workflows/notify-telegram-unreleased-shared.yml@v0.5 - secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index d1d43b7..43237a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,12 +69,17 @@ tools to already be installed in the developer environment. - `.github/actions/detect-linters/` โ€” composite action that selects applicable linters from tracked files - `.github/actions/check-precommit/` โ€” composite action: verifies pre-commit hooks match detected CI linters - `.github/actions/setup-runtimes/` โ€” installs Python packages, Ruby, and standalone binaries for requested linters; Python is provided by the runner +- `.github/actions/prepare-telegram-pr-message/` โ€” formats pull request and open-PR digest messages +- `.github/actions/prepare-telegram-issue-message/` โ€” formats closed-issue messages +- `.github/actions/send-telegram-message/` โ€” sends plain-text messages through the Telegram Bot API - `.github/workflows/lint-shared.yml` โ€” reusable workflow exported for consuming repos: setup + lint - `.github/workflows/embedder-shared.yml` โ€” reusable workflow exported for required content validation +- `.github/workflows/notify-telegram-pr-shared.yml` โ€” reusable pull request notifications and open-PR digest +- `.github/workflows/notify-telegram-issue-shared.yml` โ€” optional reusable issue-closure notifications - `.github/workflows/pr.yml` โ€” validates Baseline pull request titles against Conventional Commits - `.github/workflows/lint.yml` โ€” baseline self-lint (uses local `./` references, not `@vX`) - `.github/workflows/release-please.yml` โ€” maintains the release PR and publishes merged releases -- `.github/workflows/notify-telegram-unreleased.yml` โ€” baseline's own caller (delegates to `rubykatzen/releaser`) +- `.github/workflows/notify-telegram-pr.yml` โ€” Baseline's own Telegram pull request notification caller - `.pre-commit-hooks.yaml` โ€” hook definitions for pre-commit - `.pre-commit-config.yaml.example` โ€” example for consuming repos (all hooks, prune as needed) @@ -99,8 +104,14 @@ Pre-commit hooks expect tools to already be on PATH in the developer environment ## Workflows -`notify-telegram-unreleased.yml` is baseline's own caller that delegates to -`rubykatzen/releaser`; it is not exported for external use. +`notify-telegram-pr-shared.yml` exports pull request notifications and the +open-pull-request digest. Baseline calls it locally through +`notify-telegram-pr.yml`. + +`notify-telegram-issue-shared.yml` exports optional issue-closure notifications. +The caller owns any label or other notification condition; the shared workflow +does not modify issues. Baseline does not call it because this repository does +not use issue notifications. `lint-shared.yml` is the primary export โ€” consuming repos call it via `uses: rubykatzen/baseline/.github/workflows/lint-shared.yml@VERSION`. diff --git a/README.md b/README.md index cd6567b..7f6a39d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ repositories install runtimes and linter binaries only for local pre-commit use. Replace `VERSION` in all examples with the latest release tag from [github.com/rubykatzen/baseline/releases](https://github.com/rubykatzen/baseline/releases). -After initial setup, [Dependabot](#6-dependabot) keeps the pin current automatically. +After initial setup, [Dependabot](#7-dependabot) keeps the pin current automatically. ### 1. Lint workflow @@ -77,7 +77,61 @@ jobs: The `skip` input must be a JSON array. Unknown check names fail the workflow. -### 3. Embedded content +### 3. Telegram pull request notifications + +Create `.github/workflows/notify-telegram-pr.yml`: + +```yaml +name: Notify Telegram PR +on: + pull_request_target: + types: [opened, ready_for_review, reopened, closed] + schedule: + - cron: "0 10 * * *" + workflow_dispatch: +jobs: + notify: + uses: rubykatzen/baseline/.github/workflows/notify-telegram-pr-shared.yml@VERSION + secrets: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} +``` + +The workflow reports non-draft pull requests when they are opened, reopened, +or marked ready for review, reports merged pull requests, and sends a daily +digest of open non-draft pull requests. An empty digest sends no message. + +`pull_request_target` loads trusted workflow code from the default branch so +Telegram secrets remain available without executing or checking out pull +request code. Pass the two secrets explicitly; do not use `secrets: inherit`. + +### 4. Telegram issue notifications + +Issue notifications are optional. A repository that needs them creates +`.github/workflows/notify-telegram-issue.yml`: + +```yaml +name: Notify Telegram issue +on: + issues: + types: [closed] +jobs: + notify: + if: contains(github.event.issue.labels.*.name, 'notify') + uses: rubykatzen/baseline/.github/workflows/notify-telegram-issue-shared.yml@VERSION + secrets: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_ISSUE_CHAT_ID }} +``` + +This example reports only issues carrying the `notify` label at close time. +The caller owns this condition and may replace it or omit it to report every +closed issue. The shared workflow only formats and sends the notification; it +does not modify the issue. The message includes up to 500 characters of the +issue body. PR and issue callers may map `TELEGRAM_CHAT_ID` to different +repository secrets and therefore different channels. + +### 5. Embedded content Create `.github/workflows/embedder.yml`: @@ -111,7 +165,7 @@ jobs: The `skip` input must be a JSON array. Unknown fragment names fail the workflow. -### 4. Pre-commit hooks +### 6. Pre-commit hooks Copy `.pre-commit-config.yaml.example` to your repo or add to your existing config. Include only the hooks relevant to your stack: @@ -143,7 +197,7 @@ Ruby hooks use `bundle exec`; install Ruby and run `bundle install` in the consuming repository first. `rubocop` and `erb_lint` must be available through the [`rubykatzen-baseline`](#ruby-gem-rubocop--erb_lint) gem. -### 5. Dependabot +### 7. Dependabot Add `.github/dependabot.yml` to keep GitHub Actions and pre-commit pins current automatically: diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py new file mode 100644 index 0000000..b504cef --- /dev/null +++ b/test/test_telegram_notifications.py @@ -0,0 +1,215 @@ +import importlib.util +import os +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import yaml + +BASELINE_ROOT = Path(__file__).parent.parent + + +def load_action_module(name): + path = BASELINE_ROOT / ".github" / "actions" / name / "prepare.py" + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +PR_TELEGRAM = load_action_module("prepare-telegram-pr-message") +ISSUE_TELEGRAM = load_action_module("prepare-telegram-issue-message") + + +class TelegramPullRequestMessageTest(unittest.TestCase): + def setUp(self): + self.pull_request = { + "number": 42, + "title": "feat: add notifications", + "author": {"login": "octocat"}, + "url": "https://github.com/owner/repo/pull/42", + "isDraft": False, + } + + def test_formats_opened_pull_request(self): + message = PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "opened") + + self.assertEqual( + message, + "๐Ÿ†• owner/repo โ€” PR opened\n" + "#42 feat: add notifications\n" + "octocat ยท https://github.com/owner/repo/pull/42", + ) + + def test_formats_ready_and_reopened_pull_request_actions(self): + self.assertIn("๐Ÿ†— owner/repo โ€” PR ready for review", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) + self.assertIn("๐Ÿ†™ owner/repo โ€” PR reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) + + def test_skips_draft_pull_request(self): + self.pull_request["isDraft"] = True + + self.assertEqual(PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "opened"), "") + + def test_formats_merged_pull_request(self): + message = PR_TELEGRAM.format_merged("owner/repo", self.pull_request) + + self.assertIn("๐Ÿ”€ owner/repo โ€” PR merged", message) + self.assertIn("#42 feat: add notifications", message) + + def test_open_pull_request_digest_excludes_drafts_and_reports_age(self): + draft = dict(self.pull_request, number=41, isDraft=True) + message = PR_TELEGRAM.format_digest( + "owner/repo", + [draft, self.pull_request | {"createdAt": "2026-08-10T12:00:00Z"}], + now=datetime(2026, 8, 12, 12, tzinfo=timezone.utc), + ) + + self.assertIn("1 open PR(s)", message) + self.assertTrue(message.startswith("๐Ÿ”  owner/repo")) + self.assertIn("octocat ยท 2d", message) + self.assertNotIn("#41", message) + self.assertNotIn("more", message) + + def test_open_pull_request_digest_is_empty_without_targets(self): + self.assertEqual(PR_TELEGRAM.format_digest("owner/repo", []), "") + + def test_open_pull_request_digest_lists_at_most_ten_pull_requests(self): + pull_requests = [ + self.pull_request + | { + "number": number, + "createdAt": "2026-08-10T12:00:00Z", + } + for number in range(1, 13) + ] + + message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) + + self.assertIn("12 open PR(s)", message) + self.assertIn("#10 feat: add notifications", message) + self.assertNotIn("#11 feat: add notifications", message) + self.assertIn("...and 2 more ยท https://github.com/owner/repo/pulls", message) + + def test_open_pull_request_digest_stays_within_telegram_limit(self): + pull_requests = [ + self.pull_request + | { + "number": number, + "title": "x" * 1000, + "createdAt": "2026-08-10T12:00:00Z", + } + for number in range(1, 11) + ] + + message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) + + self.assertLessEqual(len(message), PR_TELEGRAM.TELEGRAM_MESSAGE_LIMIT) + self.assertIn("more ยท https://github.com/owner/repo/pulls", message) + + def test_writes_multiline_github_output(self): + with tempfile.NamedTemporaryFile() as output, patch.dict(os.environ, {"GITHUB_OUTPUT": output.name}): + PR_TELEGRAM.write_output("first\nsecond") + value = Path(output.name).read_text() + + self.assertRegex(value, r"^message<