api/graph: Implement GraphQL-native ticket webhooks

Implement GraphQL-native ticket webhooks for ticket CRUD operations.
This commit is contained in:
Adnan Maolood 2022-04-28 10:04:23 -04:00 committed by Drew DeVault
parent f495ccdf32
commit 9a205600aa
5 changed files with 561 additions and 0 deletions

View File

@ -338,3 +338,112 @@ func (sub *TrackerWebhookSubscription) QueryWithCursor(ctx context.Context,
return subs, cur
}
type TicketWebhookSubscription struct {
ID int `json:"id"`
Events []WebhookEvent `json:"events"`
Query string `json:"query"`
URL string `json:"url"`
TrackerID int `json:"trackerId"`
TicketID int `json:"ticketId"`
UserID int
AuthMethod string
ClientID *string
TokenHash *string
Expires *time.Time
Grants *string
NodeID *string
alias string
fields *database.ModelFields
}
func (TicketWebhookSubscription) IsWebhookSubscription() {}
func (sub *TicketWebhookSubscription) As(alias string) *TicketWebhookSubscription {
sub.alias = alias
return sub
}
func (sub *TicketWebhookSubscription) Alias() string {
return sub.alias
}
func (sub *TicketWebhookSubscription) Table() string {
return "gql_ticket_wh_sub"
}
func (sub *TicketWebhookSubscription) Fields() *database.ModelFields {
if sub.fields != nil {
return sub.fields
}
sub.fields = &database.ModelFields{
Fields: []*database.FieldMap{
{"events", "events", pq.Array(&sub.Events)},
{"url", "url", &sub.URL},
// Always fetch:
{"id", "", &sub.ID},
{"query", "", &sub.Query},
{"user_id", "", &sub.UserID},
{"auth_method", "", &sub.AuthMethod},
{"token_hash", "", &sub.TokenHash},
{"client_id", "", &sub.ClientID},
{"grants", "", &sub.Grants},
{"expires", "", &sub.Expires},
{"node_id", "", &sub.NodeID},
{"tracker_id", "", &sub.TrackerID},
{"scoped_id", "", &sub.TicketID},
},
}
return sub.fields
}
func (sub *TicketWebhookSubscription) QueryWithCursor(ctx context.Context,
runner sq.BaseRunner, q sq.SelectBuilder,
cur *model.Cursor) ([]WebhookSubscription, *model.Cursor) {
var (
err error
rows *sql.Rows
)
if cur.Next != "" {
next, _ := strconv.ParseInt(cur.Next, 10, 64)
q = q.Where(database.WithAlias(sub.alias, "id")+"<= ?", next)
}
q = q.
OrderBy(database.WithAlias(sub.alias, "id")).
Limit(uint64(cur.Count + 1))
if rows, err = q.RunWith(runner).QueryContext(ctx); err != nil {
panic(err)
}
defer rows.Close()
var (
subs []WebhookSubscription
lastID int
)
for rows.Next() {
var sub TicketWebhookSubscription
if err := rows.Scan(database.Scan(ctx, &sub)...); err != nil {
panic(err)
}
subs = append(subs, &sub)
lastID = sub.ID
}
if len(subs) > cur.Count {
cur = &model.Cursor{
Count: cur.Count,
Next: strconv.Itoa(lastID),
Search: cur.Search,
}
subs = subs[:cur.Count]
} else {
cur = nil
}
return subs, cur
}

View File

@ -208,6 +208,18 @@ type TrackerWebhookSubscription implements WebhookSubscription {
tracker: Tracker!
}
type TicketWebhookSubscription implements WebhookSubscription {
id: Int!
events: [WebhookEvent!]!
query: String!
url: String!
client: OAuthClient @private
deliveries(cursor: Cursor): WebhookDeliveryCursor!
sample(event: WebhookEvent!): String!
ticket: Ticket!
}
type WebhookDelivery {
uuid: String!
date: Time!
@ -335,6 +347,18 @@ type Ticket {
subscription.
"""
subscription: TicketSubscription @access(scope: SUBSCRIPTIONS, kind: RO)
"""
Returns a list of ticket webhook subscriptions. For clients
authenticated with a personal access token, this returns all webhooks
configured by all GraphQL clients for your account. For clients
authenticated with an OAuth 2.0 access token, this returns only webhooks
registered for your client.
"""
webhooks(cursor: Cursor): WebhookSubscriptionCursor!
"Returns details of a ticket webhook subscription by its ID."
webhook(id: Int!): WebhookSubscription
}
interface ACL {
@ -779,6 +803,12 @@ input TrackerWebhookInput {
query: String!
}
input TicketWebhookInput {
url: String!
events: [WebhookEvent!]!
query: String!
}
type Mutation {
"""
Creates a new bug tracker. If specified, the 'import' field specifies a
@ -928,4 +958,10 @@ type Mutation {
"Deletes a tracker webhook."
deleteTrackerWebhook(id: Int!): WebhookSubscription
"Creates a new ticket webhook."
createTicketWebhook(trackerId: Int!, ticketId: Int!, config: TicketWebhookInput!): WebhookSubscription!
"Deletes a ticket webhook."
deleteTicketWebhook(id: Int!): WebhookSubscription
}

View File

@ -1007,6 +1007,7 @@ func (r *mutationResolver) UpdateTicket(ctx context.Context, trackerID int, tick
}
webhooks.DeliverTrackerTicketEvent(ctx, model.WebhookEventTicketUpdate, ticket.TrackerID, ticket)
webhooks.DeliverTicketEvent(ctx, model.WebhookEventTicketUpdate, ticket.PKID, ticket)
return ticket, nil
}
@ -1119,7 +1120,9 @@ func (r *mutationResolver) UpdateTicketStatus(ctx context.Context, trackerID int
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerTicketEvent(ctx, model.WebhookEventTicketUpdate, ticket.TrackerID, ticket)
webhooks.DeliverTicketEvent(ctx, model.WebhookEventTicketUpdate, ticket.PKID, ticket)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event)
return &event, nil
}
@ -1276,6 +1279,7 @@ func (r *mutationResolver) SubmitComment(ctx context.Context, trackerID int, tic
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event)
return &event, nil
}
@ -1403,6 +1407,7 @@ func (r *mutationResolver) AssignUser(ctx context.Context, trackerID int, ticket
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event)
return &event, nil
}
@ -1527,6 +1532,7 @@ func (r *mutationResolver) UnassignUser(ctx context.Context, trackerID int, tick
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event)
return &event, nil
}
@ -1615,6 +1621,7 @@ func (r *mutationResolver) LabelTicket(ctx context.Context, trackerID int, ticke
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event)
return &event, nil
}
@ -1696,6 +1703,7 @@ func (r *mutationResolver) UnlabelTicket(ctx context.Context, trackerID int, tic
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event)
return &event, nil
}
@ -1916,6 +1924,119 @@ func (r *mutationResolver) DeleteTrackerWebhook(ctx context.Context, id int) (mo
return &sub, nil
}
func (r *mutationResolver) CreateTicketWebhook(ctx context.Context, trackerID int, ticketID int, config model.TicketWebhookInput) (model.WebhookSubscription, error) {
schema := server.ForContext(ctx).Schema
if err := corewebhooks.Validate(schema, config.Query); err != nil {
return nil, err
}
user := auth.ForContext(ctx)
ac, err := corewebhooks.NewAuthConfig(ctx)
if err != nil {
return nil, err
}
var sub model.TicketWebhookSubscription
if len(config.Events) == 0 {
return nil, fmt.Errorf("Must specify at least one event")
}
events := make([]string, len(config.Events))
for i, ev := range config.Events {
events[i] = ev.String()
// TODO: gqlgen does not support doing anything useful with directives
// on enums at the time of writing, so we have to do a little bit of
// manual fuckery
var access string
switch ev {
case model.WebhookEventTicketUpdate:
access = "TICKETS"
case model.WebhookEventEventCreated:
access = "EVENTS"
default:
return nil, fmt.Errorf("Unsupported event %s", ev.String())
}
if !user.Grants.Has(access, auth.RO) {
return nil, fmt.Errorf("Insufficient access granted for webhook event %s", ev.String())
}
}
u, err := url.Parse(config.URL)
if err != nil {
return nil, err
} else if u.Host == "" {
return nil, fmt.Errorf("Cannot use URL without host")
} else if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("Cannot use non-HTTP or HTTPS URL")
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
WITH tk AS (
SELECT id FROM ticket WHERE tracker_id = $11 AND scoped_id = $12
)
INSERT INTO gql_ticket_wh_sub (
created, events, url, query,
auth_method,
token_hash, grants, client_id, expires,
node_id,
user_id,
tracker_id,
scoped_id,
ticket_id
) VALUES (
NOW() at time zone 'utc',
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (SELECT id FROM tk)
) RETURNING id, url, query, events, user_id, tracker_id, scoped_id;`,
pq.Array(events), config.URL, config.Query,
ac.AuthMethod,
ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2
ac.NodeID, // INTERNAL
user.UserID,
trackerID,
ticketID)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID, &sub.TrackerID, &sub.TicketID); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &sub, nil
}
func (r *mutationResolver) DeleteTicketWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
var sub model.TicketWebhookSubscription
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := sq.Delete(`gql_ticket_wh_sub`).
PlaceholderFormat(sq.Dollar).
Where(sq.And{sq.Expr(`id = ?`, id), filter}).
Suffix(`RETURNING id, url, query, events, user_id, tracker_id, scoped_id`).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID, &sub.TrackerID, &sub.TicketID); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("No tracker webhook by ID %d found for this user", id)
}
return nil, err
}
return &sub, nil
}
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
return &model.Version{
Major: 0,
@ -2216,6 +2337,71 @@ func (r *ticketResolver) Subscription(ctx context.Context, obj *model.Ticket) (*
return loaders.ForContext(ctx).SubsByTicketIDUnsafe.Load(obj.PKID)
}
func (r *ticketResolver) Webhooks(ctx context.Context, obj *model.Ticket, cursor *coremodel.Cursor) (*model.WebhookSubscriptionCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
var subs []model.WebhookSubscription
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
sub := (&model.TicketWebhookSubscription{}).As(`sub`)
query := database.
Select(ctx, sub).
From(`gql_ticket_wh_sub sub`).
Where(sq.And{sq.Expr(`ticket_id = ?`, obj.PKID), filter})
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookSubscriptionCursor{subs, cursor}, nil
}
func (r *ticketResolver) Webhook(ctx context.Context, obj *model.Ticket, id int) (model.WebhookSubscription, error) {
var sub model.TicketWebhookSubscription
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
row := database.
Select(ctx, &sub).
From(`gql_ticket_wh_sub`).
Where(sq.And{
sq.Expr(`id = ?`, id),
sq.Expr(`ticket_id = ?`, obj.PKID),
filter,
}).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(database.Scan(ctx, &sub)...); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &sub, nil
}
func (r *ticketMentionResolver) Ticket(ctx context.Context, obj *model.TicketMention) (*model.Ticket, error) {
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
}
@ -2232,6 +2418,126 @@ func (r *ticketSubscriptionResolver) Ticket(ctx context.Context, obj *model.Tick
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
}
func (r *ticketWebhookSubscriptionResolver) Client(ctx context.Context, obj *model.TicketWebhookSubscription) (*model.OAuthClient, error) {
if obj.ClientID == nil {
return nil, nil
}
return &model.OAuthClient{
UUID: *obj.ClientID,
}, nil
}
func (r *ticketWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.TicketWebhookSubscription, cursor *coremodel.Cursor) (*model.WebhookDeliveryCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
var deliveries []*model.WebhookDelivery
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
d := (&model.WebhookDelivery{}).
WithName(`ticket`).
As(`delivery`)
query := database.
Select(ctx, d).
From(`gql_ticket_wh_delivery delivery`).
Where(`delivery.subscription_id = ?`, obj.ID)
deliveries, cursor = d.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookDeliveryCursor{deliveries, cursor}, nil
}
func (r *ticketWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.TicketWebhookSubscription, event model.WebhookEvent) (string, error) {
payloadUUID := uuid.New()
webhook := corewebhooks.WebhookContext{
User: auth.ForContext(ctx),
PayloadUUID: payloadUUID,
Name: "ticket",
Event: event.String(),
Subscription: &corewebhooks.WebhookSubscription{
ID: obj.ID,
URL: obj.URL,
Query: obj.Query,
AuthMethod: obj.AuthMethod,
TokenHash: obj.TokenHash,
Grants: obj.Grants,
ClientID: obj.ClientID,
Expires: obj.Expires,
NodeID: obj.NodeID,
},
}
auth := auth.ForContext(ctx)
switch event {
case model.WebhookEventTicketUpdate:
body := "This is a sample ticket body."
webhook.Payload = &model.TicketEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Ticket: &model.Ticket{
ID: 1,
Created: time.Now().UTC(),
Updated: time.Now().UTC(),
Subject: "A sample ticket",
Body: &body,
PKID: -1,
TrackerID: -1,
TrackerName: "sample-tracker",
OwnerName: auth.Username,
SubmitterID: -1,
RawAuthenticity: model.AUTH_AUTHENTIC,
RawStatus: model.STATUS_REPORTED,
RawResolution: model.RESOLVED_UNRESOLVED,
},
}
case model.WebhookEventEventCreated:
oldStatus := model.STATUS_REPORTED
newStatus := model.STATUS_RESOLVED
oldResolution := model.RESOLVED_UNRESOLVED
newResolution := model.RESOLVED_FIXED
webhook.Payload = &model.EventCreated{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
NewEvent: &model.Event{
ID: -1,
Created: time.Now().UTC(),
EventType: model.EVENT_STATUS_CHANGE,
ParticipantID: -1,
TicketID: -1,
ByParticipantID: nil,
CommentID: nil,
LabelID: nil,
FromTicketID: nil,
OldStatus: &oldStatus,
NewStatus: &newStatus,
OldResolution: &oldResolution,
NewResolution: &newResolution,
},
}
default:
return "", fmt.Errorf("Unsupported event %s", event.String())
}
subctx := corewebhooks.Context(ctx, webhook.Payload)
bytes, err := webhook.Exec(subctx, server.ForContext(ctx).Schema)
if err != nil {
return "", err
}
return string(bytes), nil
}
func (r *ticketWebhookSubscriptionResolver) Ticket(ctx context.Context, obj *model.TicketWebhookSubscription) (*model.Ticket, error) {
return loaders.ForContext(ctx).TicketsByTrackerID.Load([2]int{obj.TrackerID, obj.TicketID})
}
func (r *trackerResolver) Owner(ctx context.Context, obj *model.Tracker) (model.Entity, error) {
return loaders.ForContext(ctx).UsersByID.Load(obj.OwnerID)
}
@ -2726,6 +3032,8 @@ func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.W
subscription = (&model.UserWebhookSubscription{}).As(`sub`)
case "tracker":
subscription = (&model.TrackerWebhookSubscription{}).As(`sub`)
case "ticket":
subscription = (&model.TicketWebhookSubscription{}).As(`sub`)
default:
panic(fmt.Errorf("unknown webhook name %q", obj.Name))
}
@ -2786,6 +3094,11 @@ func (r *Resolver) TicketSubscription() api.TicketSubscriptionResolver {
return &ticketSubscriptionResolver{r}
}
// TicketWebhookSubscription returns api.TicketWebhookSubscriptionResolver implementation.
func (r *Resolver) TicketWebhookSubscription() api.TicketWebhookSubscriptionResolver {
return &ticketWebhookSubscriptionResolver{r}
}
// Tracker returns api.TrackerResolver implementation.
func (r *Resolver) Tracker() api.TrackerResolver { return &trackerResolver{r} }
@ -2828,6 +3141,7 @@ type statusChangeResolver struct{ *Resolver }
type ticketResolver struct{ *Resolver }
type ticketMentionResolver struct{ *Resolver }
type ticketSubscriptionResolver struct{ *Resolver }
type ticketWebhookSubscriptionResolver struct{ *Resolver }
type trackerResolver struct{ *Resolver }
type trackerACLResolver struct{ *Resolver }
type trackerSubscriptionResolver struct{ *Resolver }

View File

@ -36,6 +36,18 @@ func deliverTrackerWebhook(ctx context.Context, trackerID int,
payloadUUID, payload)
}
func deliverTicketWebhook(ctx context.Context, ticketID int,
event model.WebhookEvent, payload model.WebhookPayload, payloadUUID uuid.UUID) {
q := webhooks.ForContext(ctx)
userID := auth.ForContext(ctx).UserID
query := sq.
Select().
From("gql_ticket_wh_sub sub").
Where("sub.user_id = ? AND sub.ticket_id = ?", userID, ticketID)
q.Schedule(ctx, query, "ticket", event.String(),
payloadUUID, payload)
}
func DeliverUserTrackerEvent(ctx context.Context,
event model.WebhookEvent, tracker *model.Tracker) {
payloadUUID := uuid.New()
@ -107,3 +119,27 @@ func DeliverTrackerEventCreated(ctx context.Context, trackerID int, newEvent *mo
}
deliverTrackerWebhook(ctx, trackerID, event, &payload, payloadUUID)
}
func DeliverTicketEvent(ctx context.Context,
event model.WebhookEvent, ticketID int, ticket *model.Ticket) {
payloadUUID := uuid.New()
payload := model.TicketEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Ticket: ticket,
}
deliverTicketWebhook(ctx, ticketID, event, &payload, payloadUUID)
}
func DeliverTicketEventCreated(ctx context.Context, ticketID int, newEvent *model.Event) {
event := model.WebhookEventEventCreated
payloadUUID := uuid.New()
payload := model.EventCreated{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
NewEvent: newEvent,
}
deliverTicketWebhook(ctx, ticketID, event, &payload, payloadUUID)
}

View File

@ -0,0 +1,66 @@
"""Add GraphQL ticket webhook tables
Revision ID: 7c117bc9e99b
Revises: 87daab81985b
Create Date: 2022-04-27 09:16:21.180937
"""
# revision identifiers, used by Alembic.
revision = '7c117bc9e99b'
down_revision = '87daab81985b'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.execute("""
CREATE TYPE ticket_webhook_event AS ENUM (
'TICKET_UPDATE',
'EVENT_CREATED'
);
CREATE TABLE gql_ticket_wh_sub (
id serial PRIMARY KEY,
created timestamp NOT NULL,
events ticket_webhook_event[] NOT NULL check (array_length(events, 1) > 0),
url varchar NOT NULL,
query varchar NOT NULL,
auth_method auth_method NOT NULL check (auth_method in ('OAUTH2', 'INTERNAL')),
token_hash varchar(128) check ((auth_method = 'OAUTH2') = (token_hash IS NOT NULL)),
grants varchar,
client_id uuid,
expires timestamp check ((auth_method = 'OAUTH2') = (expires IS NOT NULL)),
node_id varchar check ((auth_method = 'INTERNAL') = (node_id IS NOT NULL)),
user_id integer NOT NULL references "user"(id),
tracker_id integer NOT NULL references "tracker"(id) ON DELETE CASCADE,
ticket_id integer NOT NULL references "ticket"(id) ON DELETE CASCADE,
scoped_id integer NOT NULL
);
CREATE INDEX gql_ticket_wh_sub_token_hash_idx ON gql_ticket_wh_sub (token_hash);
CREATE TABLE gql_ticket_wh_delivery (
id serial PRIMARY KEY,
uuid uuid NOT NULL,
date timestamp NOT NULL,
event ticket_webhook_event NOT NULL,
subscription_id integer NOT NULL references gql_ticket_wh_sub(id) ON DELETE CASCADE,
request_body varchar NOT NULL,
response_body varchar,
response_headers varchar,
response_status integer
);
""")
def downgrade():
op.execute("""
DROP TABLE gql_ticket_wh_delivery;
DROP INDEX gql_ticket_wh_sub_token_hash_idx;
DROP TABLE gql_ticket_wh_sub;
DROP TYPE ticket_webhook_event;
""")