Skip to content

Add webhook event parsing for GitHub, GitLab and Gitea/Forgejo - #184

Merged
andrew merged 2 commits into
git-pkgs:mainfrom
abhinavgautam01:webhook-event-parsing
Oct 22, 2026
Merged

andrew merged 2 commits into
git-pkgs:mainfrom
abhinavgautam01:webhook-event-parsing

Conversation

@abhinavgautam01

@abhinavgautam01 abhinavgautam01 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #50

Adds verification and parsing of incoming webhook deliveries into a normalized WebhookEvent, so consumers can handle GitHub, GitLab and Gitea/Forgejo events without forge-specific code. It builds on the webhook management from #182 and uses the same event names.

API

type WebhookService interface {
	// ...List, Get, Create, Delete from #182
	ParseEvent(r *http.Request, secret string) (*WebhookEvent, error)
}

github, gitlab and gitea also export ParseWebhook(r, secret), which needs no API client. This matters for Gitea, where gitea.New contacts the server on construction.

A new ErrInvalidWebhookSignature is returned when a delivery's signature or token does not match, so handlers can answer 401.

event, err := f.Webhooks().ParseEvent(r, secret)
if errors.Is(err, forges.ErrInvalidWebhookSignature) {
	http.Error(w, "bad signature", http.StatusUnauthorized)
	return
}
if event.Type == "pull_request" && event.Action == "opened" {
	log.Printf("new PR #%d in %s", event.PullRequest.Number, event.Repository.FullName)
}

WebhookEvent

Field Contents
Type Normalized event name, matching Webhook.Events
Action GitHub's action name where one exists, otherwise as sent
DeliveryID The forge's delivery ID header
Repository, Sender Set for every event that carries them
Push push and tag_push: ref, before/after, created/deleted, commits
PullRequest pull_request plus issue_comment on a pull request
Issue issues plus issue_comment on an issue
Comment issue_comment
Release release
Payload Always the raw JSON, for anything not normalized yet

The typed fields reuse the existing PullRequest, Issue, Comment, Release and Repository types and, on GitHub and Gitea, the existing converters.

Verification

Forge Check
GitHub HMAC-SHA256 in X-Hub-Signature-256 over the raw body (X-Hub-Signature accepted as a fallback)
GitLab X-Gitlab-Token compared to the secret in constant time
Gitea / Forgejo HMAC-SHA256 in X-Forgejo-Signature / X-Gitea-Signature over the JSON payload
  • Gitea and Forgejo sign the JSON payload even for form deliveries, unlike GitHub, which signs the form body. Both cases are tested.
  • Gitea hooks set to GET send the payload in the payload query parameter with no body. It is read from there and verified the same way.
  • Gitea and Forgejo also send X-GitHub-* headers for compatibility, so the Gitea parser reads only its own headers, preferring Forgejo's.
  • Bodies are capped at 25 MB, GitHub's payload limit.
  • An empty secret skips verification. The doc comment notes this is only safe for hooks created without a secret.

Event and action mapping

GitLab (X-Gitlab-Event):

Header Type
Push Hook / Tag Push Hook push / tag_push
Merge Request Hook pull_request
Issue Hook, Confidential Issue Hook issues
Note Hook on an issue or merge request issue_comment
Note Hook on a commit commit_comment
Release Hook release
Pipeline Hook / Job Hook workflow_run / workflow_job
Deployment Hook / Wiki Page Hook deployment / gollum

GitLab actions map to GitHub's: open → opened, close → closed, reopen → reopened, update → edited (or synchronize when the update brings new commits), merge → closed with Merged set. Release create → published, since GitLab releases are public once created. Actions with no equivalent, such as approved, are kept as sent.

Gitea / Forgejo: the event header already groups sub-events (issue_label arrives as issues). wiki → gollum and the review events (pull_request_approved, pull_request_rejected, pull_request_comment) → pull_request_review. Actions synchronized → synchronize and updated → edited.

