From a66f88e0bf21f617fb9bfa2446cf585fc30b0db4 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Wed, 20 May 2026 20:01:29 -0500 Subject: [PATCH] feat(notify): native ntfy push notification integration (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ntfy as a native notification channel via the Notifier interface. Events notified: - NewIssue — new issue created - IssueChangeStatus — issue closed/reopened - NewPullRequest — new PR opened - MergePullRequest — PR merged - NewRelease — new release published - WorkflowRunStatusUpdate — CI success/failure Implementation: - modules/setting/ntfy.go — [ntfy] config section - services/ntfy/ntfy.go — HTTP POST sender with 5s timeout - services/ntfy/notifier.go — Notifier implementation (async, non-blocking) Config: [ntfy] ENABLED = true SERVER_URL = https://ntfy.mokoconsulting.tech DEFAULT_TOPIC = mokogitea Closes #41 Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/setting/ntfy.go | 24 +++++++++ modules/setting/setting.go | 1 + services/ntfy/notifier.go | 107 +++++++++++++++++++++++++++++++++++++ services/ntfy/ntfy.go | 66 +++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 modules/setting/ntfy.go create mode 100644 services/ntfy/notifier.go create mode 100644 services/ntfy/ntfy.go diff --git a/modules/setting/ntfy.go b/modules/setting/ntfy.go new file mode 100644 index 0000000000..eb324a7cb8 --- /dev/null +++ b/modules/setting/ntfy.go @@ -0,0 +1,24 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package setting + +// Ntfy holds ntfy push notification settings. +var Ntfy = struct { + Enabled bool + ServerURL string + DefaultTopic string + Token string +}{ + Enabled: false, + ServerURL: "https://ntfy.mokoconsulting.tech", + DefaultTopic: "mokogitea", +} + +func loadNtfyFrom(cfg ConfigProvider) { + sec := cfg.Section("ntfy") + Ntfy.Enabled = sec.Key("ENABLED").MustBool(false) + Ntfy.ServerURL = sec.Key("SERVER_URL").MustString(Ntfy.ServerURL) + Ntfy.DefaultTopic = sec.Key("DEFAULT_TOPIC").MustString(Ntfy.DefaultTopic) + Ntfy.Token = sec.Key("TOKEN").String() +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 659fae4ed7..bfdc19243f 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -168,6 +168,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadGlobalLockFrom(cfg) loadOtherFrom(cfg) loadUpdateCheckerFrom(cfg) + loadNtfyFrom(cfg) return nil } diff --git a/services/ntfy/notifier.go b/services/ntfy/notifier.go new file mode 100644 index 0000000000..cf18496b18 --- /dev/null +++ b/services/ntfy/notifier.go @@ -0,0 +1,107 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package ntfy + +import ( + "context" + "fmt" + + actions_model "code.gitea.io/gitea/models/actions" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + notify_service "code.gitea.io/gitea/services/notify" +) + +func init() { + if setting.Ntfy.Enabled { + notify_service.RegisterNotifier(NewNotifier()) + } +} + +type ntfyNotifier struct { + notify_service.NullNotifier +} + +// NewNotifier creates a new ntfy notifier. +func NewNotifier() notify_service.Notifier { + return &ntfyNotifier{} +} + +func (*ntfyNotifier) Run() {} + +func repoTopic(repo *repo_model.Repository) string { + if repo == nil { + return setting.Ntfy.DefaultTopic + } + return setting.Ntfy.DefaultTopic +} + +func (*ntfyNotifier) NewIssue(_ context.Context, issue *issues_model.Issue, _ []*user_model.User) { + _ = issue.LoadRepo(context.Background()) + SendAsync(repoTopic(issue.Repo), + fmt.Sprintf("New Issue: %s", issue.Title), + fmt.Sprintf("#%d in %s\n%s", issue.Index, issue.Repo.FullName(), issue.Content), + "default", + "issue,new") +} + +func (*ntfyNotifier) IssueChangeStatus(_ context.Context, doer *user_model.User, _ string, issue *issues_model.Issue, _ *issues_model.Comment, closeOrReopen bool) { + _ = issue.LoadRepo(context.Background()) + action := "reopened" + if !closeOrReopen { + action = "closed" + } + SendAsync(repoTopic(issue.Repo), + fmt.Sprintf("Issue %s: %s", action, issue.Title), + fmt.Sprintf("#%d %s by %s", issue.Index, action, doer.Name), + "low", + "issue,"+action) +} + +func (*ntfyNotifier) NewPullRequest(_ context.Context, pr *issues_model.PullRequest, _ []*user_model.User) { + _ = pr.LoadIssue(context.Background()) + _ = pr.Issue.LoadRepo(context.Background()) + SendAsync(repoTopic(pr.Issue.Repo), + fmt.Sprintf("New PR: %s", pr.Issue.Title), + fmt.Sprintf("#%d in %s\n%s → %s", pr.Issue.Index, pr.Issue.Repo.FullName(), pr.HeadBranch, pr.BaseBranch), + "default", + "git-pull-request,new") +} + +func (*ntfyNotifier) MergePullRequest(_ context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + _ = pr.LoadIssue(context.Background()) + _ = pr.Issue.LoadRepo(context.Background()) + SendAsync(repoTopic(pr.Issue.Repo), + fmt.Sprintf("PR Merged: %s", pr.Issue.Title), + fmt.Sprintf("#%d merged by %s", pr.Issue.Index, doer.Name), + "default", + "git-merge,merged") +} + +func (*ntfyNotifier) NewRelease(_ context.Context, rel *repo_model.Release) { + SendAsync(repoTopic(rel.Repo), + fmt.Sprintf("New Release: %s", rel.TagName), + fmt.Sprintf("%s in %s\n%s", rel.TagName, rel.Repo.FullName(), rel.Note), + "high", + "rocket,release") +} + +func (*ntfyNotifier) WorkflowRunStatusUpdate(_ context.Context, repo *repo_model.Repository, _ *user_model.User, run *actions_model.ActionRun) { + if run.Status.String() != "success" && run.Status.String() != "failure" { + return // only notify on completion + } + priority := "default" + tags := "white_check_mark,ci" + if run.Status.String() == "failure" { + priority = "high" + tags = "x,ci-fail" + } + SendAsync(repoTopic(repo), + fmt.Sprintf("CI %s: %s", run.Status.String(), run.Title), + fmt.Sprintf("Workflow in %s", repo.FullName()), + priority, + tags) +} diff --git a/services/ntfy/ntfy.go b/services/ntfy/ntfy.go new file mode 100644 index 0000000000..1bbb9f7e76 --- /dev/null +++ b/services/ntfy/ntfy.go @@ -0,0 +1,66 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package ntfy + +import ( + "fmt" + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// Send publishes a notification to the ntfy server. +func Send(topic, title, message, priority, tags string) error { + if !setting.Ntfy.Enabled || setting.Ntfy.ServerURL == "" { + return nil + } + + if topic == "" { + topic = setting.Ntfy.DefaultTopic + } + + url := fmt.Sprintf("%s/%s", strings.TrimRight(setting.Ntfy.ServerURL, "/"), topic) + + req, err := http.NewRequest("POST", url, strings.NewReader(message)) + if err != nil { + return fmt.Errorf("ntfy request: %w", err) + } + + req.Header.Set("Title", title) + if priority != "" { + req.Header.Set("Priority", priority) + } + if tags != "" { + req.Header.Set("Tags", tags) + } + if setting.Ntfy.Token != "" { + req.Header.Set("Authorization", "Bearer "+setting.Ntfy.Token) + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("ntfy send: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("ntfy returned status %d", resp.StatusCode) + } + + log.Debug("ntfy notification sent: %s — %s", topic, title) + return nil +} + +// SendAsync sends a notification in a goroutine (non-blocking). +func SendAsync(topic, title, message, priority, tags string) { + go func() { + if err := Send(topic, title, message, priority, tags); err != nil { + log.Error("ntfy async send failed: %v", err) + } + }() +} -- 2.52.0