diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8a86791 --- /dev/null +++ b/AGENTS.md @@ -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) diff --git a/format.go b/format.go index b573308..4df9eb5 100644 --- a/format.go +++ b/format.go @@ -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())