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

+4
View File
@@ -1281,6 +1281,10 @@ func Routes() *web.Router {
Patch(bind(api.EditHookOption{}), repo.EditHook).
Delete(repo.DeleteHook)
m.Post("/tests", context.ReferencesGitRepo(), context.RepoRefForAPI, repo.TestHook)
m.Group("/deliveries", func() {
m.Get("", repo.ListDeliveries)
m.Get("/{uuid}", repo.GetDelivery)
})
})
}, reqToken(), reqAdmin(), reqWebhooksEnabled())
m.Group("/collaborators", func() {
+122
View File
@@ -122,6 +122,128 @@ func GetHook(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, apiHook)
}
func ListDeliveries(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/hooks/{id}/deliveries repository repoListHookDeliveries
// ---
// summary: List the deliveries of a hook in a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: id
// in: path
// description: id of the hook
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/HookDeliveryList"
// "404":
// "$ref": "#/responses/notFound"
hookID := ctx.PathParamInt64("id")
if _, err := utils.GetRepoHook(ctx, ctx.Repo.Repository.ID, hookID); err != nil {
return
}
listOptions := utils.GetListOptions(ctx)
tasks, count, err := db.FindAndCount[webhook.HookTask](ctx, webhook.ListHookTaskOptions{
ListOptions: listOptions,
HookID: hookID,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
deliveries := make([]*api.HookDelivery, len(tasks))
for i, task := range tasks {
delivery := webhook_service.ToHookDelivery(task)
if delivery.Request != nil { // the list omits payloads, they are only returned per delivery
delivery.Request.Headers = nil
delivery.Request.Body = ""
}
if delivery.Response != nil {
delivery.Response.Headers = nil
delivery.Response.Body = ""
}
deliveries[i] = delivery
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, deliveries)
}
func GetDelivery(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid} repository repoGetHookDelivery
// ---
// summary: Get a delivery of a hook in a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: id
// in: path
// description: id of the hook
// type: integer
// format: int64
// required: true
// - name: uuid
// in: path
// description: uuid of the delivery
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/HookDelivery"
// "404":
// "$ref": "#/responses/notFound"
hookID := ctx.PathParamInt64("id")
if _, err := utils.GetRepoHook(ctx, ctx.Repo.Repository.ID, hookID); err != nil {
return
}
task, err := webhook.GetHookTaskByUUID(ctx, hookID, ctx.PathParam("uuid"))
if err != nil {
if webhook.IsErrHookTaskNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
ctx.JSON(http.StatusOK, webhook_service.ToHookDelivery(task))
}
// TestHook tests a hook
func TestHook(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/hooks/{id}/tests repository repoTestHook
+14
View File
@@ -105,6 +105,20 @@ type swaggerResponseHookList struct {
Body []api.Hook `json:"body"`
}
// HookDelivery
// swagger:response HookDelivery
type swaggerResponseHookDelivery struct {
// in:body
Body api.HookDelivery `json:"body"`
}
// HookDeliveryList
// swagger:response HookDeliveryList
type swaggerResponseHookDeliveryList struct {
// in:body
Body []api.HookDelivery `json:"body"`
}
// GitHook
// swagger:response GitHook
type swaggerResponseGitHook struct {