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
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+238
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user