mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09:00
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:
11 files changed
+824
-6
No files matched your search
@@ -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,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user