From d790a9edfe4680e2e4ccdedc42e86e7ca95e987b Mon Sep 17 00:00:00 2001 From: k3nny Date: Sun, 12 Jul 2026 13:55:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(notify):=20release=20v1.8.0=20=E2=80=94=20?= =?UTF-8?q?Slack/Teams/Google=20Chat/Telegram/webhook=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/notify package: best-effort release notifications to Slack, Microsoft Teams, Google Chat, Telegram, and a generic JSON webhook; every target is independently opt-in and a failed notification never fails the release - Add notify config section (slack_webhook_url, teams_webhook_url, google_chat_webhook_url, telegram_bot_token/telegram_chat_id, webhook_url) with matching env var fallbacks; 100% coverage plus FuzzMessagePayloads - Wire notification sending into run() — fires after tag+push (including --no-release), skipped on --no-push/--no-commit since nothing was published yet - Document the notify section in README, Hugo docs, and .releaser.yml template; update CLAUDE.md architecture/fuzzing tables - Also commit the Apache License 2.0 docs-site footer link (docs/hugo.toml geekdocContentLicense), left uncommitted from a prior change Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 8 ++ CLAUDE.md | 15 +++ README.md | 18 +++- ROADMAP.md | 2 +- cmd/main.go | 86 +++++++++++++---- cmd/main_test.go | 112 +++++++++++++++++++++++ docs/content/changelog.md | 6 ++ docs/content/configuration.md | 34 +++++++ docs/hugo.toml | 4 + internal/config/config.go | 84 +++++++++++++++++ internal/config/config_test.go | 92 +++++++++++++++++++ internal/notify/googlechat.go | 20 ++++ internal/notify/googlechat_test.go | 37 ++++++++ internal/notify/notify.go | 88 ++++++++++++++++++ internal/notify/notify_fuzz_test.go | 28 ++++++ internal/notify/notify_test.go | 137 ++++++++++++++++++++++++++++ internal/notify/slack.go | 20 ++++ internal/notify/slack_test.go | 40 ++++++++ internal/notify/teams.go | 31 +++++++ internal/notify/teams_test.go | 43 +++++++++ internal/notify/telegram.go | 30 ++++++ internal/notify/telegram_test.go | 53 +++++++++++ internal/notify/webhook.go | 18 ++++ internal/notify/webhook_test.go | 39 ++++++++ 24 files changed, 1023 insertions(+), 22 deletions(-) create mode 100644 internal/notify/googlechat.go create mode 100644 internal/notify/googlechat_test.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_fuzz_test.go create mode 100644 internal/notify/notify_test.go create mode 100644 internal/notify/slack.go create mode 100644 internal/notify/slack_test.go create mode 100644 internal/notify/teams.go create mode 100644 internal/notify/teams_test.go create mode 100644 internal/notify/telegram.go create mode 100644 internal/notify/telegram_test.go create mode 100644 internal/notify/webhook.go create mode 100644 internal/notify/webhook_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f900a..aec385b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.8.0] - 2026-07-12 + +### Added + +- **Release notifications** — new `internal/notify` package; best-effort notification on release to Slack, Microsoft Teams, Google Chat, Telegram, and/or a generic JSON webhook; every target is independently opt-in and a failed notification never fails the release +- **`notify` config section** — `slack_webhook_url`, `teams_webhook_url`, `google_chat_webhook_url`, `telegram_bot_token` + `telegram_chat_id`, `webhook_url`; each also configurable via env var (`SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`, `GOOGLE_CHAT_WEBHOOK_URL`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `RELEASER_WEBHOOK_URL`) +- **`FuzzMessagePayloads`** in `internal/notify` — 100% per-package statement coverage maintained across all 15 packages + ## [1.7.1] - 2026-07-12 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index bc9b086..3326be1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,9 +15,12 @@ internal/config/ — .releaser.yml loader + env var overlay + source trackin internal/ghclient/ — minimal GitHub Releases API client internal/gitutil/ — go-git helpers: tag discovery, commit walker, push internal/glclient/ — minimal GitLab Releases API client +internal/gradle/ — build.gradle / build.gradle.kts version reader/writer internal/maven/ — pom.xml version reader/writer internal/node/ — package.json version reader/writer internal/notes/ — release notes body generator +internal/notify/ — Slack / Teams / Google Chat / Telegram / webhook release notifications +internal/pyproject/ — pyproject.toml version reader/writer internal/version/ — semver next-version calculator ``` @@ -67,6 +70,7 @@ All seed cases must pass. The table below is authoritative — keep it in sync w | `internal/maven` | `FuzzReadVersion`, `FuzzReplaceProjectVersion` | reads/rewrites arbitrary XML file content | | `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content | | `internal/notes` | `FuzzGenerate` | generates notes from arbitrary commit messages | +| `internal/notify` | `FuzzMessagePayloads` | builds notification JSON payloads from arbitrary release notes | Packages **not** requiring fuzz tests (no free-form text parsing): `internal/config` (yaml.v3 handles parsing), `internal/ghclient` (HTTP client, no text parsing), `internal/gitutil` (git operations), `internal/version` (typed inputs only), `cmd` (CLI orchestration). When adding a new package, check whether it parses text or rewrites files — if yes, add a row above. @@ -75,6 +79,17 @@ Fuzz seed corpus guidelines: - Include empty string, binary/non-UTF-8 bytes (`"\x00\xff"`), and inputs that stress known branches (e.g. existing `## [version]` heading for changelog). - The fuzz body must never assert on return values — only verify no panic. +## Documentation sync + +**Every change that adds, changes, or removes a feature, flag, or config key must update all of the following in the same commit:** + +- `README.md` — features list, usage examples, option tables, version badge +- `CHANGELOG.md` — new dated `## [X.Y.Z]` entry (see `/release` skill for format) +- `ROADMAP.md` — mark shipped items `✓`, add a "Shipped in vX.Y.Z" note, drop outdated caveats +- Hugo docs under `docs/content/` (`installation.md`, `usage.md`, `configuration.md`, `ci-integration.md`, `changelog.md`) — whichever pages describe the changed behavior + +Treat these as one unit: a PR that changes CLI behavior or config but leaves any of the above stale is incomplete. `docs/content/changelog.md` only needs an entry for tagged (minor/major) releases, matching the pattern already in that file — patch-only releases are covered by the root `CHANGELOG.md` but not duplicated there. + ## Dependency rules - **No new external dependencies** unless absolutely necessary. The project deliberately avoids pulling in large ecosystems. diff --git a/README.md b/README.md index 94924ba..4cb2d06 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ releaser logo -![release](https://img.shields.io/badge/release-v1.7.1-blue.svg) +![release](https://img.shields.io/badge/release-v1.8.0-blue.svg) A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. @@ -28,6 +28,7 @@ release/1.2 branch 3. **Commit analysis** — parses Conventional Commits between last tag and HEAD 4. **Version bump** — increments patch (the minor is owned by the branch) 5. **Release** — updates `pom.xml` / `package.json` / `build.gradle`, commits, tags, creates GitLab or GitHub release +6. **Notify** — best-effort notification to Slack, Microsoft Teams, Google Chat, Telegram, and/or a generic webhook ## Version bump rules @@ -139,6 +140,14 @@ gitlab: github: token: "" # env GITHUB_TOKEN (never commit this) repo: "" # "owner/repo" format + +notify: # opt-in — every field independent; failures are warnings, not errors + # slack_webhook_url: "" # or env SLACK_WEBHOOK_URL + # teams_webhook_url: "" # or env TEAMS_WEBHOOK_URL + # google_chat_webhook_url: "" # or env GOOGLE_CHAT_WEBHOOK_URL + # telegram_bot_token: "" # or env TELEGRAM_BOT_TOKEN (both required) + # telegram_chat_id: "" # or env TELEGRAM_CHAT_ID + # webhook_url: "" # generic {"version","notes"} JSON POST; or env RELEASER_WEBHOOK_URL ``` ### Environment variables @@ -150,8 +159,13 @@ github: | `CI_PROJECT_ID` | GitLab project identifier (numeric) | | `CI_PROJECT_PATH` | GitLab project identifier (fallback) | | `GITHUB_TOKEN` | GitHub API auth | +| `SLACK_WEBHOOK_URL` | Slack release notification | +| `TEAMS_WEBHOOK_URL` | Microsoft Teams release notification | +| `GOOGLE_CHAT_WEBHOOK_URL` | Google Chat release notification | +| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | Telegram release notification (both required) | +| `RELEASER_WEBHOOK_URL` | Generic webhook release notification | -When both `github.*` and `gitlab.*` are configured, GitHub takes precedence. +When both `github.*` and `gitlab.*` are configured, GitHub takes precedence. All `notify.*` targets are independent — any combination may be configured at once, and a failed notification never fails the release. ## CI integration (GitLab CI example) diff --git a/ROADMAP.md b/ROADMAP.md index 9c0ee96..0f1d72d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,4 +87,4 @@ - ~~Gradle support (`build.gradle` / `build.gradle.kts`)~~ — ✓ shipped v1.6.0 (`internal/gradle`; Groovy + Kotlin DSL; multi-module via `gradle.build_files`; `--gradle` flag) - ~~Python `pyproject.toml` version bump (`[project].version` and `[tool.poetry].version`; single and multi-path like Maven's `pom_paths`)~~ — ✓ shipped v1.7.0 (`internal/pyproject`; PEP 621 + Poetry; `python.pyproject_tomls`; `--pyproject` flag) -- Slack / Teams notification on release +- ~~Slack / Teams notification on release~~ — ✓ shipped v1.8.0 (`internal/notify`; Slack, Microsoft Teams, Google Chat, Telegram, and generic webhook; each opt-in via `notify.*` config or env var; best-effort — never fails the release) diff --git a/cmd/main.go b/cmd/main.go index f2bbe60..b11e96b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,6 +23,7 @@ import ( "git.k3nny.fr/releaser/internal/pyproject" "git.k3nny.fr/releaser/internal/node" "git.k3nny.fr/releaser/internal/notes" + "git.k3nny.fr/releaser/internal/notify" semver "git.k3nny.fr/releaser/internal/version" ) @@ -122,6 +123,29 @@ github: # Repository in "owner/repo" format. # repo: "" + +notify: + # Every field below is opt-in. A target is only used when its required + # fields are set (via this file, or the matching environment variable). + # Notification failures never fail the release — they're logged as warnings. + + # Slack incoming webhook URL. Falls back to SLACK_WEBHOOK_URL. + # slack_webhook_url: "" + + # Microsoft Teams incoming webhook URL. Falls back to TEAMS_WEBHOOK_URL. + # teams_webhook_url: "" + + # Google Chat incoming webhook URL. Falls back to GOOGLE_CHAT_WEBHOOK_URL. + # google_chat_webhook_url: "" + + # Telegram bot token and chat ID — both required. Fall back to + # TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. + # telegram_bot_token: "" + # telegram_chat_id: "" + + # Generic webhook URL — POSTed a {"version": ..., "notes": ...} JSON body. + # Falls back to RELEASER_WEBHOOK_URL. + # webhook_url: "" ` var ( @@ -265,6 +289,15 @@ type options struct { releaseEnvFile string } +// maskedIfSet reports whether a secret-like config value is set, without +// printing the value itself. +func maskedIfSet(v string) string { + if v != "" { + return "(set)" + } + return "(not set)" +} + func printVerboseConfig(cfg config.Config, src config.Sources) { logSection("configuration") rows := []struct{ key, val string }{ @@ -320,20 +353,16 @@ func printVerboseConfig(cfg config.Config, src config.Sources) { return strings.Join(paths, ", ") }()}, {"gitlab.url", cfg.GitLab.URL}, - {"gitlab.token", func() string { - if cfg.GitLab.Token != "" { - return "(set)" - } - return "(not set)" - }()}, + {"gitlab.token", maskedIfSet(cfg.GitLab.Token)}, {"gitlab.project", cfg.GitLab.Project}, - {"github.token", func() string { - if cfg.GitHub.Token != "" { - return "(set)" - } - return "(not set)" - }()}, + {"github.token", maskedIfSet(cfg.GitHub.Token)}, {"github.repo", cfg.GitHub.Repo}, + {"notify.slack_webhook_url", maskedIfSet(cfg.Notify.SlackWebhookURL)}, + {"notify.teams_webhook_url", maskedIfSet(cfg.Notify.TeamsWebhookURL)}, + {"notify.google_chat_webhook_url", maskedIfSet(cfg.Notify.GoogleChatWebhookURL)}, + {"notify.telegram_bot_token", maskedIfSet(cfg.Notify.TelegramBotToken)}, + {"notify.telegram_chat_id", maskedIfSet(cfg.Notify.TelegramChatID)}, + {"notify.webhook_url", maskedIfSet(cfg.Notify.WebhookURL)}, } for _, r := range rows { source := src[r.key] @@ -682,7 +711,10 @@ func run(o options) error { } logDone("pushed") + releaseNotes := notes.Generate(nextTag, messages) + if o.noRelease { + notifyRelease(cfg, nextTag, releaseNotes) fmt.Printf("released %s\n", nextTag) return nil } @@ -694,15 +726,31 @@ func run(o options) error { } if publisher == nil { logWarn("no release provider configured — skipping release creation") - fmt.Printf("released %s\n", nextTag) - return nil + } else { + if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil { + return fmt.Errorf("create release: %w", err) + } + logDone("release created: %s", nextTag) } - releaseNotes := notes.Generate(nextTag, messages) - if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil { - return fmt.Errorf("create release: %w", err) - } - logDone("release created: %s", nextTag) + notifyRelease(cfg, nextTag, releaseNotes) fmt.Printf("released %s\n", nextTag) return nil } + +// notifyRelease sends best-effort release notifications to every configured +// target. Failures are logged as warnings, not errors — the release itself +// already succeeded by the time this runs. +func notifyRelease(cfg config.Config, tagName, releaseNotes string) { + notifyCfg := notify.Config{ + SlackWebhookURL: cfg.Notify.SlackWebhookURL, + TeamsWebhookURL: cfg.Notify.TeamsWebhookURL, + GoogleChatWebhookURL: cfg.Notify.GoogleChatWebhookURL, + TelegramBotToken: cfg.Notify.TelegramBotToken, + TelegramChatID: cfg.Notify.TelegramChatID, + WebhookURL: cfg.Notify.WebhookURL, + } + for _, err := range notify.SendAll(context.Background(), notifyCfg, notify.Message{Version: tagName, Notes: releaseNotes}) { + logWarn("notification failed: %v", err) + } +} diff --git a/cmd/main_test.go b/cmd/main_test.go index fa36bf2..f1c1de0 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -526,6 +526,118 @@ func TestRunWithGitLab(t *testing.T) { } } +func TestRunWithNotify(t *testing.T) { + var notified bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + notified = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + t.Setenv("SLACK_WEBHOOK_URL", srv.URL) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("release with notify: unexpected error: %v", err) + } + if !notified { + t.Error("expected Slack webhook to be called") + } +} + +func TestRunNotifyFailureDoesNotFailRelease(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + t.Setenv("SLACK_WEBHOOK_URL", srv.URL) + + old := os.Stderr + r, wPipe, _ := os.Pipe() + os.Stderr = wPipe + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + + wPipe.Close() + os.Stderr = old + rawBytes, _ := io.ReadAll(r) + output := string(rawBytes) + + if err != nil { + t.Fatalf("a failed notification must not fail the release: %v", err) + } + if !strings.Contains(output, "notification failed") { + t.Errorf("expected a notification-failed warning, got:\n%s", output) + } +} + +func TestRunNoReleaseStillNotifies(t *testing.T) { + var notified bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + notified = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + t.Setenv("SLACK_WEBHOOK_URL", srv.URL) + + err := execCmd(t, "--no-release", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("--no-release: unexpected error: %v", err) + } + if !notified { + t.Error("expected Slack webhook to be called even with --no-release") + } +} + +func TestRunNoPushSkipsNotify(t *testing.T) { + var notified bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + notified = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + t.Setenv("SLACK_WEBHOOK_URL", srv.URL) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("--no-push: unexpected error: %v", err) + } + if notified { + t.Error("--no-push must not send notifications — nothing was published") + } +} + // ── main() tests via exitFn ─────────────────────────────────────────────────── func TestMainSuccess(t *testing.T) { diff --git a/docs/content/changelog.md b/docs/content/changelog.md index 62bad22..02946fb 100644 --- a/docs/content/changelog.md +++ b/docs/content/changelog.md @@ -3,6 +3,12 @@ title: Changelog weight: 50 --- +## v1.8.0 — 2026-07-12 + +### Added + +- **Release notifications** — best-effort notification on release to Slack, Microsoft Teams, Google Chat, Telegram, and/or a generic JSON webhook via the new `notify` config section; every target is independently opt-in (config file or env var), and a failed notification never fails the release + ## v1.7.0 — 2026-07-12 ### Added diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 6b2e2bc..b9b9d25 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -69,6 +69,14 @@ gitlab: github: token: "" # prefer env GITHUB_TOKEN repo: "" # "owner/repo" format + +notify: # opt-in — every field independent; failures are warnings, not errors + # slack_webhook_url: "" # or env SLACK_WEBHOOK_URL + # teams_webhook_url: "" # or env TEAMS_WEBHOOK_URL + # google_chat_webhook_url: "" # or env GOOGLE_CHAT_WEBHOOK_URL + # telegram_bot_token: "" # or env TELEGRAM_BOT_TOKEN (both required) + # telegram_chat_id: "" # or env TELEGRAM_CHAT_ID + # webhook_url: "" # generic {"version","notes"} JSON POST; or env RELEASER_WEBHOOK_URL ``` {{< hint info >}} @@ -84,6 +92,11 @@ When both `github.*` and `gitlab.*` are configured, GitHub takes precedence. | `CI_PROJECT_ID` | GitLab project identifier (numeric) | | `CI_PROJECT_PATH` | GitLab project identifier (fallback) | | `GITHUB_TOKEN` | GitHub API auth | +| `SLACK_WEBHOOK_URL` | Slack release notification | +| `TEAMS_WEBHOOK_URL` | Microsoft Teams release notification | +| `GOOGLE_CHAT_WEBHOOK_URL` | Google Chat release notification | +| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | Telegram release notification (both required) | +| `RELEASER_WEBHOOK_URL` | Generic webhook release notification | ## Config sources @@ -177,3 +190,24 @@ gradle: ``` The `--gradle ` CLI flag sets a single build file path and clears `build_files`. + +## Notifications + +The `notify` section is opt-in — every target is independent, and any combination may be configured at once. A notification failure is logged as a warning and never fails the release; notifications fire once the tag has been pushed, whether or not a GitLab/GitHub release is also created (`--no-release` still notifies; `--no-push` and `--no-commit` do not, since nothing was published). + +```yaml +notify: + slack_webhook_url: "https://hooks.slack.com/services/..." + teams_webhook_url: "https://outlook.office.com/webhook/..." + google_chat_webhook_url: "https://chat.googleapis.com/v1/spaces/.../messages?key=..." + telegram_bot_token: "123456:ABC-..." + telegram_chat_id: "-1001234567890" + webhook_url: "https://example.com/hooks/releaser" +``` + +- **Slack** / **Google Chat** — a single `{"text": "..."}` incoming-webhook payload. +- **Microsoft Teams** — a legacy O365 connector `MessageCard` payload (the format Teams incoming webhook URLs accept). +- **Telegram** — `telegram_bot_token` and `telegram_chat_id` are both required; posts to the Bot API `sendMessage` endpoint as plain text (no `parse_mode`, since release notes come from free-form commit messages and aren't guaranteed to be valid Telegram Markdown). +- **Generic webhook** — POSTs `{"version": "", "notes": ""}` for any downstream consumer (n8n, Zapier, a custom receiver). + +Each field also has an environment variable fallback — see [Environment variables](#environment-variables) above. diff --git a/docs/hugo.toml b/docs/hugo.toml index b818ec3..1ac1fd5 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -20,3 +20,7 @@ pygmentsCodeFences = true geekdocMenuBundle = true geekdocBreadcrumb = false geekdocToC = true + + [params.geekdocContentLicense] + name = "Apache License 2.0" + link = "https://git.k3nny.fr/k3nny/releaser/src/branch/main/LICENSE" diff --git a/internal/config/config.go b/internal/config/config.go index a0631e0..8877a6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { Python PythonConfig `yaml:"python"` GitLab GitLabConfig `yaml:"gitlab"` GitHub GitHubConfig `yaml:"github"` + Notify NotifyConfig `yaml:"notify"` } type GitConfig struct { @@ -120,6 +121,17 @@ type GitHubConfig struct { Repo string `yaml:"repo"` // "owner/repo" } +// NotifyConfig configures best-effort release notifications. Every field is +// opt-in — a target is only used when its required fields are non-empty. +type NotifyConfig struct { + SlackWebhookURL string `yaml:"slack_webhook_url"` + TeamsWebhookURL string `yaml:"teams_webhook_url"` + GoogleChatWebhookURL string `yaml:"google_chat_webhook_url"` + TelegramBotToken string `yaml:"telegram_bot_token"` + TelegramChatID string `yaml:"telegram_chat_id"` + WebhookURL string `yaml:"webhook_url"` +} + func defaults() Config { return Config{ Git: GitConfig{ @@ -162,6 +174,12 @@ func defaultSources() Sources { "gitlab.project": "default", "github.token": "default", "github.repo": "default", + "notify.slack_webhook_url": "default", + "notify.teams_webhook_url": "default", + "notify.google_chat_webhook_url": "default", + "notify.telegram_bot_token": "default", + "notify.telegram_chat_id": "default", + "notify.webhook_url": "default", } } @@ -260,6 +278,24 @@ func LoadWithSources(dir string) (Config, Sources, error) { if overlay.GitHub.Repo != "" { src["github.repo"] = "config file" } + if overlay.Notify.SlackWebhookURL != "" { + src["notify.slack_webhook_url"] = "config file" + } + if overlay.Notify.TeamsWebhookURL != "" { + src["notify.teams_webhook_url"] = "config file" + } + if overlay.Notify.GoogleChatWebhookURL != "" { + src["notify.google_chat_webhook_url"] = "config file" + } + if overlay.Notify.TelegramBotToken != "" { + src["notify.telegram_bot_token"] = "config file" + } + if overlay.Notify.TelegramChatID != "" { + src["notify.telegram_chat_id"] = "config file" + } + if overlay.Notify.WebhookURL != "" { + src["notify.webhook_url"] = "config file" + } return cfg, src, nil } @@ -310,4 +346,52 @@ func (c *Config) ApplyEnvWithSources(src Sources) { } } } + if c.Notify.SlackWebhookURL == "" { + if v := os.Getenv("SLACK_WEBHOOK_URL"); v != "" { + c.Notify.SlackWebhookURL = v + if src != nil { + src["notify.slack_webhook_url"] = "env: SLACK_WEBHOOK_URL" + } + } + } + if c.Notify.TeamsWebhookURL == "" { + if v := os.Getenv("TEAMS_WEBHOOK_URL"); v != "" { + c.Notify.TeamsWebhookURL = v + if src != nil { + src["notify.teams_webhook_url"] = "env: TEAMS_WEBHOOK_URL" + } + } + } + if c.Notify.GoogleChatWebhookURL == "" { + if v := os.Getenv("GOOGLE_CHAT_WEBHOOK_URL"); v != "" { + c.Notify.GoogleChatWebhookURL = v + if src != nil { + src["notify.google_chat_webhook_url"] = "env: GOOGLE_CHAT_WEBHOOK_URL" + } + } + } + if c.Notify.TelegramBotToken == "" { + if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" { + c.Notify.TelegramBotToken = v + if src != nil { + src["notify.telegram_bot_token"] = "env: TELEGRAM_BOT_TOKEN" + } + } + } + if c.Notify.TelegramChatID == "" { + if v := os.Getenv("TELEGRAM_CHAT_ID"); v != "" { + c.Notify.TelegramChatID = v + if src != nil { + src["notify.telegram_chat_id"] = "env: TELEGRAM_CHAT_ID" + } + } + } + if c.Notify.WebhookURL == "" { + if v := os.Getenv("RELEASER_WEBHOOK_URL"); v != "" { + c.Notify.WebhookURL = v + if src != nil { + src["notify.webhook_url"] = "env: RELEASER_WEBHOOK_URL" + } + } + } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ef85871..c3e248b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -381,6 +381,98 @@ func TestLoadPythonPathsSources(t *testing.T) { } } +func TestLoadNotifySources(t *testing.T) { + dir := t.TempDir() + content := ` +notify: + slack_webhook_url: "https://hooks.slack.example/x" + teams_webhook_url: "https://outlook.office.example/y" + google_chat_webhook_url: "https://chat.googleapis.example/z" + telegram_bot_token: "bot-token" + telegram_chat_id: "chat-1" + webhook_url: "https://example.com/webhook" +` + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatal(err) + } + cfg, src, err := LoadWithSources(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Notify.SlackWebhookURL != "https://hooks.slack.example/x" { + t.Errorf("SlackWebhookURL = %q", cfg.Notify.SlackWebhookURL) + } + if cfg.Notify.TeamsWebhookURL != "https://outlook.office.example/y" { + t.Errorf("TeamsWebhookURL = %q", cfg.Notify.TeamsWebhookURL) + } + if cfg.Notify.GoogleChatWebhookURL != "https://chat.googleapis.example/z" { + t.Errorf("GoogleChatWebhookURL = %q", cfg.Notify.GoogleChatWebhookURL) + } + if cfg.Notify.TelegramBotToken != "bot-token" { + t.Errorf("TelegramBotToken = %q", cfg.Notify.TelegramBotToken) + } + if cfg.Notify.TelegramChatID != "chat-1" { + t.Errorf("TelegramChatID = %q", cfg.Notify.TelegramChatID) + } + if cfg.Notify.WebhookURL != "https://example.com/webhook" { + t.Errorf("WebhookURL = %q", cfg.Notify.WebhookURL) + } + + wantConfigFile := []string{ + "notify.slack_webhook_url", "notify.teams_webhook_url", "notify.google_chat_webhook_url", + "notify.telegram_bot_token", "notify.telegram_chat_id", "notify.webhook_url", + } + for _, key := range wantConfigFile { + if got := src[key]; got != "config file" { + t.Errorf("src[%q] = %q, want %q", key, got, "config file") + } + } +} + +func TestApplyEnvWithSourcesNotify(t *testing.T) { + t.Setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.example/env") + t.Setenv("TEAMS_WEBHOOK_URL", "https://outlook.office.example/env") + t.Setenv("GOOGLE_CHAT_WEBHOOK_URL", "https://chat.googleapis.example/env") + t.Setenv("TELEGRAM_BOT_TOKEN", "env-bot-token") + t.Setenv("TELEGRAM_CHAT_ID", "env-chat-id") + t.Setenv("RELEASER_WEBHOOK_URL", "https://example.com/env-webhook") + + cfg := defaults() + src := defaultSources() + cfg.ApplyEnvWithSources(src) + + cases := []struct { + got, want, srcKey, wantSrc string + }{ + {cfg.Notify.SlackWebhookURL, "https://hooks.slack.example/env", "notify.slack_webhook_url", "env: SLACK_WEBHOOK_URL"}, + {cfg.Notify.TeamsWebhookURL, "https://outlook.office.example/env", "notify.teams_webhook_url", "env: TEAMS_WEBHOOK_URL"}, + {cfg.Notify.GoogleChatWebhookURL, "https://chat.googleapis.example/env", "notify.google_chat_webhook_url", "env: GOOGLE_CHAT_WEBHOOK_URL"}, + {cfg.Notify.TelegramBotToken, "env-bot-token", "notify.telegram_bot_token", "env: TELEGRAM_BOT_TOKEN"}, + {cfg.Notify.TelegramChatID, "env-chat-id", "notify.telegram_chat_id", "env: TELEGRAM_CHAT_ID"}, + {cfg.Notify.WebhookURL, "https://example.com/env-webhook", "notify.webhook_url", "env: RELEASER_WEBHOOK_URL"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("got %q, want %q", c.got, c.want) + } + if src[c.srcKey] != c.wantSrc { + t.Errorf("src[%q] = %q, want %q", c.srcKey, src[c.srcKey], c.wantSrc) + } + } +} + +func TestApplyEnvNotifyDoesNotOverwrite(t *testing.T) { + t.Setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.example/env") + + cfg := defaults() + cfg.Notify.SlackWebhookURL = "https://hooks.slack.example/config" + cfg.ApplyEnv() + + if cfg.Notify.SlackWebhookURL != "https://hooks.slack.example/config" { + t.Errorf("SlackWebhookURL overwritten: got %q", cfg.Notify.SlackWebhookURL) + } +} + func TestLoadPartialOverride(t *testing.T) { dir := t.TempDir() // Only override tag_prefix — commit_message should keep its default diff --git a/internal/notify/googlechat.go b/internal/notify/googlechat.go new file mode 100644 index 0000000..0d91576 --- /dev/null +++ b/internal/notify/googlechat.go @@ -0,0 +1,20 @@ +package notify + +import ( + "context" + "fmt" +) + +// googleChatPayload matches the Google Chat Incoming Webhook contract: a +// single "text" field. +type googleChatPayload struct { + Text string `json:"text"` +} + +func newGoogleChatPayload(msg Message) googleChatPayload { + return googleChatPayload{Text: fmt.Sprintf("*Released %s*\n\n%s", msg.Version, msg.Notes)} +} + +func sendGoogleChat(ctx context.Context, webhookURL string, msg Message) error { + return postJSON(ctx, webhookURL, newGoogleChatPayload(msg)) +} diff --git a/internal/notify/googlechat_test.go b/internal/notify/googlechat_test.go new file mode 100644 index 0000000..3a5b114 --- /dev/null +++ b/internal/notify/googlechat_test.go @@ -0,0 +1,37 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSendGoogleChat(t *testing.T) { + var got googleChatPayload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if err := sendGoogleChat(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "- feat: y"}); err != nil { + t.Fatalf("sendGoogleChat: %v", err) + } + if !strings.Contains(got.Text, "v1.2.3") || !strings.Contains(got.Text, "feat: y") { + t.Errorf("payload text = %q", got.Text) + } +} + +func TestSendGoogleChatError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + if err := sendGoogleChat(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil { + t.Fatal("expected error for 400 response") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..078b116 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,88 @@ +// Package notify sends best-effort release notifications to chat and webhook targets. +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Message is the content of a release notification, shared across all targets. +type Message struct { + Version string // tag name, e.g. "v1.2.3" + Notes string // release notes body +} + +// Config holds the destination for every supported notification target. +// Every field is opt-in — a target is skipped when its required fields are empty. +type Config struct { + SlackWebhookURL string + TeamsWebhookURL string + GoogleChatWebhookURL string + TelegramBotToken string + TelegramChatID string + WebhookURL string +} + +var httpClient = &http.Client{Timeout: 15 * time.Second} + +// SendAll sends msg to every target configured in cfg. Each target is attempted +// independently — a failure on one does not prevent the others from being tried. +// Returns one error per failed target, or nil if every configured target +// succeeded (or none were configured). +func SendAll(ctx context.Context, cfg Config, msg Message) []error { + var errs []error + + if cfg.SlackWebhookURL != "" { + if err := sendSlack(ctx, cfg.SlackWebhookURL, msg); err != nil { + errs = append(errs, fmt.Errorf("slack: %w", err)) + } + } + if cfg.TeamsWebhookURL != "" { + if err := sendTeams(ctx, cfg.TeamsWebhookURL, msg); err != nil { + errs = append(errs, fmt.Errorf("teams: %w", err)) + } + } + if cfg.GoogleChatWebhookURL != "" { + if err := sendGoogleChat(ctx, cfg.GoogleChatWebhookURL, msg); err != nil { + errs = append(errs, fmt.Errorf("google chat: %w", err)) + } + } + if cfg.TelegramBotToken != "" && cfg.TelegramChatID != "" { + if err := sendTelegram(ctx, cfg.TelegramBotToken, cfg.TelegramChatID, msg); err != nil { + errs = append(errs, fmt.Errorf("telegram: %w", err)) + } + } + if cfg.WebhookURL != "" { + if err := sendWebhook(ctx, cfg.WebhookURL, msg); err != nil { + errs = append(errs, fmt.Errorf("webhook: %w", err)) + } + } + + return errs +} + +// postJSON POSTs payload as JSON to url and treats any non-2xx response as an error. +func postJSON(ctx context.Context, url string, payload any) error { + body, _ := json.Marshal(payload) // payload fields are always plain strings — Marshal cannot fail here + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("returned status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/notify/notify_fuzz_test.go b/internal/notify/notify_fuzz_test.go new file mode 100644 index 0000000..eef8cb9 --- /dev/null +++ b/internal/notify/notify_fuzz_test.go @@ -0,0 +1,28 @@ +package notify + +import ( + "encoding/json" + "strings" + "testing" +) + +// FuzzMessagePayloads verifies that building a notification payload never +// panics on arbitrary version/notes strings, no matter the target — release +// notes are generated from free-form commit messages and end up embedded in +// every payload below. +func FuzzMessagePayloads(f *testing.F) { + f.Add("v1.2.3", "release notes") + f.Add("", "") + f.Add("v1.0.0", "* markdown _weird_ [chars] `code` & \"quotes\"") + f.Add("tag\nwith\nnewline", "notes\x00with\xffbinary") + f.Add("v1.0.0", strings.Repeat("x", 10000)) + + f.Fuzz(func(t *testing.T, version, notes string) { + msg := Message{Version: version, Notes: notes} + _, _ = json.Marshal(newSlackPayload(msg)) + _, _ = json.Marshal(newGoogleChatPayload(msg)) + _, _ = json.Marshal(newTeamsPayload(msg)) + _, _ = json.Marshal(newTelegramPayload("chat-id", msg)) + _, _ = json.Marshal(newWebhookPayload(msg)) + }) +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..05cea6b --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,137 @@ +package notify + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// alwaysFailTransport returns an error for every request. +type alwaysFailTransport struct{} + +func (alwaysFailTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, &testTransportError{"connection refused"} +} + +type testTransportError struct{ msg string } + +func (e *testTransportError) Error() string { return e.msg } + +func TestPostJSONSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if err := postJSON(context.Background(), srv.URL, map[string]string{"a": "b"}); err != nil { + t.Fatalf("postJSON: %v", err) + } +} + +func TestPostJSONBadURL(t *testing.T) { + err := postJSON(context.Background(), "http://\x00bad", map[string]string{"a": "b"}) + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestPostJSONRequestFails(t *testing.T) { + orig := httpClient + httpClient = &http.Client{Transport: alwaysFailTransport{}} + defer func() { httpClient = orig }() + + err := postJSON(context.Background(), "http://127.0.0.1:1", map[string]string{"a": "b"}) + if err == nil { + t.Fatal("expected error when HTTP request fails") + } +} + +func TestPostJSONNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + err := postJSON(context.Background(), srv.URL, map[string]string{"a": "b"}) + if err == nil { + t.Fatal("expected error for 500 response") + } +} + +func TestSendAllNoneConfigured(t *testing.T) { + errs := SendAll(context.Background(), Config{}, Message{Version: "v1.0.0"}) + if errs != nil { + t.Fatalf("expected no errors, got %v", errs) + } +} + +func TestSendAllAllSucceed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + origBase := telegramAPIBase + telegramAPIBase = srv.URL + defer func() { telegramAPIBase = origBase }() + + cfg := Config{ + SlackWebhookURL: srv.URL, + TeamsWebhookURL: srv.URL, + GoogleChatWebhookURL: srv.URL, + TelegramBotToken: "tok", + TelegramChatID: "123", + WebhookURL: srv.URL, + } + errs := SendAll(context.Background(), cfg, Message{Version: "v1.0.0", Notes: "notes"}) + if errs != nil { + t.Fatalf("expected no errors, got %v", errs) + } +} + +func TestSendAllAllFail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + origBase := telegramAPIBase + telegramAPIBase = srv.URL + defer func() { telegramAPIBase = origBase }() + + cfg := Config{ + SlackWebhookURL: srv.URL, + TeamsWebhookURL: srv.URL, + GoogleChatWebhookURL: srv.URL, + TelegramBotToken: "tok", + TelegramChatID: "123", + WebhookURL: srv.URL, + } + errs := SendAll(context.Background(), cfg, Message{Version: "v1.0.0"}) + if len(errs) != 5 { + t.Fatalf("expected 5 errors, got %d: %v", len(errs), errs) + } +} + +func TestSendAllTelegramRequiresBothFields(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + origBase := telegramAPIBase + telegramAPIBase = srv.URL + defer func() { telegramAPIBase = origBase }() + + // Only bot token set — chat ID missing — Telegram target must be skipped. + errs := SendAll(context.Background(), Config{TelegramBotToken: "tok"}, Message{Version: "v1.0.0"}) + if errs != nil { + t.Fatalf("expected no errors, got %v", errs) + } + if calls != 0 { + t.Fatalf("expected telegram to be skipped, got %d calls", calls) + } +} diff --git a/internal/notify/slack.go b/internal/notify/slack.go new file mode 100644 index 0000000..4f5c024 --- /dev/null +++ b/internal/notify/slack.go @@ -0,0 +1,20 @@ +package notify + +import ( + "context" + "fmt" +) + +// slackPayload matches the Slack Incoming Webhook contract: a single "text" +// field, interpreted as Slack mrkdwn. +type slackPayload struct { + Text string `json:"text"` +} + +func newSlackPayload(msg Message) slackPayload { + return slackPayload{Text: fmt.Sprintf("*Released %s*\n\n%s", msg.Version, msg.Notes)} +} + +func sendSlack(ctx context.Context, webhookURL string, msg Message) error { + return postJSON(ctx, webhookURL, newSlackPayload(msg)) +} diff --git a/internal/notify/slack_test.go b/internal/notify/slack_test.go new file mode 100644 index 0000000..7368013 --- /dev/null +++ b/internal/notify/slack_test.go @@ -0,0 +1,40 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSendSlack(t *testing.T) { + var got slackPayload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q", ct) + } + json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if err := sendSlack(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "- fix: x"}); err != nil { + t.Fatalf("sendSlack: %v", err) + } + if !strings.Contains(got.Text, "v1.2.3") || !strings.Contains(got.Text, "fix: x") { + t.Errorf("payload text = %q", got.Text) + } +} + +func TestSendSlackError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + if err := sendSlack(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil { + t.Fatal("expected error for 403 response") + } +} diff --git a/internal/notify/teams.go b/internal/notify/teams.go new file mode 100644 index 0000000..ba36b29 --- /dev/null +++ b/internal/notify/teams.go @@ -0,0 +1,31 @@ +package notify + +import ( + "context" + "fmt" +) + +// teamsPayload is a Microsoft Teams "Incoming Webhook" O365 connector card +// (MessageCard schema) — the format Teams webhook URLs still accept. +type teamsPayload struct { + Type string `json:"@type"` + Context string `json:"@context"` + Summary string `json:"summary"` + Title string `json:"title"` + Text string `json:"text"` +} + +func newTeamsPayload(msg Message) teamsPayload { + title := fmt.Sprintf("Released %s", msg.Version) + return teamsPayload{ + Type: "MessageCard", + Context: "http://schema.org/extensions", + Summary: title, + Title: title, + Text: msg.Notes, + } +} + +func sendTeams(ctx context.Context, webhookURL string, msg Message) error { + return postJSON(ctx, webhookURL, newTeamsPayload(msg)) +} diff --git a/internal/notify/teams_test.go b/internal/notify/teams_test.go new file mode 100644 index 0000000..9417833 --- /dev/null +++ b/internal/notify/teams_test.go @@ -0,0 +1,43 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSendTeams(t *testing.T) { + var got teamsPayload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if err := sendTeams(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "- fix: z"}); err != nil { + t.Fatalf("sendTeams: %v", err) + } + if got.Type != "MessageCard" { + t.Errorf("Type = %q, want MessageCard", got.Type) + } + if !strings.Contains(got.Title, "v1.2.3") || !strings.Contains(got.Summary, "v1.2.3") { + t.Errorf("Title/Summary = %q/%q, want to contain v1.2.3", got.Title, got.Summary) + } + if !strings.Contains(got.Text, "fix: z") { + t.Errorf("Text = %q", got.Text) + } +} + +func TestSendTeamsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + if err := sendTeams(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil { + t.Fatal("expected error for 503 response") + } +} diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go new file mode 100644 index 0000000..bfa1aae --- /dev/null +++ b/internal/notify/telegram.go @@ -0,0 +1,30 @@ +package notify + +import ( + "context" + "fmt" +) + +// telegramAPIBase is the Telegram Bot API host. Overridden in tests. +var telegramAPIBase = "https://api.telegram.org" + +// telegramPayload matches the Telegram Bot API sendMessage contract. Notes +// are sent as plain text (no parse_mode) — release notes come from arbitrary +// commit messages and are not guaranteed to be valid Telegram Markdown/HTML, +// and a malformed entity makes the whole request fail with a 400. +type telegramPayload struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` +} + +func newTelegramPayload(chatID string, msg Message) telegramPayload { + return telegramPayload{ + ChatID: chatID, + Text: fmt.Sprintf("Released %s\n\n%s", msg.Version, msg.Notes), + } +} + +func sendTelegram(ctx context.Context, botToken, chatID string, msg Message) error { + url := fmt.Sprintf("%s/bot%s/sendMessage", telegramAPIBase, botToken) + return postJSON(ctx, url, newTelegramPayload(chatID, msg)) +} diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go new file mode 100644 index 0000000..bc7e7f1 --- /dev/null +++ b/internal/notify/telegram_test.go @@ -0,0 +1,53 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSendTelegram(t *testing.T) { + var got telegramPayload + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + orig := telegramAPIBase + telegramAPIBase = srv.URL + defer func() { telegramAPIBase = orig }() + + if err := sendTelegram(context.Background(), "bot-token", "chat-1", Message{Version: "v1.2.3", Notes: "notes body"}); err != nil { + t.Fatalf("sendTelegram: %v", err) + } + if !strings.Contains(path, "bot-token") { + t.Errorf("path = %q, want to contain bot token", path) + } + if got.ChatID != "chat-1" { + t.Errorf("ChatID = %q, want chat-1", got.ChatID) + } + if !strings.Contains(got.Text, "v1.2.3") || !strings.Contains(got.Text, "notes body") { + t.Errorf("Text = %q", got.Text) + } +} + +func TestSendTelegramError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + orig := telegramAPIBase + telegramAPIBase = srv.URL + defer func() { telegramAPIBase = orig }() + + if err := sendTelegram(context.Background(), "bot-token", "chat-1", Message{Version: "v1.0.0"}); err == nil { + t.Fatal("expected error for 400 response") + } +} diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go new file mode 100644 index 0000000..682237e --- /dev/null +++ b/internal/notify/webhook.go @@ -0,0 +1,18 @@ +package notify + +import "context" + +// webhookPayload is a generic JSON envelope for arbitrary API webhooks +// (e.g. n8n, Zapier, a custom receiver) that don't follow a chat-app schema. +type webhookPayload struct { + Version string `json:"version"` + Notes string `json:"notes"` +} + +func newWebhookPayload(msg Message) webhookPayload { + return webhookPayload(msg) +} + +func sendWebhook(ctx context.Context, url string, msg Message) error { + return postJSON(ctx, url, newWebhookPayload(msg)) +} diff --git a/internal/notify/webhook_test.go b/internal/notify/webhook_test.go new file mode 100644 index 0000000..bddf38e --- /dev/null +++ b/internal/notify/webhook_test.go @@ -0,0 +1,39 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSendWebhook(t *testing.T) { + var got webhookPayload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if err := sendWebhook(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "notes body"}); err != nil { + t.Fatalf("sendWebhook: %v", err) + } + if got.Version != "v1.2.3" { + t.Errorf("Version = %q, want v1.2.3", got.Version) + } + if got.Notes != "notes body" { + t.Errorf("Notes = %q, want %q", got.Notes, "notes body") + } +} + +func TestSendWebhookError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + if err := sendWebhook(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil { + t.Fatal("expected error for 500 response") + } +}