commit b73aeaed914908c2bdf837bc49790f44701771df Author: Tai Groot Date: Tue Mar 10 23:25:54 2026 +0000 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). 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 +}