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

+27 -6
View File
@@ -19,6 +19,19 @@ import (
"xorm.io/builder"
)
type ListHookTaskOptions struct {
db.ListOptions
HookID int64
}
func (opts ListHookTaskOptions) ToConds() builder.Cond {
return builder.Eq{"hook_task.hook_id": opts.HookID}
}
func (opts ListHookTaskOptions) ToOrders() string {
return "hook_task.id DESC"
}
// ___ ___ __ ___________ __
// / | \ ____ ____ | | _\__ ___/____ _____| | __
// / ~ \/ _ \ / _ \| |/ / | | \__ \ / ___/ |/ /
@@ -116,6 +129,19 @@ func HookTasks(ctx context.Context, hookID int64, page int) ([]*HookTask, error)
Find(&tasks)
}
func GetHookTaskByUUID(ctx context.Context, hookID int64, uuid string) (*HookTask, error) {
task, exist, err := db.Get[HookTask](ctx, builder.Eq{"hook_id": hookID, "uuid": uuid})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrHookTaskNotExist{
HookID: hookID,
UUID: uuid,
}
}
return task, nil
}
// CreateHookTask creates a new hook task,
// it handles conversion from Payload to PayloadContent.
func CreateHookTask(ctx context.Context, t *HookTask) (*HookTask, error) {
@@ -152,14 +178,9 @@ func UpdateHookTask(ctx context.Context, t *HookTask) error {
// ReplayHookTask copies a hook task to get re-delivered
func ReplayHookTask(ctx context.Context, hookID int64, uuid string) (*HookTask, error) {
task, exist, err := db.Get[HookTask](ctx, builder.Eq{"hook_id": hookID, "uuid": uuid})
task, err := GetHookTaskByUUID(ctx, hookID, uuid)
if err != nil {
return nil, err
} else if !exist {
return nil, ErrHookTaskNotExist{
HookID: hookID,
UUID: uuid,
}
}
return CreateHookTask(ctx, &HookTask{
+17
View File
@@ -277,6 +277,23 @@ func TestHookTasks(t *testing.T) {
hookTasks, err = HookTasks(t.Context(), unittest.NonexistentID, 1)
assert.NoError(t, err)
assert.Empty(t, hookTasks)
tasks, count, err := db.FindAndCount[HookTask](t.Context(), ListHookTaskOptions{HookID: hook.ID})
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
require.Len(t, tasks, 3)
task, err := GetHookTaskByUUID(t.Context(), hook.ID, tasks[0].UUID)
require.NoError(t, err)
assert.Equal(t, tasks[0].ID, task.ID)
tasks, count, err = db.FindAndCount[HookTask](t.Context(), ListHookTaskOptions{HookID: unittest.NonexistentID})
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
assert.Empty(t, tasks)
_, err = GetHookTaskByUUID(t.Context(), hook.ID, "non-existent-uuid")
assert.True(t, IsErrHookTaskNotExist(err))
}
func TestCreateHookTask(t *testing.T) {