feat(notify): release v1.8.0 — Slack/Teams/Google Chat/Telegram/webhook notifications
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,14 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
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
|
## [1.7.1] - 2026-07-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ internal/config/ — .releaser.yml loader + env var overlay + source trackin
|
|||||||
internal/ghclient/ — minimal GitHub Releases API client
|
internal/ghclient/ — minimal GitHub Releases API client
|
||||||
internal/gitutil/ — go-git helpers: tag discovery, commit walker, push
|
internal/gitutil/ — go-git helpers: tag discovery, commit walker, push
|
||||||
internal/glclient/ — minimal GitLab Releases API client
|
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/maven/ — pom.xml version reader/writer
|
||||||
internal/node/ — package.json version reader/writer
|
internal/node/ — package.json version reader/writer
|
||||||
internal/notes/ — release notes body generator
|
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
|
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/maven` | `FuzzReadVersion`, `FuzzReplaceProjectVersion` | reads/rewrites arbitrary XML file content |
|
||||||
| `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content |
|
| `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content |
|
||||||
| `internal/notes` | `FuzzGenerate` | generates notes from arbitrary commit messages |
|
| `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.
|
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).
|
- 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.
|
- 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
|
## Dependency rules
|
||||||
|
|
||||||
- **No new external dependencies** unless absolutely necessary. The project deliberately avoids pulling in large ecosystems.
|
- **No new external dependencies** unless absolutely necessary. The project deliberately avoids pulling in large ecosystems.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<img src="docs/static/images/releaser-logo-128.png" alt="releaser logo" width="128">
|
<img src="docs/static/images/releaser-logo-128.png" alt="releaser logo" width="128">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
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
|
3. **Commit analysis** — parses Conventional Commits between last tag and HEAD
|
||||||
4. **Version bump** — increments patch (the minor is owned by the branch)
|
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
|
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
|
## Version bump rules
|
||||||
|
|
||||||
@@ -139,6 +140,14 @@ gitlab:
|
|||||||
github:
|
github:
|
||||||
token: "" # env GITHUB_TOKEN (never commit this)
|
token: "" # env GITHUB_TOKEN (never commit this)
|
||||||
repo: "" # "owner/repo" format
|
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
|
### Environment variables
|
||||||
@@ -150,8 +159,13 @@ github:
|
|||||||
| `CI_PROJECT_ID` | GitLab project identifier (numeric) |
|
| `CI_PROJECT_ID` | GitLab project identifier (numeric) |
|
||||||
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
|
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
|
||||||
| `GITHUB_TOKEN` | GitHub API auth |
|
| `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)
|
## CI integration (GitLab CI example)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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)
|
- ~~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)
|
- ~~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)
|
||||||
|
|||||||
+65
-17
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.k3nny.fr/releaser/internal/pyproject"
|
"git.k3nny.fr/releaser/internal/pyproject"
|
||||||
"git.k3nny.fr/releaser/internal/node"
|
"git.k3nny.fr/releaser/internal/node"
|
||||||
"git.k3nny.fr/releaser/internal/notes"
|
"git.k3nny.fr/releaser/internal/notes"
|
||||||
|
"git.k3nny.fr/releaser/internal/notify"
|
||||||
semver "git.k3nny.fr/releaser/internal/version"
|
semver "git.k3nny.fr/releaser/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,6 +123,29 @@ github:
|
|||||||
|
|
||||||
# Repository in "owner/repo" format.
|
# Repository in "owner/repo" format.
|
||||||
# repo: ""
|
# 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 (
|
var (
|
||||||
@@ -265,6 +289,15 @@ type options struct {
|
|||||||
releaseEnvFile string
|
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) {
|
func printVerboseConfig(cfg config.Config, src config.Sources) {
|
||||||
logSection("configuration")
|
logSection("configuration")
|
||||||
rows := []struct{ key, val string }{
|
rows := []struct{ key, val string }{
|
||||||
@@ -320,20 +353,16 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
|
|||||||
return strings.Join(paths, ", ")
|
return strings.Join(paths, ", ")
|
||||||
}()},
|
}()},
|
||||||
{"gitlab.url", cfg.GitLab.URL},
|
{"gitlab.url", cfg.GitLab.URL},
|
||||||
{"gitlab.token", func() string {
|
{"gitlab.token", maskedIfSet(cfg.GitLab.Token)},
|
||||||
if cfg.GitLab.Token != "" {
|
|
||||||
return "(set)"
|
|
||||||
}
|
|
||||||
return "(not set)"
|
|
||||||
}()},
|
|
||||||
{"gitlab.project", cfg.GitLab.Project},
|
{"gitlab.project", cfg.GitLab.Project},
|
||||||
{"github.token", func() string {
|
{"github.token", maskedIfSet(cfg.GitHub.Token)},
|
||||||
if cfg.GitHub.Token != "" {
|
|
||||||
return "(set)"
|
|
||||||
}
|
|
||||||
return "(not set)"
|
|
||||||
}()},
|
|
||||||
{"github.repo", cfg.GitHub.Repo},
|
{"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 {
|
for _, r := range rows {
|
||||||
source := src[r.key]
|
source := src[r.key]
|
||||||
@@ -682,7 +711,10 @@ func run(o options) error {
|
|||||||
}
|
}
|
||||||
logDone("pushed")
|
logDone("pushed")
|
||||||
|
|
||||||
|
releaseNotes := notes.Generate(nextTag, messages)
|
||||||
|
|
||||||
if o.noRelease {
|
if o.noRelease {
|
||||||
|
notifyRelease(cfg, nextTag, releaseNotes)
|
||||||
fmt.Printf("released %s\n", nextTag)
|
fmt.Printf("released %s\n", nextTag)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -694,15 +726,31 @@ func run(o options) error {
|
|||||||
}
|
}
|
||||||
if publisher == nil {
|
if publisher == nil {
|
||||||
logWarn("no release provider configured — skipping release creation")
|
logWarn("no release provider configured — skipping release creation")
|
||||||
fmt.Printf("released %s\n", nextTag)
|
} else {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseNotes := notes.Generate(nextTag, messages)
|
|
||||||
if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
|
if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
|
||||||
return fmt.Errorf("create release: %w", err)
|
return fmt.Errorf("create release: %w", err)
|
||||||
}
|
}
|
||||||
logDone("release created: %s", nextTag)
|
logDone("release created: %s", nextTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyRelease(cfg, nextTag, releaseNotes)
|
||||||
fmt.Printf("released %s\n", nextTag)
|
fmt.Printf("released %s\n", nextTag)
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────
|
// ── main() tests via exitFn ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestMainSuccess(t *testing.T) {
|
func TestMainSuccess(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ title: Changelog
|
|||||||
weight: 50
|
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
|
## v1.7.0 — 2026-07-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ gitlab:
|
|||||||
github:
|
github:
|
||||||
token: "" # prefer env GITHUB_TOKEN
|
token: "" # prefer env GITHUB_TOKEN
|
||||||
repo: "" # "owner/repo" format
|
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 >}}
|
{{< 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_ID` | GitLab project identifier (numeric) |
|
||||||
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
|
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
|
||||||
| `GITHUB_TOKEN` | GitHub API auth |
|
| `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
|
## Config sources
|
||||||
|
|
||||||
@@ -177,3 +190,24 @@ gradle:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The `--gradle <path>` CLI flag sets a single build file path and clears `build_files`.
|
The `--gradle <path>` 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": "<tag>", "notes": "<release notes>"}` for any downstream consumer (n8n, Zapier, a custom receiver).
|
||||||
|
|
||||||
|
Each field also has an environment variable fallback — see [Environment variables](#environment-variables) above.
|
||||||
|
|||||||
@@ -20,3 +20,7 @@ pygmentsCodeFences = true
|
|||||||
geekdocMenuBundle = true
|
geekdocMenuBundle = true
|
||||||
geekdocBreadcrumb = false
|
geekdocBreadcrumb = false
|
||||||
geekdocToC = true
|
geekdocToC = true
|
||||||
|
|
||||||
|
[params.geekdocContentLicense]
|
||||||
|
name = "Apache License 2.0"
|
||||||
|
link = "https://git.k3nny.fr/k3nny/releaser/src/branch/main/LICENSE"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Config struct {
|
|||||||
Python PythonConfig `yaml:"python"`
|
Python PythonConfig `yaml:"python"`
|
||||||
GitLab GitLabConfig `yaml:"gitlab"`
|
GitLab GitLabConfig `yaml:"gitlab"`
|
||||||
GitHub GitHubConfig `yaml:"github"`
|
GitHub GitHubConfig `yaml:"github"`
|
||||||
|
Notify NotifyConfig `yaml:"notify"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitConfig struct {
|
type GitConfig struct {
|
||||||
@@ -120,6 +121,17 @@ type GitHubConfig struct {
|
|||||||
Repo string `yaml:"repo"` // "owner/repo"
|
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 {
|
func defaults() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Git: GitConfig{
|
Git: GitConfig{
|
||||||
@@ -162,6 +174,12 @@ func defaultSources() Sources {
|
|||||||
"gitlab.project": "default",
|
"gitlab.project": "default",
|
||||||
"github.token": "default",
|
"github.token": "default",
|
||||||
"github.repo": "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 != "" {
|
if overlay.GitHub.Repo != "" {
|
||||||
src["github.repo"] = "config file"
|
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
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestLoadPartialOverride(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
// Only override tag_prefix — commit_message should keep its default
|
// Only override tag_prefix — commit_message should keep its default
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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` <html> & \"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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user