From 96bee6c5a54f8fb8e072ac4d1a02ee72d6a78c1f Mon Sep 17 00:00:00 2001 From: Mitrahsoft Date: Sat, 24 Oct 2026 05:48:51 +0530 Subject: [PATCH] feat(api): add REST API endpoints for repository webhook delivery history (#38775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: silverwind --- models/webhook/hooktask.go | 33 ++- models/webhook/webhook_test.go | 17 ++ modules/structs/hook.go | 44 ++++ routers/api/v1/api.go | 4 + routers/api/v1/repo/hook.go | 122 ++++++++++ routers/api/v1/swagger/repo.go | 14 ++ services/webhook/general.go | 37 +++ services/webhook/general_test.go | 36 +++ templates/swagger/v1-openapi3.generated.json | 238 +++++++++++++++++++ templates/swagger/v1-swagger.generated.json | 217 +++++++++++++++++ tests/integration/api_repo_hook_test.go | 68 ++++++ 11 files changed, 824 insertions(+), 6 deletions(-) diff --git a/models/webhook/hooktask.go b/models/webhook/hooktask.go index 2c60e34227d..618e80e5756 100644 --- a/models/webhook/hooktask.go +++ b/models/webhook/hooktask.go @@ -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{ diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index d9b263c96aa..ef8294565b4 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -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) { diff --git a/modules/structs/hook.go b/modules/structs/hook.go index afa3f27b610..217293adf61 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -46,6 +46,50 @@ type Hook struct { // HookList represents a list of API hook. type HookList []*Hook +// HookDeliveryRequest represents the request information of a webhook delivery +type HookDeliveryRequest struct { + // The URL the delivery was sent to + URL string `json:"url"` + // The HTTP method used for the delivery + HTTPMethod string `json:"http_method"` + // The headers sent with the delivery + Headers map[string]string `json:"headers"` + // The body sent with the delivery + Body string `json:"body"` +} + +// HookDeliveryResponse represents the response information of a webhook delivery +type HookDeliveryResponse struct { + // The HTTP status code of the response + Status int `json:"status"` + // The headers received in the response + Headers map[string]string `json:"headers"` + // The body received in the response + Body string `json:"body"` +} + +// HookDelivery represents a single delivery attempt for a webhook +type HookDelivery struct { + // The unique identifier of the delivery + UUID string `json:"uuid"` + // The event type that triggered this delivery + EventType string `json:"event_type"` + // Whether the delivery has been sent + IsDelivered bool `json:"is_delivered"` + // Whether the delivery was successful (2xx response) + IsSucceeded bool `json:"is_succeeded"` + // swagger:strfmt date-time + // The date and time when the delivery was sent + Delivered *time.Time `json:"delivered_at"` + // The request that was sent + Request *HookDeliveryRequest `json:"request"` + // The response that was received + Response *HookDeliveryResponse `json:"response"` +} + +// HookDeliveryList represents a list of API hook deliveries. +type HookDeliveryList []*HookDelivery + // CreateHookOptionConfig has all config options in it // required are "content_type" and "url" Required type CreateHookOptionConfig map[string]string diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 6cebb1d1842..3f5208ce210 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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() { diff --git a/routers/api/v1/repo/hook.go b/routers/api/v1/repo/hook.go index a4aafa4f516..3796d61aed0 100644 --- a/routers/api/v1/repo/hook.go +++ b/routers/api/v1/repo/hook.go @@ -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 diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 50dbb91d3b1..dcdfe062c9c 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -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 { diff --git a/services/webhook/general.go b/services/webhook/general.go index 0d7af67ae86..32f5c209c06 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -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 +} diff --git a/services/webhook/general_test.go b/services/webhook/general_test.go index f0e17501bbb..43649f2af5c 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -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", diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index a34121640ff..6ac0d2556a1 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -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", diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index a3abc69fb6a..e1a8a055991 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -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": { diff --git a/tests/integration/api_repo_hook_test.go b/tests/integration/api_repo_hook_test.go index e92bbd31ad3..25639ddefff 100644 --- a/tests/integration/api_repo_hook_test.go +++ b/tests/integration/api_repo_hook_test.go @@ -12,10 +12,13 @@ import ( repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" + webhook_model "gitea.dev/models/webhook" api "gitea.dev/modules/structs" + webhook_module "gitea.dev/modules/webhook" "gitea.dev/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPICreateHook(t *testing.T) { @@ -96,3 +99,68 @@ func TestAPICreateHook(t *testing.T) { cleared := DecodeJSON(t, clearResp, &api.Hook{}) assert.Empty(t, cleared.Name) } + +func TestAPIListAndGetRepoHookDeliveries(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + hook := DecodeJSON(t, MakeRequest(t, NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/hooks", owner.Name, repo.Name), api.CreateHookOption{ + Type: "gitea", + Config: api.CreateHookOptionConfig{ + "content_type": "json", + "url": "http://example.com/", + }, + }).AddTokenAuth(token), http.StatusCreated), &api.Hook{}) + + tasks := make([]*webhook_model.HookTask, 0, 4) + for i := range 4 { + task, err := webhook_model.CreateHookTask(t.Context(), &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadVersion: 2, + IsDelivered: i < 3, + IsSucceed: i < 3, + }) + require.NoError(t, err) + tasks = append(tasks, task) + } + + deliveriesURL := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d/deliveries", owner.Name, repo.Name, hook.ID) + deliveries := DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", deliveriesURL).AddTokenAuth(token), http.StatusOK), &api.HookDeliveryList{}) + require.Len(t, *deliveries, 4) + assert.Equal(t, tasks[3].UUID, (*deliveries)[0].UUID) + assert.False(t, (*deliveries)[0].IsDelivered) + assert.Nil(t, (*deliveries)[0].Response) + assert.Nil(t, (*deliveries)[0].Delivered) + + for page, expectedUUIDs := range [][]string{{tasks[3].UUID, tasks[2].UUID}, {tasks[1].UUID, tasks[0].UUID}} { + t.Run(fmt.Sprintf("page_%d", page+1), func(t *testing.T) { + resp := MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("%s?limit=2&page=%d", deliveriesURL, page+1)).AddTokenAuth(token), http.StatusOK) + deliveries := DecodeJSON(t, resp, &api.HookDeliveryList{}) + require.Len(t, *deliveries, len(expectedUUIDs)) + assert.Equal(t, "4", resp.Header().Get("X-Total-Count")) + for i, uuid := range expectedUUIDs { + assert.Equal(t, uuid, (*deliveries)[i].UUID) + } + }) + } + + delivery := DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", deliveriesURL+"/"+tasks[0].UUID).AddTokenAuth(token), http.StatusOK), &api.HookDelivery{}) + assert.Equal(t, tasks[0].UUID, delivery.UUID) + assert.True(t, delivery.IsDelivered) + assert.True(t, delivery.IsSucceeded) + assert.NotNil(t, delivery.Delivered) + + MakeRequest(t, NewRequest(t, "GET", deliveriesURL+"/does-not-exist").AddTokenAuth(token), http.StatusNotFound) + + otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + otherOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: otherRepo.OwnerID}) + for _, suffix := range []string{"", "/" + tasks[0].UUID} { + MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d/deliveries%s", otherOwner.Name, otherRepo.Name, hook.ID, suffix)). + AddTokenAuth(token), http.StatusNotFound) + } +}