GitHub names and actions are used as is.

Backend support

Forge Support
GitHub Full
GitLab Full. Under TinyGo the API operations return ErrNotSupported, but webhook parsing works
Gitea / Forgejo Full
Bitbucket, Gerrit, Tangled ErrNotSupported

Notes for review

  • Adding ParseEvent to WebhookService changes an interface that landed in Add webhook management service #182. Happy to move it to a separate optional interface instead if you prefer.
  • GitLab issue and merge request hooks only include author_id, so Author is empty there. The acting user is in Sender.
  • GitLab parsing uses small local payload structs instead of client-go's event types, which carry timestamps as strings in two formats and a separate project type per event. Both timestamp formats are handled.
  • Shared helpers (body limit, HMAC, token comparison, form payloads) live in internal/hookutil to keep the public API small.

Tests

  • GitHub, GitLab and Gitea: valid, wrong, missing and tampered signatures or tokens, skipped verification, each typed event, action mapping, comment targets, unknown events and malformed input.
  • Gitea: form deliveries signed over the JSON payload are accepted, while ones signed over the form body are rejected. Signed GET deliveries are accepted, with wrong or missing signatures and a missing payload parameter rejected. Also checks that Forgejo headers take precedence.
  • GitLab: both timestamp formats, merge request action variants including synchronize and merge, fork detection and every note target.
  • internal/hookutil unit tests, plus ParseEvent coverage for Bitbucket.
  • The GitLab parser tests also run under TinyGo. The tinygo CI job now includes them for both the wasm and wasip1 targets.
  • go test -race ./..., go test -tags=tinygo ./gitlab, the js/wasip1 WebAssembly builds and golangci-lint v2.14.0 all pass locally.

@andrew andrew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, this is a solid base. Three things before merge:

  1. gitea/webhook_events.go: Gitea hooks can be configured to deliver with GET. Gitea then sends the JSON in a payload query parameter with no body, so ParseWebhook currently fails with decoding webhook payload: unexpected end of JSON input. Gitea signs that same JSON, so when r.Method == http.MethodGet take the payload from r.URL.Query().Get("payload") and verify it as usual. Please add a test for a signed GET delivery.

  2. README: the example passes os.Getenv("WEBHOOK_SECRET") straight to ParseEvent, so if that variable is unset in a deployment, verification is silently skipped and unsigned deliveries are accepted. Keep the empty-secret behaviour in ParseEvent, but have the example read the secret up front and refuse to start when it's empty.

  3. gitlab/webhook_events.go: the parser only uses the standard library and hookutil, so it doesn't need the //go:build !tinygo tag. Drop the tag, move the gitLabWebhookService.ParseEvent method into gitlab/webhooks.go, have the TinyGo ParseEvent call ParseWebhook, and remove the stub ParseWebhook from gitlab/gitlab_tinygo.go. GitLab webhook parsing then works under TinyGo, and the tinygo CI job covers it.

@abhinavgautam01

Copy link
Copy Markdown
Contributor Author

Thanks, all three fixed.

  1. Gitea GET deliveries: ParseWebhook now reads the payload from the payload query parameter on GET requests and verifies it like a body payload. A new test covers a signed GET delivery, plus wrong or missing signatures and a missing payload parameter.
  2. README: the example reads WEBHOOK_SECRET once at startup and exits if it's empty. ParseEvent keeps the empty-secret behaviour.
  3. GitLab: dropped the !tinygo tag, moved the native ParseEvent into gitlab/webhooks.go, had the TinyGo ParseEvent call ParseWebhook and removed the stub. I also untagged the GitLab parser tests and added them to the tinygo CI job's -run pattern so it runs them on both wasm and wasip1. I ran those commands locally with TinyGo 0.42.0 and Wasmtime 44.0.1 and they pass.

@andrew
andrew merged commit 9a9ba7f into git-pkgs:main Oct 22, 2026
7 checks passed
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.

Webhook event parsing

2 participants