mirror of
https://github.com/taigrr/github-to-signal.git
synced 2026-04-14 17:20:51 -07:00
Compare commits
3 Commits
v0.1.1
...
cd/deps-an
| Author | SHA1 | Date | |
|---|---|---|---|
| cffa7821e2 | |||
| 816ec8fd6b | |||
| 03791ecca8 |
132
AGENTS.md
Normal file
132
AGENTS.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Agent Guide for github-to-signal
|
||||
|
||||
HTTP server that receives GitHub webhook events and forwards them as Signal messages via signal-cli.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
GOWORK=off go build -o github-to-signal .
|
||||
|
||||
# Test
|
||||
GOWORK=off go test .
|
||||
|
||||
# Run (requires config.toml)
|
||||
./github-to-signal
|
||||
```
|
||||
|
||||
**Note**: This repo may not be in a parent `go.work` workspace. Use `GOWORK=off` to ensure commands work standalone.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── main.go # Entry point, HTTP server, event handlers
|
||||
├── config.go # Configuration loading (TOML + env vars)
|
||||
├── filter.go # Event filtering logic
|
||||
├── filter_test.go # Tests for event filtering
|
||||
├── format.go # Message formatting for each event type
|
||||
├── config.example.toml
|
||||
└── deploy/ # Systemd services and nginx config
|
||||
```
|
||||
|
||||
All source files are in the root directory (single `main` package, no subdirectories).
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration via `config.toml` or environment variables with `GH2SIG_` prefix:
|
||||
|
||||
| Config Key | Env Variable | Description |
|
||||
|------------|--------------|-------------|
|
||||
| `webhook_secret` | `GH2SIG_WEBHOOK_SECRET` | GitHub webhook secret |
|
||||
| `listen_addr` | `GH2SIG_LISTEN_ADDR` | Server address (default `:9900`) |
|
||||
| `signal_url` | `GH2SIG_SIGNAL_URL` | signal-cli JSON-RPC endpoint |
|
||||
| `signal_account` | `GH2SIG_SIGNAL_ACCOUNT` | Phone number for signal-cli |
|
||||
| `signal_recipient` | `GH2SIG_SIGNAL_RECIPIENT` | Recipient UUID for DMs |
|
||||
| `signal_group_id` | `GH2SIG_SIGNAL_GROUP_ID` | Group ID (overrides recipient) |
|
||||
| `events` | `GH2SIG_EVENTS` | Comma-separated event filter |
|
||||
|
||||
Configuration is loaded using [jety](https://github.com/taigrr/jety) library.
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Event Handlers
|
||||
|
||||
Each GitHub event type has:
|
||||
1. A handler method on `notifier` struct in `main.go`
|
||||
2. A `format*` function in `format.go` that returns the Signal message
|
||||
|
||||
Handler pattern:
|
||||
```go
|
||||
func (n *notifier) onEventName(ctx context.Context, _ string, _ string, event *github.EventType) error {
|
||||
if !n.filter.Allowed("event_name", event.GetAction()) {
|
||||
return nil
|
||||
}
|
||||
n.send(ctx, formatEventName(event))
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Event Filtering
|
||||
|
||||
`EventFilter` in `filter.go` supports:
|
||||
- Empty filter = allow all events
|
||||
- `"event"` = all actions of that event type
|
||||
- `"event:action"` = specific action only
|
||||
|
||||
Two-level check:
|
||||
1. `EventEnabled(event)` — used at registration time to skip registering handlers
|
||||
2. `Allowed(event, action)` — checked at runtime for action-level filtering
|
||||
|
||||
### Message Formatting
|
||||
|
||||
All `format*` functions in `format.go`:
|
||||
- Return a string (empty string = no message sent)
|
||||
- Use `[repo] user action ...` prefix format
|
||||
- Include relevant URLs
|
||||
- Truncate bodies with `truncate()` helper
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `cbrgm/githubevents` — GitHub webhook event parsing and routing
|
||||
- `google/go-github` — GitHub API types
|
||||
- `taigrr/signalcli` — signal-cli JSON-RPC client
|
||||
- `taigrr/jety` — Configuration (TOML/JSON/YAML/env)
|
||||
|
||||
## Adding New Event Types
|
||||
|
||||
1. Add handler method in `main.go` following existing pattern
|
||||
2. Register handler in `main()` with `EventEnabled` check
|
||||
3. Add `format*` function in `format.go`
|
||||
4. Add event name to filter docs in `config.example.toml`
|
||||
|
||||
## Testing
|
||||
|
||||
Only `filter_test.go` exists — table-driven tests for `EventFilter`.
|
||||
|
||||
```bash
|
||||
GOWORK=off go test -v .
|
||||
```
|
||||
|
||||
## HTTP Endpoints
|
||||
|
||||
| Path | Method | Description |
|
||||
|------|--------|-------------|
|
||||
| `/webhook` | POST | GitHub webhook receiver |
|
||||
| `/health` | GET | Health check (returns `ok`) |
|
||||
|
||||
## Deployment
|
||||
|
||||
Systemd services in `deploy/`:
|
||||
- `signal-cli-bot.service` — runs signal-cli daemon
|
||||
- `github-to-signal.service` — runs this server (depends on signal-cli)
|
||||
- `github-to-signal.nginx.conf` — nginx reverse proxy config
|
||||
|
||||
The server expects signal-cli to be running on `127.0.0.1:8081`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **GOWORK**: May need `GOWORK=off` if a parent `go.work` exists
|
||||
- **signal-cli port**: Default in code is `8080`, but deployment uses `8081` to avoid conflicts
|
||||
- **Workflow runs**: Only notifies on `completed` action, ignores `requested`/`in_progress`
|
||||
- **Empty message**: Returning `""` from a formatter skips sending (used by workflow_run filter)
|
||||
@@ -1,6 +1,10 @@
|
||||
# GitHub webhook secret (set in your GitHub webhook settings)
|
||||
webhook_secret = ""
|
||||
|
||||
# Shared secret for custom endpoints (validated via X-CI-Secret header).
|
||||
# Leave empty to allow unauthenticated access to custom endpoints.
|
||||
ci_secret = ""
|
||||
|
||||
# Address to listen on
|
||||
listen_addr = ":9900"
|
||||
|
||||
@@ -10,7 +14,7 @@ signal_url = "http://127.0.0.1:8081"
|
||||
# signal-cli account (phone number registered with signal-cli)
|
||||
signal_account = "+YOURNUMBER"
|
||||
|
||||
# Signal recipient UUID for DM notifications
|
||||
# Signal recipient UUID for DM notifications (used by GitHub webhook handler)
|
||||
signal_recipient = ""
|
||||
|
||||
# OR: Signal group ID for group notifications (overrides signal_recipient)
|
||||
@@ -30,3 +34,22 @@ signal_recipient = ""
|
||||
# star, fork, workflow_run, create, delete
|
||||
#
|
||||
# events = ""
|
||||
|
||||
# Custom endpoints — each [[endpoints]] block registers a POST route
|
||||
# that accepts {"source":"...","message":"..."} JSON and forwards the
|
||||
# message to the listed Signal group IDs. Auth via X-CI-Secret header
|
||||
# (if ci_secret is set).
|
||||
#
|
||||
# POST /prs -> sends to one group
|
||||
# POST /releases -> sends to two groups
|
||||
|
||||
[[endpoints]]
|
||||
slug = "/prs"
|
||||
group_ids = ["group-id-for-pr-notifications"]
|
||||
|
||||
[[endpoints]]
|
||||
slug = "/releases"
|
||||
group_ids = [
|
||||
"group-id-for-release-channel-1",
|
||||
"group-id-for-release-channel-2",
|
||||
]
|
||||
|
||||
77
config.go
77
config.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/taigrr/jety"
|
||||
@@ -8,20 +9,21 @@ import (
|
||||
|
||||
// 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 UUID for DM notifications.
|
||||
ListenAddr string
|
||||
WebhookSecret string
|
||||
CISecret string
|
||||
SignalURL string
|
||||
SignalAccount string
|
||||
SignalRecipient string
|
||||
// SignalGroupID is the Signal group ID for group notifications (overrides SignalRecipient).
|
||||
SignalGroupID string
|
||||
// Events is the event filter. Empty means all events are forwarded.
|
||||
Events EventFilter
|
||||
SignalGroupID string
|
||||
Events EventFilter
|
||||
Endpoints []Endpoint
|
||||
}
|
||||
|
||||
// Endpoint defines a custom HTTP endpoint that forwards messages to one or more Signal groups.
|
||||
type Endpoint struct {
|
||||
Slug string
|
||||
GroupIDs []string
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
@@ -33,7 +35,6 @@ func loadConfig() Config {
|
||||
jety.SetConfigType("toml")
|
||||
_ = jety.ReadInConfig()
|
||||
|
||||
// Parse events filter from comma-separated string or TOML array.
|
||||
var filters []string
|
||||
raw := jety.GetString("events")
|
||||
if raw != "" {
|
||||
@@ -48,10 +49,58 @@ func loadConfig() Config {
|
||||
return Config{
|
||||
ListenAddr: jety.GetString("listen_addr"),
|
||||
WebhookSecret: jety.GetString("webhook_secret"),
|
||||
CISecret: jety.GetString("ci_secret"),
|
||||
SignalURL: jety.GetString("signal_url"),
|
||||
SignalAccount: jety.GetString("signal_account"),
|
||||
SignalRecipient: jety.GetString("signal_recipient"),
|
||||
SignalGroupID: jety.GetString("signal_group_id"),
|
||||
Events: ParseEventFilter(filters),
|
||||
Endpoints: parseEndpoints(),
|
||||
}
|
||||
}
|
||||
|
||||
func parseEndpoints() []Endpoint {
|
||||
raw := jety.Get("endpoints")
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tables, ok := raw.([]map[string]any)
|
||||
if !ok {
|
||||
log.Printf("warning: endpoints config is not a valid TOML array of tables")
|
||||
return nil
|
||||
}
|
||||
|
||||
var endpoints []Endpoint
|
||||
for _, t := range tables {
|
||||
slug, _ := t["slug"].(string)
|
||||
if slug == "" {
|
||||
log.Printf("warning: endpoint missing slug, skipping")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(slug, "/") {
|
||||
slug = "/" + slug
|
||||
}
|
||||
|
||||
var groupIDs []string
|
||||
switch v := t["group_ids"].(type) {
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
groupIDs = append(groupIDs, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
groupIDs = v
|
||||
}
|
||||
|
||||
if len(groupIDs) == 0 {
|
||||
log.Printf("warning: endpoint %q has no group_ids, skipping", slug)
|
||||
continue
|
||||
}
|
||||
|
||||
endpoints = append(endpoints, Endpoint{Slug: slug, GroupIDs: groupIDs})
|
||||
}
|
||||
|
||||
return endpoints
|
||||
}
|
||||
|
||||
32
examples/signal-notify.yml
Normal file
32
examples/signal-notify.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Signal Notification
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify Signal on failure
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||
run: |
|
||||
curl -sf -X POST "${{ secrets.SIGNAL_ENDPOINT }}/ci" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CI-Secret: ${{ secrets.SIGNAL_API_KEY }}" \
|
||||
-d '{
|
||||
"source": "${{ github.repository }}",
|
||||
"message": "❌ Workflow \"${{ github.event.workflow_run.name }}\" failed on ${{ github.event.workflow_run.head_branch }}\n${{ github.event.workflow_run.html_url }}"
|
||||
}'
|
||||
|
||||
- name: Notify Signal on success
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
run: |
|
||||
curl -sf -X POST "${{ secrets.SIGNAL_ENDPOINT }}/ci" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CI-Secret: ${{ secrets.SIGNAL_API_KEY }}" \
|
||||
-d '{
|
||||
"source": "${{ github.repository }}",
|
||||
"message": "✅ Workflow \"${{ github.event.workflow_run.name }}\" succeeded on ${{ github.event.workflow_run.head_branch }}\n${{ github.event.workflow_run.html_url }}"
|
||||
}'
|
||||
12
format.go
12
format.go
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-github/v70/github"
|
||||
"github.com/google/go-github/v84/github"
|
||||
)
|
||||
|
||||
// formatPush formats a push event into a Signal message.
|
||||
@@ -44,7 +44,7 @@ func formatIssue(event *github.IssuesEvent) string {
|
||||
repo, sender, action, issue.GetNumber(), issue.GetTitle(), issue.GetHTMLURL())
|
||||
|
||||
if action == "opened" && issue.GetBody() != "" {
|
||||
body := truncate(issue.GetBody(), 200)
|
||||
body := truncate(issue.GetBody(), 2000)
|
||||
msg += "\n\n" + body
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func formatIssueComment(event *github.IssueCommentEvent) string {
|
||||
issue := event.GetIssue()
|
||||
comment := event.GetComment()
|
||||
|
||||
body := truncate(comment.GetBody(), 300)
|
||||
body := truncate(comment.GetBody(), 2000)
|
||||
|
||||
return fmt.Sprintf("[%s] %s commented on #%d (%s):\n%s\n%s",
|
||||
repo, sender, issue.GetNumber(), issue.GetTitle(), body, comment.GetHTMLURL())
|
||||
@@ -75,7 +75,7 @@ func formatPR(event *github.PullRequestEvent) string {
|
||||
repo, sender, action, pr.GetNumber(), pr.GetTitle(), pr.GetHTMLURL())
|
||||
|
||||
if action == "opened" && pr.GetBody() != "" {
|
||||
body := truncate(pr.GetBody(), 200)
|
||||
body := truncate(pr.GetBody(), 2000)
|
||||
msg += "\n\n" + body
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func formatPRReview(event *github.PullRequestReviewEvent) string {
|
||||
review := event.GetReview()
|
||||
|
||||
state := review.GetState()
|
||||
body := truncate(review.GetBody(), 200)
|
||||
body := truncate(review.GetBody(), 2000)
|
||||
|
||||
msg := fmt.Sprintf("[%s] %s %s PR #%d: %s\n%s",
|
||||
repo, sender, state, pr.GetNumber(), pr.GetTitle(), review.GetHTMLURL())
|
||||
@@ -109,7 +109,7 @@ func formatPRReviewComment(event *github.PullRequestReviewCommentEvent) string {
|
||||
pr := event.GetPullRequest()
|
||||
comment := event.GetComment()
|
||||
|
||||
body := truncate(comment.GetBody(), 300)
|
||||
body := truncate(comment.GetBody(), 2000)
|
||||
|
||||
return fmt.Sprintf("[%s] %s commented on PR #%d (%s):\n%s\n%s",
|
||||
repo, sender, pr.GetNumber(), pr.GetTitle(), body, comment.GetHTMLURL())
|
||||
|
||||
507
format_test.go
Normal file
507
format_test.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-github/v84/github"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func TestFormatPush(t *testing.T) {
|
||||
event := &github.PushEvent{
|
||||
Ref: strPtr("refs/heads/main"),
|
||||
Repo: &github.PushEventRepository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Pusher: &github.CommitAuthor{
|
||||
Name: strPtr("tai"),
|
||||
},
|
||||
Commits: []*github.HeadCommit{
|
||||
{
|
||||
ID: strPtr("abc1234567890"),
|
||||
Message: strPtr("feat: initial commit"),
|
||||
},
|
||||
{
|
||||
ID: strPtr("def4567890123"),
|
||||
Message: strPtr("fix: typo\n\nLonger description here"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := formatPush(event)
|
||||
if got == "" {
|
||||
t.Fatal("formatPush returned empty string")
|
||||
}
|
||||
|
||||
// Check key parts are present
|
||||
checks := []string{
|
||||
"[taigrr/example]",
|
||||
"tai pushed 2 commits to main",
|
||||
"abc1234",
|
||||
"feat: initial commit",
|
||||
"def4567",
|
||||
"fix: typo",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatPush missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPushSingular(t *testing.T) {
|
||||
event := &github.PushEvent{
|
||||
Ref: strPtr("refs/heads/dev"),
|
||||
Repo: &github.PushEventRepository{
|
||||
FullName: strPtr("taigrr/repo"),
|
||||
},
|
||||
Pusher: &github.CommitAuthor{
|
||||
Name: strPtr("tai"),
|
||||
},
|
||||
Commits: []*github.HeadCommit{
|
||||
{
|
||||
ID: strPtr("aaa1111222233"),
|
||||
Message: strPtr("docs: update readme"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := formatPush(event)
|
||||
if !contains(got, "1 commit to dev") {
|
||||
t.Errorf("expected singular 'commit', got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatIssue(t *testing.T) {
|
||||
event := &github.IssuesEvent{
|
||||
Action: strPtr("opened"),
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("contributor"),
|
||||
},
|
||||
Issue: &github.Issue{
|
||||
Number: intPtr(42),
|
||||
Title: strPtr("Bug: something broken"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/issues/42"),
|
||||
Body: strPtr("Steps to reproduce..."),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatIssue(event)
|
||||
checks := []string{
|
||||
"[taigrr/example]",
|
||||
"contributor",
|
||||
"opened",
|
||||
"#42",
|
||||
"Bug: something broken",
|
||||
"https://github.com/taigrr/example/issues/42",
|
||||
"Steps to reproduce...",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatIssue missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatIssueComment(t *testing.T) {
|
||||
event := &github.IssueCommentEvent{
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("reviewer"),
|
||||
},
|
||||
Issue: &github.Issue{
|
||||
Number: intPtr(10),
|
||||
Title: strPtr("Feature request"),
|
||||
},
|
||||
Comment: &github.IssueComment{
|
||||
Body: strPtr("Looks good to me!"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/issues/10#comment-1"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatIssueComment(event)
|
||||
checks := []string{
|
||||
"[taigrr/example]",
|
||||
"reviewer",
|
||||
"#10",
|
||||
"Feature request",
|
||||
"Looks good to me!",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatIssueComment missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPR(t *testing.T) {
|
||||
event := &github.PullRequestEvent{
|
||||
Action: strPtr("opened"),
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("author"),
|
||||
},
|
||||
PullRequest: &github.PullRequest{
|
||||
Number: intPtr(5),
|
||||
Title: strPtr("Add feature X"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/pull/5"),
|
||||
Body: strPtr("This PR adds feature X"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatPR(event)
|
||||
checks := []string{
|
||||
"[taigrr/example]",
|
||||
"author",
|
||||
"opened",
|
||||
"PR #5",
|
||||
"Add feature X",
|
||||
"This PR adds feature X",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatPR missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPRReview(t *testing.T) {
|
||||
event := &github.PullRequestReviewEvent{
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("reviewer"),
|
||||
},
|
||||
PullRequest: &github.PullRequest{
|
||||
Number: intPtr(5),
|
||||
Title: strPtr("Add feature X"),
|
||||
},
|
||||
Review: &github.PullRequestReview{
|
||||
State: strPtr("approved"),
|
||||
Body: strPtr("Ship it!"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/pull/5#pullrequestreview-1"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatPRReview(event)
|
||||
checks := []string{
|
||||
"[taigrr/example]",
|
||||
"reviewer",
|
||||
"approved",
|
||||
"PR #5",
|
||||
"Ship it!",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatPRReview missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPRReviewComment(t *testing.T) {
|
||||
event := &github.PullRequestReviewCommentEvent{
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("reviewer"),
|
||||
},
|
||||
PullRequest: &github.PullRequest{
|
||||
Number: intPtr(5),
|
||||
Title: strPtr("Add feature X"),
|
||||
},
|
||||
Comment: &github.PullRequestComment{
|
||||
Body: strPtr("Nit: rename this variable"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/pull/5#discussion_r1"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatPRReviewComment(event)
|
||||
checks := []string{
|
||||
"[taigrr/example]",
|
||||
"reviewer",
|
||||
"PR #5",
|
||||
"Nit: rename this variable",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatPRReviewComment missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRelease(t *testing.T) {
|
||||
event := &github.ReleaseEvent{
|
||||
Action: strPtr("published"),
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("tai"),
|
||||
},
|
||||
Release: &github.RepositoryRelease{
|
||||
TagName: strPtr("v1.0.0"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/releases/tag/v1.0.0"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatRelease(event)
|
||||
checks := []string{
|
||||
"[taigrr/example]",
|
||||
"tai",
|
||||
"published",
|
||||
"v1.0.0",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatRelease missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatStar(t *testing.T) {
|
||||
event := &github.StarEvent{
|
||||
Action: strPtr("created"),
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
StargazersCount: intPtr(100),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("fan"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatStar(event)
|
||||
if !contains(got, "fan") || !contains(got, "starred") || !contains(got, "100") {
|
||||
t.Errorf("formatStar unexpected output:\n%s", got)
|
||||
}
|
||||
|
||||
// Test unstar
|
||||
event.Action = strPtr("deleted")
|
||||
got = formatStar(event)
|
||||
if !contains(got, "unstarred") {
|
||||
t.Errorf("formatStar deleted should say 'unstarred', got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFork(t *testing.T) {
|
||||
event := &github.ForkEvent{
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Forkee: &github.Repository{
|
||||
FullName: strPtr("user/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("user"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatFork(event)
|
||||
checks := []string{"taigrr/example", "user", "forked", "user/example"}
|
||||
for _, c := range checks {
|
||||
if !contains(got, c) {
|
||||
t.Errorf("formatFork missing %q in output:\n%s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWorkflowRun(t *testing.T) {
|
||||
event := &github.WorkflowRunEvent{
|
||||
Action: strPtr("completed"),
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
WorkflowRun: &github.WorkflowRun{
|
||||
Name: strPtr("CI"),
|
||||
Conclusion: strPtr("success"),
|
||||
HeadBranch: strPtr("main"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/actions/runs/1"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatWorkflowRun(event)
|
||||
if !contains(got, "✅") || !contains(got, "CI") || !contains(got, "success") {
|
||||
t.Errorf("formatWorkflowRun success unexpected output:\n%s", got)
|
||||
}
|
||||
|
||||
// Test failure
|
||||
event.WorkflowRun.Conclusion = strPtr("failure")
|
||||
got = formatWorkflowRun(event)
|
||||
if !contains(got, "❌") {
|
||||
t.Errorf("formatWorkflowRun failure should have ❌, got:\n%s", got)
|
||||
}
|
||||
|
||||
// Test cancelled
|
||||
event.WorkflowRun.Conclusion = strPtr("cancelled")
|
||||
got = formatWorkflowRun(event)
|
||||
if !contains(got, "⚠️") {
|
||||
t.Errorf("formatWorkflowRun cancelled should have ⚠️, got:\n%s", got)
|
||||
}
|
||||
|
||||
// Test non-completed action returns empty
|
||||
event.Action = strPtr("requested")
|
||||
got = formatWorkflowRun(event)
|
||||
if got != "" {
|
||||
t.Errorf("formatWorkflowRun non-completed should return empty, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCreate(t *testing.T) {
|
||||
event := &github.CreateEvent{
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("tai"),
|
||||
},
|
||||
RefType: strPtr("branch"),
|
||||
Ref: strPtr("feature-x"),
|
||||
}
|
||||
|
||||
got := formatCreate(event)
|
||||
if !contains(got, "created") || !contains(got, "branch") || !contains(got, "feature-x") {
|
||||
t.Errorf("formatCreate unexpected output:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDelete(t *testing.T) {
|
||||
event := &github.DeleteEvent{
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("tai"),
|
||||
},
|
||||
RefType: strPtr("tag"),
|
||||
Ref: strPtr("v0.1.0"),
|
||||
}
|
||||
|
||||
got := formatDelete(event)
|
||||
if !contains(got, "deleted") || !contains(got, "tag") || !contains(got, "v0.1.0") {
|
||||
t.Errorf("formatDelete unexpected output:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"single line", "single line"},
|
||||
{"first\nsecond\nthird", "first"},
|
||||
{"", ""},
|
||||
{"trailing\n", "trailing"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := firstLine(tt.input); got != tt.want {
|
||||
t.Errorf("firstLine(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"exactly10!", 10, "exactly10!"},
|
||||
{"this is too long", 10, "this is to..."},
|
||||
{"", 5, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := truncate(tt.input, tt.maxLen); got != tt.want {
|
||||
t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitMessage(t *testing.T) {
|
||||
// Short message — no split
|
||||
short := "hello"
|
||||
chunks := splitMessage(short)
|
||||
if len(chunks) != 1 || chunks[0] != short {
|
||||
t.Errorf("splitMessage short: got %v", chunks)
|
||||
}
|
||||
|
||||
// Exactly at limit
|
||||
exact := string(make([]byte, maxMessageLen))
|
||||
for i := range exact {
|
||||
exact = exact[:i] + "a" + exact[i+1:]
|
||||
}
|
||||
chunks = splitMessage(exact)
|
||||
if len(chunks) != 1 {
|
||||
t.Errorf("splitMessage exact: got %d chunks, want 1", len(chunks))
|
||||
}
|
||||
|
||||
// Over limit — should split
|
||||
long := make([]byte, maxMessageLen+500)
|
||||
for i := range long {
|
||||
long[i] = 'x'
|
||||
}
|
||||
// Insert a newline near the boundary for clean split
|
||||
long[maxMessageLen-10] = '\n'
|
||||
chunks = splitMessage(string(long))
|
||||
if len(chunks) < 2 {
|
||||
t.Errorf("splitMessage long: expected 2+ chunks, got %d", len(chunks))
|
||||
}
|
||||
// Verify all content is preserved
|
||||
total := 0
|
||||
for _, c := range chunks {
|
||||
total += len(c)
|
||||
}
|
||||
// Account for whitespace trimming
|
||||
if total < maxMessageLen {
|
||||
t.Errorf("splitMessage long: total content %d seems too small", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatIssueClosedNoBody(t *testing.T) {
|
||||
event := &github.IssuesEvent{
|
||||
Action: strPtr("closed"),
|
||||
Repo: &github.Repository{
|
||||
FullName: strPtr("taigrr/example"),
|
||||
},
|
||||
Sender: &github.User{
|
||||
Login: strPtr("tai"),
|
||||
},
|
||||
Issue: &github.Issue{
|
||||
Number: intPtr(1),
|
||||
Title: strPtr("Old bug"),
|
||||
HTMLURL: strPtr("https://github.com/taigrr/example/issues/1"),
|
||||
Body: strPtr("Some body that should not appear"),
|
||||
},
|
||||
}
|
||||
|
||||
got := formatIssue(event)
|
||||
// Body should NOT be included for non-opened actions
|
||||
if contains(got, "Some body that should not appear") {
|
||||
t.Errorf("formatIssue closed should not include body, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(substr) > 0 && containsStr(s, substr)))
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
12
go.mod
12
go.mod
@@ -1,18 +1,18 @@
|
||||
module github.com/taigrr/github-to-signal
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.2
|
||||
|
||||
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
|
||||
github.com/cbrgm/githubevents/v2 v2.15.2
|
||||
github.com/google/go-github/v84 v84.0.0
|
||||
github.com/taigrr/jety v0.4.0
|
||||
github.com/taigrr/signalcli v0.0.0-20260412082710-b926f174d525
|
||||
)
|
||||
|
||||
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
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
20
go.sum
20
go.sum
@@ -1,22 +1,22 @@
|
||||
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/cbrgm/githubevents/v2 v2.15.2 h1:0sNunNkNzovXKez91KzWZYWlqzXTl9waj6HN5OPOiKw=
|
||||
github.com/cbrgm/githubevents/v2 v2.15.2/go.mod h1:XtEZTSgv0iRx1BO6E3xXNp2iwAu84CmDYsKrMHztDE8=
|
||||
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-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA=
|
||||
github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ=
|
||||
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=
|
||||
github.com/taigrr/jety v0.4.0 h1:BECC3r3CdQOxN/OdJpJ1VFH6DCJnmNby4vxRwL2wcZQ=
|
||||
github.com/taigrr/jety v0.4.0/go.mod h1:Z8O3yHvOIv0O+KTadzHl58/gfAtLwNo8FlsL2JwpaKA=
|
||||
github.com/taigrr/signalcli v0.0.0-20260412082710-b926f174d525 h1:tK6cU4iaSSp/HwsFOfeaVJfviMWBBh/V92AV8P3LAYw=
|
||||
github.com/taigrr/signalcli v0.0.0-20260412082710-b926f174d525/go.mod h1:dgF5U1lF8rdr4PsrDyvv/jMEYJ1OYfjRNz9AgGRh7aU=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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=
|
||||
|
||||
83
main.go
83
main.go
@@ -4,12 +4,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/cbrgm/githubevents/v2/githubevents"
|
||||
"github.com/google/go-github/v70/github"
|
||||
"github.com/google/go-github/v84/github"
|
||||
"github.com/taigrr/signalcli"
|
||||
)
|
||||
|
||||
@@ -92,6 +95,10 @@ func main() {
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
for _, ep := range cfg.Endpoints {
|
||||
mux.HandleFunc("POST "+ep.Slug, n.handleCustom(cfg.CISecret, ep.GroupIDs))
|
||||
log.Printf("custom endpoint enabled: POST %s -> %d group(s)", ep.Slug, len(ep.GroupIDs))
|
||||
}
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "ok")
|
||||
@@ -221,3 +228,77 @@ func (n *notifier) onDelete(ctx context.Context, _ string, _ string, event *gith
|
||||
n.send(ctx, formatDelete(event))
|
||||
return nil
|
||||
}
|
||||
|
||||
type customMessage struct {
|
||||
Source string `json:"source"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (n *notifier) handleCustom(secret string, groupIDs []string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if secret != "" {
|
||||
provided := r.Header.Get("X-CI-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var msg customMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if msg.Message == "" {
|
||||
http.Error(w, "message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
text := msg.Message
|
||||
if msg.Source != "" {
|
||||
text = fmt.Sprintf("[%s] %s", msg.Source, msg.Message)
|
||||
}
|
||||
|
||||
n.sendToGroups(r.Context(), text, groupIDs)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
const maxMessageLen = 2000
|
||||
|
||||
func (n *notifier) sendToGroups(ctx context.Context, msg string, groupIDs []string) {
|
||||
chunks := splitMessage(msg)
|
||||
for _, gid := range groupIDs {
|
||||
for _, chunk := range chunks {
|
||||
params := signalcli.SendParams{Message: chunk, GroupID: gid}
|
||||
if _, err := n.signal.Send(ctx, params); err != nil {
|
||||
log.Printf("signal send error (group %s): %v", gid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitMessage(msg string) []string {
|
||||
if len(msg) <= maxMessageLen {
|
||||
return []string{msg}
|
||||
}
|
||||
|
||||
var chunks []string
|
||||
for len(msg) > 0 {
|
||||
end := maxMessageLen
|
||||
if end > len(msg) {
|
||||
end = len(msg)
|
||||
}
|
||||
if end < len(msg) {
|
||||
if idx := strings.LastIndex(msg[:end], "\n"); idx > 0 {
|
||||
end = idx + 1
|
||||
}
|
||||
}
|
||||
chunk := strings.TrimSpace(msg[:end])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
msg = msg[end:]
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user