From b73aeaed914908c2bdf837bc49790f44701771df Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 10 Mar 2026 23:25:54 +0000 Subject: [PATCH] feat: initial github-to-signal webhook server HTTP server that receives GitHub webhook events and sends formatted notifications to Signal via signal-cli's JSON-RPC API. Supported events: push, issues, issue comments, pull requests, PR reviews, PR review comments, releases, stars, forks, workflow runs, branch/tag creation, branch/tag deletion. Uses cbrgm/githubevents for webhook handling and taigrr/signalcli for Signal delivery. Config via TOML file or GH2SIG_ env vars (powered by taigrr/jety). --- .gitignore | 9 ++ LICENSE | 12 +++ README.md | 19 ++++ config.example.toml | 14 +++ config.go | 35 ++++++++ format.go | 206 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 18 ++++ go.sum | 23 +++++ main.go | 149 ++++++++++++++++++++++++++++++++ 9 files changed, 485 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 config.go create mode 100644 format.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..877bc76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Binaries +github-to-signal +*.exe + +# Config with secrets +config.toml +config.yaml +config.json +*.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4d89d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright (C) 2026 Tai Groot + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f45e495 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# github-to-signal + +HTTP server that receives GitHub webhook events and forwards them as Signal messages via [signal-cli](https://github.com/AsamK/signal-cli). + +## Events + +Push, issues, issue comments, pull requests, PR reviews, PR review comments, releases, stars, forks, workflow runs, branch/tag creation and deletion. + +## Setup + +1. Copy `config.example.toml` to `config.toml` and fill in values +2. Run the server: `go run .` +3. Add a webhook in your GitHub repo pointing to `https://your-host:9900/webhook` + +All config values can also be set via environment variables with `GH2SIG_` prefix (e.g. `GH2SIG_SIGNAL_ACCOUNT`). + +## Requirements + +- [signal-cli](https://github.com/AsamK/signal-cli) running in daemon mode with JSON-RPC enabled diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..143720d --- /dev/null +++ b/config.example.toml @@ -0,0 +1,14 @@ +# GitHub webhook secret (set in your GitHub webhook settings) +webhook_secret = "" + +# Address to listen on +listen_addr = ":9900" + +# signal-cli JSON-RPC endpoint +signal_url = "http://127.0.0.1:8080" + +# signal-cli account (phone number registered with signal-cli) +signal_account = "+1234567890" + +# Signal recipient UUID or phone number to send notifications to +signal_recipient = "" diff --git a/config.go b/config.go new file mode 100644 index 0000000..0149696 --- /dev/null +++ b/config.go @@ -0,0 +1,35 @@ +package main + +import "github.com/taigrr/jety" + +// Config holds the application configuration. +type Config struct { + // ListenAddr is the address to bind the webhook server to. + ListenAddr string + // WebhookSecret is the GitHub webhook secret for signature validation. + WebhookSecret string + // SignalURL is the signal-cli JSON-RPC base URL. + SignalURL string + // SignalAccount is the signal-cli account (phone number or UUID). + SignalAccount string + // SignalRecipient is the default Signal recipient for notifications. + SignalRecipient string +} + +func loadConfig() Config { + jety.SetDefault("listen_addr", ":9900") + jety.SetDefault("signal_url", "http://127.0.0.1:8080") + + jety.SetEnvPrefix("GH2SIG") + jety.SetConfigFile("config.toml") + jety.SetConfigType("toml") + _ = jety.ReadInConfig() + + return Config{ + ListenAddr: jety.GetString("listen_addr"), + WebhookSecret: jety.GetString("webhook_secret"), + SignalURL: jety.GetString("signal_url"), + SignalAccount: jety.GetString("signal_account"), + SignalRecipient: jety.GetString("signal_recipient"), + } +} diff --git a/format.go b/format.go new file mode 100644 index 0000000..b573308 --- /dev/null +++ b/format.go @@ -0,0 +1,206 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/google/go-github/v70/github" +) + +// formatPush formats a push event into a Signal message. +func formatPush(event *github.PushEvent) string { + repo := event.GetRepo().GetFullName() + ref := strings.TrimPrefix(event.GetRef(), "refs/heads/") + pusher := event.GetPusher().GetName() + count := len(event.Commits) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("[%s] %s pushed %d commit", repo, pusher, count)) + if count != 1 { + sb.WriteString("s") + } + sb.WriteString(fmt.Sprintf(" to %s\n", ref)) + + for _, commit := range event.Commits { + short := commit.GetID() + if len(short) > 7 { + short = short[:7] + } + msg := firstLine(commit.GetMessage()) + sb.WriteString(fmt.Sprintf(" %s %s\n", short, msg)) + } + + return strings.TrimSpace(sb.String()) +} + +// formatIssue formats an issue event into a Signal message. +func formatIssue(event *github.IssuesEvent) string { + repo := event.GetRepo().GetFullName() + action := event.GetAction() + issue := event.GetIssue() + sender := event.GetSender().GetLogin() + + msg := fmt.Sprintf("[%s] %s %s issue #%d: %s\n%s", + repo, sender, action, issue.GetNumber(), issue.GetTitle(), issue.GetHTMLURL()) + + if action == "opened" && issue.GetBody() != "" { + body := truncate(issue.GetBody(), 200) + msg += "\n\n" + body + } + + return msg +} + +// formatIssueComment formats an issue comment event into a Signal message. +func formatIssueComment(event *github.IssueCommentEvent) string { + repo := event.GetRepo().GetFullName() + sender := event.GetSender().GetLogin() + issue := event.GetIssue() + comment := event.GetComment() + + body := truncate(comment.GetBody(), 300) + + return fmt.Sprintf("[%s] %s commented on #%d (%s):\n%s\n%s", + repo, sender, issue.GetNumber(), issue.GetTitle(), body, comment.GetHTMLURL()) +} + +// formatPR formats a pull request event into a Signal message. +func formatPR(event *github.PullRequestEvent) string { + repo := event.GetRepo().GetFullName() + action := event.GetAction() + pr := event.GetPullRequest() + sender := event.GetSender().GetLogin() + + msg := fmt.Sprintf("[%s] %s %s PR #%d: %s\n%s", + repo, sender, action, pr.GetNumber(), pr.GetTitle(), pr.GetHTMLURL()) + + if action == "opened" && pr.GetBody() != "" { + body := truncate(pr.GetBody(), 200) + msg += "\n\n" + body + } + + return msg +} + +// formatPRReview formats a pull request review event into a Signal message. +func formatPRReview(event *github.PullRequestReviewEvent) string { + repo := event.GetRepo().GetFullName() + sender := event.GetSender().GetLogin() + pr := event.GetPullRequest() + review := event.GetReview() + + state := review.GetState() + body := truncate(review.GetBody(), 200) + + msg := fmt.Sprintf("[%s] %s %s PR #%d: %s\n%s", + repo, sender, state, pr.GetNumber(), pr.GetTitle(), review.GetHTMLURL()) + + if body != "" { + msg += "\n\n" + body + } + + return msg +} + +// formatPRReviewComment formats a pull request review comment event. +func formatPRReviewComment(event *github.PullRequestReviewCommentEvent) string { + repo := event.GetRepo().GetFullName() + sender := event.GetSender().GetLogin() + pr := event.GetPullRequest() + comment := event.GetComment() + + body := truncate(comment.GetBody(), 300) + + return fmt.Sprintf("[%s] %s commented on PR #%d (%s):\n%s\n%s", + repo, sender, pr.GetNumber(), pr.GetTitle(), body, comment.GetHTMLURL()) +} + +// formatRelease formats a release event into a Signal message. +func formatRelease(event *github.ReleaseEvent) string { + repo := event.GetRepo().GetFullName() + release := event.GetRelease() + sender := event.GetSender().GetLogin() + + return fmt.Sprintf("[%s] %s %s release %s\n%s", + repo, sender, event.GetAction(), release.GetTagName(), release.GetHTMLURL()) +} + +// formatStar formats a star event into a Signal message. +func formatStar(event *github.StarEvent) string { + repo := event.GetRepo().GetFullName() + sender := event.GetSender().GetLogin() + action := event.GetAction() + count := event.GetRepo().GetStargazersCount() + + if action == "deleted" { + return fmt.Sprintf("[%s] %s unstarred (now %d)", repo, sender, count) + } + return fmt.Sprintf("[%s] %s starred (now %d)", repo, sender, count) +} + +// formatFork formats a fork event into a Signal message. +func formatFork(event *github.ForkEvent) string { + repo := event.GetRepo().GetFullName() + forkee := event.GetForkee().GetFullName() + sender := event.GetSender().GetLogin() + + return fmt.Sprintf("[%s] %s forked to %s", repo, sender, forkee) +} + +// formatWorkflowRun formats a workflow run event into a Signal message. +func formatWorkflowRun(event *github.WorkflowRunEvent) string { + repo := event.GetRepo().GetFullName() + run := event.GetWorkflowRun() + conclusion := run.GetConclusion() + name := run.GetName() + branch := run.GetHeadBranch() + + // Only notify on completion + if event.GetAction() != "completed" { + return "" + } + + emoji := "✅" + if conclusion == "failure" { + emoji = "❌" + } else if conclusion == "cancelled" { + emoji = "⚠️" + } + + return fmt.Sprintf("%s [%s] workflow %q %s on %s\n%s", + emoji, repo, name, conclusion, branch, run.GetHTMLURL()) +} + +// formatCreate formats a create event (branch/tag) into a Signal message. +func formatCreate(event *github.CreateEvent) string { + repo := event.GetRepo().GetFullName() + sender := event.GetSender().GetLogin() + refType := event.GetRefType() + ref := event.GetRef() + + return fmt.Sprintf("[%s] %s created %s %s", repo, sender, refType, ref) +} + +// formatDelete formats a delete event (branch/tag) into a Signal message. +func formatDelete(event *github.DeleteEvent) string { + repo := event.GetRepo().GetFullName() + sender := event.GetSender().GetLogin() + refType := event.GetRefType() + ref := event.GetRef() + + return fmt.Sprintf("[%s] %s deleted %s %s", repo, sender, refType, ref) +} + +func firstLine(s string) string { + if idx := strings.IndexByte(s, '\n'); idx >= 0 { + return s[:idx] + } + return s +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d0e109f --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/taigrr/github-to-signal + +go 1.26.0 + +require ( + github.com/cbrgm/githubevents/v2 v2.2.0 + github.com/google/go-github/v70 v70.0.0 + github.com/taigrr/jety v0.2.0 + github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af +) + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + golang.org/x/sync v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d8b81f3 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cbrgm/githubevents/v2 v2.2.0 h1:nttKcls+vGtq+osxirM1qpY287hkXyJmTjHWHjQT1/A= +github.com/cbrgm/githubevents/v2 v2.2.0/go.mod h1:3J4oJkVO5NlZNEtEyUb8B7sW7+q8OZgHdTBvw9dPsMs= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o= +github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/taigrr/jety v0.2.0 h1:oGv6i1yBxdV5YnRD4f7V9Nv74PxY0kVIXCTJsRkqgmo= +github.com/taigrr/jety v0.2.0/go.mod h1:PtJDxNFDqR6739at6QrjlE4MGf/zcs21Y86KVQMKPEk= +github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af h1:qavmWkSyV3NeiZ+HHVj1I/6c8VQosUKFV1SfN1X/4DM= +github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af/go.mod h1:Jgly+nAwowk5O5ZUXSAAxCOVK9nlsAvPIfdNg5kXWSM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c51df94 --- /dev/null +++ b/main.go @@ -0,0 +1,149 @@ +// github-to-signal is an HTTP server that receives GitHub webhook events +// and forwards formatted notifications to Signal via signal-cli's JSON-RPC API. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + + "github.com/cbrgm/githubevents/v2/githubevents" + "github.com/google/go-github/v70/github" + "github.com/taigrr/signalcli" +) + +func main() { + cfg := loadConfig() + + if cfg.SignalAccount == "" { + log.Fatal("signal_account is required (set GH2SIG_SIGNAL_ACCOUNT or config.toml)") + } + if cfg.SignalRecipient == "" { + log.Fatal("signal_recipient is required (set GH2SIG_SIGNAL_RECIPIENT or config.toml)") + } + + signal := signalcli.NewClient(cfg.SignalURL, cfg.SignalAccount) + handle := githubevents.New(cfg.WebhookSecret) + + notifier := ¬ifier{ + signal: signal, + recipient: cfg.SignalRecipient, + } + + // Register event handlers. + handle.OnPushEventAny(notifier.onPush) + handle.OnIssuesEventAny(notifier.onIssue) + handle.OnIssueCommentEventAny(notifier.onIssueComment) + handle.OnPullRequestEventAny(notifier.onPR) + handle.OnPullRequestReviewEventAny(notifier.onPRReview) + handle.OnPullRequestReviewCommentEventAny(notifier.onPRReviewComment) + handle.OnReleaseEventAny(notifier.onRelease) + handle.OnStarEventAny(notifier.onStar) + handle.OnForkEventAny(notifier.onFork) + handle.OnWorkflowRunEventAny(notifier.onWorkflowRun) + handle.OnCreateEventAny(notifier.onCreate) + handle.OnDeleteEventAny(notifier.onDelete) + + handle.OnError(func(_ context.Context, _ string, _ string, _ interface{}, err error) error { + log.Printf("webhook error: %v", err) + return nil + }) + + mux := http.NewServeMux() + mux.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) { + if err := handle.HandleEventRequest(r); err != nil { + log.Printf("handle event: %v", err) + http.Error(w, "webhook processing failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") + }) + + log.Printf("listening on %s", cfg.ListenAddr) + if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { + log.Fatal(err) + } +} + +type notifier struct { + signal *signalcli.Client + recipient string +} + +func (n *notifier) send(ctx context.Context, msg string) { + if msg == "" { + return + } + _, err := n.signal.Send(ctx, signalcli.SendParams{ + Recipient: n.recipient, + Message: msg, + }) + if err != nil { + log.Printf("signal send error: %v", err) + } +} + +func (n *notifier) onPush(ctx context.Context, _ string, _ string, event *github.PushEvent) error { + n.send(ctx, formatPush(event)) + return nil +} + +func (n *notifier) onIssue(ctx context.Context, _ string, _ string, event *github.IssuesEvent) error { + n.send(ctx, formatIssue(event)) + return nil +} + +func (n *notifier) onIssueComment(ctx context.Context, _ string, _ string, event *github.IssueCommentEvent) error { + n.send(ctx, formatIssueComment(event)) + return nil +} + +func (n *notifier) onPR(ctx context.Context, _ string, _ string, event *github.PullRequestEvent) error { + n.send(ctx, formatPR(event)) + return nil +} + +func (n *notifier) onPRReview(ctx context.Context, _ string, _ string, event *github.PullRequestReviewEvent) error { + n.send(ctx, formatPRReview(event)) + return nil +} + +func (n *notifier) onPRReviewComment(ctx context.Context, _ string, _ string, event *github.PullRequestReviewCommentEvent) error { + n.send(ctx, formatPRReviewComment(event)) + return nil +} + +func (n *notifier) onRelease(ctx context.Context, _ string, _ string, event *github.ReleaseEvent) error { + n.send(ctx, formatRelease(event)) + return nil +} + +func (n *notifier) onStar(ctx context.Context, _ string, _ string, event *github.StarEvent) error { + n.send(ctx, formatStar(event)) + return nil +} + +func (n *notifier) onFork(ctx context.Context, _ string, _ string, event *github.ForkEvent) error { + n.send(ctx, formatFork(event)) + return nil +} + +func (n *notifier) onWorkflowRun(ctx context.Context, _ string, _ string, event *github.WorkflowRunEvent) error { + n.send(ctx, formatWorkflowRun(event)) + return nil +} + +func (n *notifier) onCreate(ctx context.Context, _ string, _ string, event *github.CreateEvent) error { + n.send(ctx, formatCreate(event)) + return nil +} + +func (n *notifier) onDelete(ctx context.Context, _ string, _ string, event *github.DeleteEvent) error { + n.send(ctx, formatDelete(event)) + return nil +}