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

+238
View File
@@ -624,6 +624,29 @@
},
"description": "Hook"
},
"HookDelivery": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HookDelivery"
}
}
},
"description": "HookDelivery"
},
"HookDeliveryList": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/HookDelivery"
},
"type": "array"
}
}
},
"description": "HookDeliveryList"
},
"HookList": {
"content": {
"application/json": {
@@ -7248,6 +7271,102 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"HookDelivery": {
"description": "HookDelivery represents a single delivery attempt for a webhook",
"properties": {
"delivered_at": {
"description": "The date and time when the delivery was sent",
"format": "date-time",
"type": "string",
"x-go-name": "Delivered"
},
"event_type": {
"description": "The event type that triggered this delivery",
"type": "string",
"x-go-name": "EventType"
},
"is_delivered": {
"description": "Whether the delivery has been sent",
"type": "boolean",
"x-go-name": "IsDelivered"
},
"is_succeeded": {
"description": "Whether the delivery was successful (2xx response)",
"type": "boolean",
"x-go-name": "IsSucceeded"
},
"request": {
"$ref": "#/components/schemas/HookDeliveryRequest"
},
"response": {
"$ref": "#/components/schemas/HookDeliveryResponse"
},
"uuid": {
"description": "The unique identifier of the delivery",
"type": "string",
"x-go-name": "UUID"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryRequest": {
"description": "HookDeliveryRequest represents the request information of a webhook delivery",
"properties": {
"body": {
"description": "The body sent with the delivery",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"description": "The headers sent with the delivery",
"type": "object",
"x-go-name": "Headers"
},
"http_method": {
"description": "The HTTP method used for the delivery",
"type": "string",
"x-go-name": "HTTPMethod"
},
"url": {
"description": "The URL the delivery was sent to",
"format": "uri",
"type": "string",
"x-go-name": "URL"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryResponse": {
"description": "HookDeliveryResponse represents the response information of a webhook delivery",
"properties": {
"body": {
"description": "The body received in the response",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"description": "The headers received in the response",
"type": "object",
"x-go-name": "Headers"
},
"status": {
"description": "The HTTP status code of the response",
"format": "int64",
"type": "integer",
"x-go-name": "Status"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"Identity": {
"description": "Identity for a person's identity like an author or committer",
"properties": {
@@ -23104,6 +23223,125 @@
]
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries": {
"get": {
"operationId": "repoListHookDeliveries",
"parameters": [
{
"description": "owner of the repo",
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "name of the repo",
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "id of the hook",
"in": "path",
"name": "id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"description": "page number of results to return (1-based)",
"in": "query",
"name": "page",
"schema": {
"type": "integer"
}
},
{
"description": "page size of results",
"in": "query",
"name": "limit",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/HookDeliveryList"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "List the deliveries of a hook in a repository",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}": {
"get": {
"operationId": "repoGetHookDelivery",
"parameters": [
{
"description": "owner of the repo",
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "name of the repo",
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "id of the hook",
"in": "path",
"name": "id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"description": "uuid of the delivery",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/HookDelivery"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "Get a delivery of a hook in a repository",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/hooks/{id}/tests": {
"post": {
"operationId": "repoTestHook",
+217
View File
@@ -10997,6 +10997,113 @@
}
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "List the deliveries of a hook in a repository",
"operationId": "repoListHookDeliveries",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "id of the hook",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/HookDeliveryList"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get a delivery of a hook in a repository",
"operationId": "repoGetHookDelivery",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "id of the hook",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "uuid of the delivery",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/HookDelivery"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/hooks/{id}/tests": {
"post": {
"produces": [
@@ -31083,6 +31190,101 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"HookDelivery": {
"description": "HookDelivery represents a single delivery attempt for a webhook",
"type": "object",
"properties": {
"delivered_at": {
"description": "The date and time when the delivery was sent",
"type": "string",
"format": "date-time",
"x-go-name": "Delivered"
},
"event_type": {
"description": "The event type that triggered this delivery",
"type": "string",
"x-go-name": "EventType"
},
"is_delivered": {
"description": "Whether the delivery has been sent",
"type": "boolean",
"x-go-name": "IsDelivered"
},
"is_succeeded": {
"description": "Whether the delivery was successful (2xx response)",
"type": "boolean",
"x-go-name": "IsSucceeded"
},
"request": {
"$ref": "#/definitions/HookDeliveryRequest"
},
"response": {
"$ref": "#/definitions/HookDeliveryResponse"
},
"uuid": {
"description": "The unique identifier of the delivery",
"type": "string",
"x-go-name": "UUID"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryRequest": {
"description": "HookDeliveryRequest represents the request information of a webhook delivery",
"type": "object",
"properties": {
"body": {
"description": "The body sent with the delivery",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"description": "The headers sent with the delivery",
"type": "object",
"additionalProperties": {
"type": "string"
},
"x-go-name": "Headers"
},
"http_method": {
"description": "The HTTP method used for the delivery",
"type": "string",
"x-go-name": "HTTPMethod"
},
"url": {
"description": "The URL the delivery was sent to",
"type": "string",
"x-go-name": "URL"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryResponse": {
"description": "HookDeliveryResponse represents the response information of a webhook delivery",
"type": "object",
"properties": {
"body": {
"description": "The body received in the response",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"description": "The headers received in the response",
"type": "object",
"additionalProperties": {
"type": "string"
},
"x-go-name": "Headers"
},
"status": {
"description": "The HTTP status code of the response",
"type": "integer",
"format": "int64",
"x-go-name": "Status"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"Identity": {
"description": "Identity for a person's identity like an author or committer",
"type": "object",
@@ -35495,6 +35697,21 @@
"$ref": "#/definitions/Hook"
}
},
"HookDelivery": {
"description": "HookDelivery",
"schema": {
"$ref": "#/definitions/HookDelivery"
}
},
"HookDeliveryList": {
"description": "HookDeliveryList",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/HookDelivery"
}
}
},
"HookList": {
"description": "HookList",
"schema": {