feat(api): add REST API endpoints for repository webhook delivery history (#38775)

Webhook delivery history (request/response, status, timestamp) was only
reachable through the repo settings UI, which shows the last ~10
deliveries with no filtering. There was no way to fetch this data
programmatically for debugging integrations or auditing webhook
activity.

This adds two read endpoints at the repository level:

- `GET /repos/{owner}/{repo}/hooks/{id}/deliveries` — paginated list of
  a hook's deliveries, most recent first
- `GET /repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}` — a single
  delivery, including request/response headers, body, and status

Both reuse the existing `hook_task` storage and retention policy (the
`CleanupHookTaskTable` cron job) that already back the "Recent
Deliveries" web UI, and are gated by the same `reqAdmin()` +
`reqWebhooksEnabled()` guard as the existing hook endpoints.

Org, user, and admin/system-level webhooks are intentionally out of
scope for this PR to keep it reviewable; they can follow as separate
PRs using the same pattern.

## Usage

```bash
curl -H "Authorization: token $TOKEN" \
  https://gitea.example.com/api/v1/repos/{owner}/{repo}/hooks/{id}/deliveries

curl -H "Authorization: token $TOKEN" \
  https://gitea.example.com/api/v1/repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}
```

## Testing

- Unit tests for the new model-layer query functions
  (`ListHookTasks`, `GetHookTaskByUUID`)
- Integration tests covering: list + get, an undelivered task's response
  is `null`, a hook/delivery UUID from another repository 404s instead
  of leaking across repos, and pagination (`page`/`limit`, `Link` and
  `X-Total-Count` headers, correct ordering/no overlap across pages)
- `make lint-go`, `make generate-swagger`

Fixes: https://github.com/go-gitea/gitea/issues/38463.
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
authored and GitHub committed 2026-10-24 00:18:51 +00:00
1 parent bcb4559c10
commit 96bee6c5a5
11 files changed
+824 -6

No files matched your search

+37
View File
@@ -6,6 +6,7 @@ package webhook
import (
"fmt"
"html"
"maps"
"net/url"
"strings"
@@ -461,3 +462,39 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
BranchFilter: w.BranchFilter,
}, nil
}
func ToHookDelivery(t *webhook_model.HookTask) *api.HookDelivery {
delivery := &api.HookDelivery{
UUID: t.UUID,
EventType: string(t.EventType),
IsDelivered: t.IsDelivered,
IsSucceeded: t.IsSucceed,
}
if t.IsDelivered {
delivery.Delivered = new(t.Delivered.AsTime())
}
if t.RequestInfo != nil {
delivery.Request = &api.HookDeliveryRequest{
URL: t.RequestInfo.URL,
HTTPMethod: t.RequestInfo.HTTPMethod,
Headers: maps.Clone(t.RequestInfo.Headers),
Body: t.RequestInfo.Body,
}
for name := range delivery.Request.Headers {
if strings.EqualFold(name, "Authorization") {
delivery.Request.Headers[name] = "******"
}
}
if t.PayloadVersion == 1 && delivery.Request.Body == "" {
delivery.Request.Body = t.PayloadContent
}
}
if t.ResponseInfo != nil {
delivery.Response = &api.HookDeliveryResponse{
Status: t.ResponseInfo.Status,
Headers: t.ResponseInfo.Headers,
Body: t.ResponseInfo.Body,
}
}
return delivery
}
+36
View File
@@ -6,11 +6,47 @@ package webhook
import (
"testing"
webhook_model "gitea.dev/models/webhook"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToHookDelivery(t *testing.T) {
for _, test := range []struct {
name string
payloadVersion int
requestContent string
body string
}{
{"legacy payload", 1, `{"headers":{"Authorization":"stored credential","authorization":"lowercase credential","Content-Type":"application/json"}}`, "payload"},
{"recorded body", 1, `{"body":"sent body"}`, "sent body"},
{"empty current body", 2, `{}`, ""},
} {
t.Run(test.name, func(t *testing.T) {
task := &webhook_model.HookTask{
PayloadVersion: test.payloadVersion,
PayloadContent: "payload",
RequestContent: test.requestContent,
}
task.AfterLoad()
delivery := ToHookDelivery(task)
require.NotNil(t, delivery.Request)
assert.Equal(t, test.body, delivery.Request.Body)
if task.RequestInfo.Headers != nil {
assert.Equal(t, map[string]string{
"Authorization": "******",
"authorization": "******",
"Content-Type": "application/json",
}, delivery.Request.Headers)
assert.Equal(t, "stored credential", task.RequestInfo.Headers["Authorization"])
assert.Equal(t, "lowercase credential", task.RequestInfo.Headers["authorization"])
}
})
}
}
func createTestPayload() *api.CreatePayload {
return &api.CreatePayload{
Sha: "2020558fe2e34debb818a514715839cabd25e777",