Skip to content

Websocket transport#7

Merged
gateixeira merged 7 commits into
mainfrom
feat/websocket-transport
May 18, 2026
Merged

Websocket transport#7
gateixeira merged 7 commits into
mainfrom
feat/websocket-transport

Conversation

@gateixeira
Copy link
Copy Markdown
Owner

This pull request adds support for receiving GitHub webhook events via a WebSocket relay, allowing the app to function without a public HTTP endpoint. It introduces new configuration options for selecting the transport method and subscribing to specific repositories, organizations, or enterprises. The webhook ingestion logic is refactored to be transport-agnostic, and the documentation is updated to describe the new setup and caveats.

gateixeira and others added 4 commits May 18, 2026 11:36
Adds a second webhook ingest path for deployments without a public HTTP
endpoint (laptops, NAT'd hosts, demos). Behind WEBHOOK_TRANSPORT=websocket,
the app dials GitHub's relay (the same one used by 'gh webhook'), creates
a temporary repo/org hook, and feeds deliveries into the existing
ordering/persistence pipeline.

Refactor:
- handlers/webhook_handler.go: extracts a transport-agnostic Ingest(event,
  delivery, body) IngestResult seam. The Gin Handle() is now a thin
  adapter that preserves the original JSON response shape.
- internal/config/config.go: adds WEBHOOK_TRANSPORT, GITHUB_TOKEN,
  GITHUB_HOST, GITHUB_REPO, GITHUB_ORG, GITHUB_EVENTS with validation.

New package internal/services/ghws:
- Subscriber.Run(ctx) handles create-hook -> wss dial -> activate -> read
  frames -> dispatch -> ack loop with exponential backoff (1s..60s).
- Best-effort hook cleanup on shutdown.
- Emits WebhookEventsTotal{outcome=ws_accepted|ws_queue_full|ws_rejected}.
- Unit test exercises the full happy path against an httptest server.

cmd/server wires the subscriber when WEBHOOK_TRANSPORT=websocket and
joins it to graceful shutdown. README gets a new section documenting
the transport, env vars, token scopes, and caveats.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds GITHUB_ENTERPRISE alongside GITHUB_REPO and GITHUB_ORG. When set,
the subscriber creates the hook under POST /enterprises/{slug}/hooks
and otherwise follows the same protocol. Config validation now requires
exactly one of the three scopes when WEBHOOK_TRANSPORT=websocket.

The README documents the new option together with a caveat: upstream
gh-webhook only targets repo and org hooks, so the relay's enterprise
behaviour is best-effort and depends on the account having the feature
enabled at that scope.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ency

GitHub Enterprise Cloud with data residency exposes its API at
https://api.<customer>.ghe.com, which is neither the public-GitHub shape
(https://api.github.com) nor the GHES shape (https://<host>/api/v3).
Detect any *.ghe.com host and pick the api.<host> form so customers on
GHE.com can use WEBHOOK_TRANSPORT=websocket without further work.

Adds a table-driven test covering all three deployment shapes and
clarifies the GITHUB_HOST description in the README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
gh-webhook only uses the bare-token Authorization header on the
WebSocket dial; its REST calls go through go-gh's api.NewClient, which
sends the standard "token <T>" / "Bearer <T>" form. Sending a bare
user-to-server OAuth token (the kind gh auth token returns) to the
REST API gets a 401 "Requires authentication".

Switch applyAuth to "Bearer <token>" for REST; the WebSocket dial still
uses the bare form because the relay expects it that way.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an optional WebSocket-based webhook transport so the server can ingest GitHub events without requiring a publicly reachable POST /webhook endpoint. It introduces a new ghws subscriber that creates an (initially inactive) webhook via the GitHub REST API, dials a relay WebSocket, activates the hook, and forwards frames into the existing ingest/order pipeline via a transport-agnostic WebhookHandler.Ingest.

Changes:

  • Added internal/services/ghws WebSocket subscriber implementation + tests.
  • Refactored webhook ingestion into a transport-agnostic WebhookHandler.Ingest and wired an adapter in cmd/server.
  • Added config/env validation for WEBHOOK_TRANSPORT and updated README with WebSocket setup/caveats.
Show a summary per file
File Description
README.md Documents new WebSocket transport configuration, quick-starts, and caveats.
internal/services/ghws/subscriber.go Implements the GitHub relay WebSocket subscription lifecycle and frame ACK protocol.
internal/services/ghws/subscriber_test.go Adds integration-style tests using an httptest server + WebSocket upgrader to validate the protocol flow.
internal/config/config.go Adds env vars for selecting transport and validates required GitHub subscription settings in WebSocket mode.
handlers/webhook_handler.go Extracts transport-agnostic ingest logic into Ingest and simplifies HTTP handler to map results to responses.
cmd/server/server.go Starts the optional WebSocket subscriber when enabled and adds parsing for GITHUB_EVENTS.
go.mod Adds github.com/gorilla/websocket dependency.
go.sum Records checksums for the added WebSocket dependency.

Copilot's findings

  • Files reviewed: 9/14 changed files
  • Comments generated: 5

Comment thread cmd/server/server.go
Comment on lines +237 to +249
// splitEvents parses a comma-separated GITHUB_EVENTS value into a slice
// suitable for ghws.Config. Whitespace is trimmed and empty entries are
// dropped so trailing/duplicate commas are forgiving.
func splitEvents(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
Comment on lines +209 to +226
func (s *Subscriber) runOnce(ctx context.Context) error {
hook, err := s.createHook(ctx)
if err != nil {
return fmt.Errorf("create hook: %w", err)
}
defer s.deleteHook(context.Background(), hook)

conn, _, err := s.dialer.DialContext(ctx, hook.WsURL, http.Header{
"Authorization": []string{s.cfg.Token},
})
if err != nil {
return fmt.Errorf("dial relay: %w", err)
}
defer conn.Close()

if err := s.activateHook(ctx, hook); err != nil {
return fmt.Errorf("activate hook: %w", err)
}
Comment on lines +333 to +350
func (s *Subscriber) createHook(ctx context.Context) (*hookResponse, error) {
body, _ := json.Marshal(createHookRequest{
Name: "cli",
Events: s.cfg.Events,
Active: false,
Config: hookConfig{
ContentType: "json",
InsecureSSL: "0",
Secret: s.cfg.Secret,
},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
s.apiBase()+s.hookPath(), bytes.NewReader(body))
if err != nil {
return nil, err
}
s.applyAuth(req)
resp, err := s.httpClient.Do(req)
Comment on lines +354 to +358
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("create hook: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
Comment thread go.mod
Comment on lines 25 to 30
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
@gateixeira gateixeira merged commit 46c94ef into main May 18, 2026
2 checks passed
@gateixeira gateixeira deleted the feat/websocket-transport branch May 18, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants