todosrht-lmtp: Use GraphQL for comment submission
Use GraphQL to submit comments from incoming emails so that GraphQL-native webhooks are delivered. Also use comments to document internal GraphQL mutations instead of docstrings. References: https://todo.sr.ht/~sircmpwn/sr.ht/315
This commit is contained in:
parent
75a118a77d
commit
10d9d29edb
|
@ -735,14 +735,31 @@ input SubmitTicketInput {
|
|||
externalUrl: String
|
||||
}
|
||||
|
||||
"For internal use only."
|
||||
input SubmitEmailInput {
|
||||
# For internal use only.
|
||||
input SubmitTicketEmailInput {
|
||||
subject: String!
|
||||
body: String
|
||||
senderId: Int!
|
||||
messageId: String!
|
||||
}
|
||||
|
||||
# For internal use only.
|
||||
enum EmailCmd {
|
||||
RESOLVE
|
||||
REOPEN
|
||||
LABEL
|
||||
UNLABEL
|
||||
}
|
||||
|
||||
# For internal use only.
|
||||
input SubmitCommentEmailInput {
|
||||
text: String!
|
||||
senderId: Int!
|
||||
cmd: EmailCmd
|
||||
resolution: TicketResolution
|
||||
labelIds: [Int!]
|
||||
}
|
||||
|
||||
"""
|
||||
You may omit any fields to leave them unchanged. To remove the ticket body,
|
||||
set it to null.
|
||||
|
@ -887,9 +904,13 @@ type Mutation {
|
|||
submitTicket(trackerId: Int!,
|
||||
input: SubmitTicketInput!): Ticket! @access(scope: TICKETS, kind: RW)
|
||||
|
||||
"Creates a new ticket from an incoming email. (For internal use only)"
|
||||
submitEmail(trackerId: Int!,
|
||||
input: SubmitEmailInput!): Ticket! @internal
|
||||
# Creates a new ticket from an incoming email. (For internal use only)
|
||||
submitTicketEmail(trackerId: Int!,
|
||||
input: SubmitTicketEmailInput!): Ticket! @internal
|
||||
|
||||
# Creates a new comment from an incoming email. (For internal use only)
|
||||
submitCommentEmail(trackerId: Int!, ticketId: Int!,
|
||||
input: SubmitCommentEmailInput!): Event! @internal
|
||||
|
||||
"Updates a ticket's subject or body"
|
||||
updateTicket(trackerId: Int!, ticketId: Int!,
|
||||
|
|
|
@ -835,7 +835,7 @@ func (r *mutationResolver) SubmitTicket(ctx context.Context, trackerID int, inpu
|
|||
return &ticket, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SubmitEmail(ctx context.Context, trackerID int, input model.SubmitEmailInput) (*model.Ticket, error) {
|
||||
func (r *mutationResolver) SubmitTicketEmail(ctx context.Context, trackerID int, input model.SubmitTicketEmailInput) (*model.Ticket, error) {
|
||||
validation := valid.New(ctx)
|
||||
validation.Expect(len(input.Subject) <= 2048,
|
||||
"Ticket subject must be fewer than to 2049 characters.").
|
||||
|
@ -946,6 +946,244 @@ func (r *mutationResolver) SubmitEmail(ctx context.Context, trackerID int, input
|
|||
return &ticket, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SubmitCommentEmail(ctx context.Context, trackerID int, ticketID int, input model.SubmitCommentEmailInput) (*model.Event, error) {
|
||||
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if tracker == nil {
|
||||
return nil, fmt.Errorf("No tracker by ID %d found for this user", trackerID)
|
||||
}
|
||||
if !tracker.CanComment() {
|
||||
return nil, fmt.Errorf("Access denied")
|
||||
}
|
||||
|
||||
ticket, err := loaders.ForContext(ctx).
|
||||
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if ticket == nil {
|
||||
return nil, errors.New("No such ticket")
|
||||
}
|
||||
|
||||
owner, err := loaders.ForContext(ctx).UsersByID.Load(tracker.OwnerID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
updateTicket := sq.Update("ticket").
|
||||
PlaceholderFormat(sq.Dollar)
|
||||
insertEvent := sq.Insert("event").
|
||||
PlaceholderFormat(sq.Dollar).
|
||||
Columns("created", "event_type",
|
||||
"ticket_id", "participant_id", "comment_id",
|
||||
"old_status", "new_status", "old_resolution", "new_resolution")
|
||||
|
||||
var (
|
||||
oldStatus *int
|
||||
_oldStatus int
|
||||
newStatus *int
|
||||
_newStatus int
|
||||
oldResolution *int
|
||||
_oldResolution int
|
||||
newResolution *int
|
||||
_newResolution int
|
||||
eventType uint = model.EVENT_COMMENT
|
||||
)
|
||||
|
||||
if input.Cmd != nil {
|
||||
switch *input.Cmd {
|
||||
case model.EmailCmdResolve:
|
||||
if input.Resolution == nil {
|
||||
return nil, errors.New("Resolution is required when cmd is RESOLVE")
|
||||
}
|
||||
eventType |= model.EVENT_STATUS_CHANGE
|
||||
oldStatus = &_oldStatus
|
||||
newStatus = &_newStatus
|
||||
oldResolution = &_oldResolution
|
||||
newResolution = &_newResolution
|
||||
*oldStatus = ticket.Status().ToInt()
|
||||
*oldResolution = ticket.Resolution().ToInt()
|
||||
*newStatus = model.STATUS_RESOLVED
|
||||
*newResolution = input.Resolution.ToInt()
|
||||
updateTicket = updateTicket.
|
||||
Set("status", *newStatus).
|
||||
Set("resolution", *newResolution)
|
||||
case model.EmailCmdReopen:
|
||||
eventType |= model.EVENT_STATUS_CHANGE
|
||||
oldStatus = &_oldStatus
|
||||
newStatus = &_newStatus
|
||||
oldResolution = &_oldResolution
|
||||
newResolution = &_newResolution
|
||||
*oldStatus = ticket.Status().ToInt()
|
||||
*oldResolution = ticket.Resolution().ToInt()
|
||||
*newStatus = model.STATUS_REPORTED
|
||||
*newResolution = model.RESOLVED_UNRESOLVED
|
||||
updateTicket = updateTicket.
|
||||
Set("status", *newStatus).
|
||||
Set("resolution", *newResolution)
|
||||
}
|
||||
}
|
||||
|
||||
var event model.Event
|
||||
columns := database.Columns(ctx, &event)
|
||||
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
_, err := updateTicket.
|
||||
Set(`updated`, sq.Expr(`now() at time zone 'utc'`)).
|
||||
Set(`comment_count`, sq.Expr(`comment_count + 1`)).
|
||||
Where(`ticket.id = ?`, ticket.PKID).
|
||||
RunWith(tx).
|
||||
ExecContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO ticket_comment (
|
||||
created, updated, submitter_id, ticket_id, text
|
||||
) VALUES (
|
||||
NOW() at time zone 'utc',
|
||||
NOW() at time zone 'utc',
|
||||
$1, $2, $3
|
||||
) RETURNING id;
|
||||
`, input.SenderID, ticket.PKID, input.Text)
|
||||
|
||||
var commentID int
|
||||
if err := row.Scan(&commentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if input.Cmd != nil {
|
||||
switch *input.Cmd {
|
||||
case model.EmailCmdLabel:
|
||||
for _, labelID := range input.LabelIds {
|
||||
var event model.Event
|
||||
insertEvent := sq.Insert("event").
|
||||
PlaceholderFormat(sq.Dollar).
|
||||
Columns("created", "event_type", "ticket_id",
|
||||
"participant_id", "label_id").
|
||||
Values(sq.Expr("now() at time zone 'utc'"),
|
||||
model.EVENT_LABEL_ADDED, ticket.PKID, input.SenderID, labelID)
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO ticket_label (
|
||||
created, ticket_id, label_id, user_id
|
||||
) VALUES (
|
||||
NOW() at time zone 'utc',
|
||||
$1, $2,
|
||||
(SELECT user_id FROM participant WHERE id = $3)
|
||||
)`, ticket.PKID, labelID, input.SenderID)
|
||||
if err, ok := err.(*pq.Error); ok &&
|
||||
err.Code == "23505" && // unique_violation
|
||||
err.Constraint == "idx_label_ticket_unique" {
|
||||
return errors.New("This label is already assigned to this ticket.")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := insertEvent.
|
||||
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
||||
RunWith(tx).
|
||||
QueryRowContext(ctx)
|
||||
if err := row.Scan(database.Scan(ctx, &event)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
builder := NewEventBuilder(ctx, tx, input.SenderID, model.EVENT_LABEL_ADDED).
|
||||
WithTicket(tracker, ticket)
|
||||
builder.InsertNotifications(event.ID, nil)
|
||||
_, err = tx.ExecContext(ctx, `DROP TABLE event_participant;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case model.EmailCmdUnlabel:
|
||||
for _, labelID := range input.LabelIds {
|
||||
var event model.Event
|
||||
insertEvent := sq.Insert("event").
|
||||
PlaceholderFormat(sq.Dollar).
|
||||
Columns("created", "event_type", "ticket_id",
|
||||
"participant_id", "label_id").
|
||||
Values(sq.Expr("now() at time zone 'utc'"),
|
||||
model.EVENT_LABEL_REMOVED, ticket.PKID, input.SenderID, labelID)
|
||||
|
||||
{
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
DELETE FROM ticket_label
|
||||
WHERE ticket_id = $1 AND label_id = $2
|
||||
RETURNING 1`,
|
||||
ticket.PKID, labelID)
|
||||
var success bool
|
||||
if err := row.Scan(&success); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return errors.New("This label is not assigned to this ticket.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
row := insertEvent.
|
||||
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
||||
RunWith(tx).
|
||||
QueryRowContext(ctx)
|
||||
if err := row.Scan(database.Scan(ctx, &event)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
builder := NewEventBuilder(ctx, tx, input.SenderID, model.EVENT_LABEL_REMOVED).
|
||||
WithTicket(tracker, ticket)
|
||||
builder.InsertNotifications(event.ID, nil)
|
||||
_, err = tx.ExecContext(ctx, `DROP TABLE event_participant;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder := NewEventBuilder(ctx, tx, input.SenderID, eventType).
|
||||
WithTicket(tracker, ticket)
|
||||
|
||||
mentions := ScanMentions(ctx, tracker, ticket, input.Text)
|
||||
builder.AddMentions(&mentions)
|
||||
builder.InsertSubscriptions()
|
||||
|
||||
eventRow := insertEvent.Values(sq.Expr("now() at time zone 'utc'"),
|
||||
eventType, ticket.PKID, input.SenderID, commentID,
|
||||
oldStatus, newStatus, oldResolution, newResolution).
|
||||
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
||||
RunWith(tx).
|
||||
QueryRowContext(ctx)
|
||||
if err := eventRow.Scan(database.Scan(ctx, &event)...); err != nil {
|
||||
return err
|
||||
}
|
||||
builder.InsertNotifications(event.ID, &commentID)
|
||||
|
||||
conf := config.ForContext(ctx)
|
||||
origin := config.GetOrigin(conf, "todo.sr.ht", true)
|
||||
details := SubmitCommentDetails{
|
||||
Root: origin,
|
||||
TicketURL: fmt.Sprintf("/%s/%s/%d",
|
||||
owner.CanonicalName(), tracker.Name, ticket.ID),
|
||||
EventID: event.ID,
|
||||
Comment: input.Text,
|
||||
StatusUpdated: newStatus != nil,
|
||||
}
|
||||
if details.StatusUpdated {
|
||||
details.Status = model.TicketStatusFromInt(*newStatus).String()
|
||||
details.Resolution = model.TicketResolutionFromInt(*newResolution).String()
|
||||
}
|
||||
subject := fmt.Sprintf("Re: %s: %s", ticket.Ref(), ticket.Subject)
|
||||
builder.SendEmails(subject, submitCommentTemplate, &details)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
|
||||
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
|
||||
webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event)
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) UpdateTicket(ctx context.Context, trackerID int, ticketID int, input map[string]interface{}) (*model.Ticket, error) {
|
||||
if _, ok := input["import"]; ok {
|
||||
panic(fmt.Errorf("not implemented")) // TODO
|
||||
|
|
|
@ -13,7 +13,6 @@ from todosrht.access import get_tracker, get_ticket
|
|||
from todosrht.types import TicketAccess, TicketResolution, Tracker, Ticket, User
|
||||
from todosrht.types import Label, TicketLabel, TicketSubscription, Event, EventType, ParticipantType
|
||||
from todosrht.tickets import add_comment, get_participant_for_email
|
||||
from todosrht.webhooks import UserWebhook, TrackerWebhook, TicketWebhook
|
||||
from srht.validation import Validation
|
||||
import asyncio
|
||||
import email
|
||||
|
@ -150,8 +149,8 @@ class MailHandler:
|
|||
}
|
||||
|
||||
resp = exec_gql("todo.sr.ht", """
|
||||
mutation SubmitEmail($trackerId: Int!, $input: SubmitEmailInput!) {
|
||||
submitEmail(trackerId: $trackerId, input: $input) {
|
||||
mutation SubmitTicketEmail($trackerId: Int!, $input: SubmitTicketEmailInput!) {
|
||||
submitTicketEmail(trackerId: $trackerId, input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
@ -161,11 +160,8 @@ class MailHandler:
|
|||
print("Rejecting email due to validation errors")
|
||||
return "550 " + ", ".join([e.message for e in valid.errors])
|
||||
|
||||
ticket, _ = get_ticket(tracker, resp["submitEmail"]["id"], user=sender.user)
|
||||
ticket, _ = get_ticket(tracker, resp["submitTicketEmail"]["id"], user=sender.user)
|
||||
|
||||
TrackerWebhook.deliver(TrackerWebhook.Events.ticket_create,
|
||||
ticket.to_dict(),
|
||||
TrackerWebhook.Subscription.tracker_id == tracker.id)
|
||||
print(f"Created ticket {ticket.ref()}")
|
||||
return "250 Message accepted for delivery"
|
||||
|
||||
|
@ -173,22 +169,34 @@ class MailHandler:
|
|||
required_access = TicketAccess.comment
|
||||
last_line = body.splitlines()[-1]
|
||||
|
||||
resolution = None
|
||||
resolve = reopen = False
|
||||
# Need to commit in case a new participant was created
|
||||
db.session.commit()
|
||||
|
||||
valid = Validation({})
|
||||
|
||||
input = {
|
||||
"text": body,
|
||||
"senderId": sender.id,
|
||||
}
|
||||
|
||||
cmds = ["!resolve", "!resolved", "!reopen"]
|
||||
if sender.participant_type == ParticipantType.user:
|
||||
# TODO: This should be possible via ACLs later
|
||||
cmds += ["!assign", "!label", "!unlabel"]
|
||||
if any(last_line.startswith(cmd) for cmd in cmds):
|
||||
cmd = shlex.split(last_line)
|
||||
body = body[:-len(last_line)-1].rstrip()
|
||||
input["text"] = body.rstrip()[:-len(last_line)-1].rstrip()
|
||||
required_access = TicketAccess.triage
|
||||
if cmd[0] in ["!resolve", "!resolved"] and len(cmd) == 2:
|
||||
resolve = True
|
||||
resolution = TicketResolution[cmd[1].lower()]
|
||||
input["cmd"] = "RESOLVE"
|
||||
input["resolution"] = cmd[1].upper()
|
||||
elif cmd[0] == "!reopen":
|
||||
reopen = True
|
||||
input["cmd"] = "REOPEN"
|
||||
elif cmd[0] == "!label" or cmd[0] == "!unlabel":
|
||||
if cmd[0] == "!label":
|
||||
input["cmd"] = "LABEL"
|
||||
else:
|
||||
input["cmd"] = "UNLABEL"
|
||||
labels = Label.query.filter(
|
||||
Label.name.in_(cmd[1:]),
|
||||
Label.tracker_id == ticket.tracker_id).all()
|
||||
|
@ -199,33 +207,7 @@ class MailHandler:
|
|||
print(f"Rejected, {sender.name} has insufficient " +
|
||||
f"permissions (have {access}, want triage)")
|
||||
return "550 You do not have permission to triage on this tracker."
|
||||
for label in labels:
|
||||
ticket_label = (TicketLabel.query
|
||||
.filter(TicketLabel.label_id == label.id)
|
||||
.filter(TicketLabel.ticket_id == ticket.id)).first()
|
||||
event = Event()
|
||||
event.participant_id = sender.id
|
||||
event.ticket_id = ticket.id
|
||||
event.label_id = label.id
|
||||
if not ticket_label and cmd[0] == "!label":
|
||||
# TODO: only supported for user participants
|
||||
ticket_label = TicketLabel()
|
||||
ticket_label.ticket_id = ticket.id
|
||||
ticket_label.label_id = label.id
|
||||
ticket_label.user_id = sender.id
|
||||
db.session.add(ticket_label)
|
||||
event.event_type = EventType.label_added
|
||||
elif ticket_label and cmd[0] == "!unlabel":
|
||||
db.session.delete(ticket_label)
|
||||
event.event_type = EventType.label_removed
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
TicketWebhook.deliver(TicketWebhook.Events.event_create,
|
||||
event.to_dict(),
|
||||
TicketWebhook.Subscription.ticket_id == ticket.id)
|
||||
TrackerWebhook.deliver(TrackerWebhook.Events.event_create,
|
||||
event.to_dict(),
|
||||
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
|
||||
input["labelIds"] = [label.id for label in labels]
|
||||
# TODO: Remaining commands
|
||||
|
||||
if not required_access in access:
|
||||
|
@ -237,14 +219,18 @@ class MailHandler:
|
|||
print("Rejected, invalid comment length")
|
||||
return "550 Comment must be between 3 and 16384 characters."
|
||||
|
||||
event = add_comment(sender, ticket, text=body,
|
||||
resolution=resolution, resolve=resolve, reopen=reopen, from_email=True)
|
||||
TicketWebhook.deliver(TicketWebhook.Events.event_create,
|
||||
event.to_dict(),
|
||||
TicketWebhook.Subscription.ticket_id == ticket.id)
|
||||
TrackerWebhook.deliver(TrackerWebhook.Events.event_create,
|
||||
event.to_dict(),
|
||||
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
|
||||
resp = exec_gql("todo.sr.ht", """
|
||||
mutation SubmitCommentEmail($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentEmailInput!) {
|
||||
submitCommentEmail(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
""", user=ticket.tracker.owner, valid=valid, trackerId=ticket.tracker_id, ticketId=ticket.scoped_id, input=input)
|
||||
|
||||
if not valid.ok:
|
||||
print("Rejecting email due to validation errors")
|
||||
return "550 " + ", ".join([e.message for e in valid.errors])
|
||||
|
||||
print(f"Added comment to {ticket.ref()}")
|
||||
return "250 Message accepted for delivery"
|
||||
|
||||
|
|
Loading…
Reference in New Issue