api: Implement tracker dump signature verification

Changes the ticket and comment signature format to remove useless
fields.

Also updates the frontend to use the GraphQL API for tracker dump
imports.
master 0.74.0
Adnan Maolood 3 months ago committed by Drew DeVault
parent 9f4e4b5a0b
commit fc70ae13a4

@ -7,65 +7,51 @@ import (
"errors"
"fmt"
"io"
"log"
"strings"
"time"
"git.sr.ht/~sircmpwn/core-go/crypto"
"git.sr.ht/~sircmpwn/core-go/database"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/loaders"
)
type TrackerDump struct {
Owner Owner `json:"owner"`
Name string `json:"name"`
Labels []Label `json:"labels"`
Tickets []Ticket `json:"tickets"`
}
type Owner struct {
CanonicalName string `json:"canonical_name"`
Name string `json:"name"`
Email string `json:"email"`
URL string `json:"url"`
Location string `json:"location"`
Bio string `json:"bio"`
ID int `json:"id"`
Owner User `json:"owner"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Name string `json:"name"`
Description string `json:"description"`
Labels []Label `json:"labels"`
Tickets []Ticket `json:"tickets"`
}
type Label struct {
Name string `json:"name"`
Colors struct {
Background string `json:"background"`
Foreground string `json:"text"`
} `json:"colors"`
Created time.Time `json:"created"`
Tracker Tracker `json:"tracker"`
}
type Tracker struct {
ID int `json:"id"`
Owner User `json:"owner"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Name string `json:"name"`
ID int `json:"id"`
Created time.Time `json:"created"`
Name string `json:"name"`
BackgroundColor string `json:"background_color"`
ForegroundColor string `json:"foreground_color"`
}
type Ticket struct {
ID int `json:"id"`
Ref string `json:"ref"`
Tracker Tracker `json:"tracker"`
Subject string `json:"title"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Submitter *Participant `json:"submitter"` // null in shorter ticket dicts
Body string `json:"description"`
Status string `json:"status"`
Resolution string `json:"resolution"`
Labels []string `json:"labels"`
Assignees []User `json:"assignees"`
Upstream string `json:"upstream"`
Signature string `json:"X-Payload-Signature"`
Nonce string `json:"X-Payload-Nonce"`
Events []Event `json:"events"`
ID int `json:"id"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Submitter Participant `json:"submitter"`
Ref string `json:"ref"`
Subject string `json:"subject"`
Body string `json:"body"`
Status string `json:"status"`
Resolution string `json:"resolution"`
Labels []string `json:"labels"`
Assignees []User `json:"assignees"`
Upstream string `json:"upstream"`
Signature string `json:"X-Payload-Signature"`
Nonce string `json:"X-Payload-Nonce"`
Events []Event `json:"events"`
}
type Event struct {
@ -76,8 +62,7 @@ type Event struct {
OldResolution *string `json:"old_resolution"`
NewStatus *string `json:"new_status"`
NewResolution *string `json:"new_resolution"`
User *Participant `json:"user"`
Ticket *Ticket `json:"ticket"`
Participant *Participant `json:"participant"`
Comment *Comment `json:"comment"`
Label *string `json:"label"`
ByUser *Participant `json:"by_user"`
@ -89,6 +74,7 @@ type Event struct {
type Participant struct {
Type string `json:"type"`
UserID int `json:"user_id"`
CanonicalName string `json:"canonical_name"`
Name string `json:"name"`
Address string `json:"address"`
@ -97,15 +83,33 @@ type Participant struct {
}
type User struct {
ID int `json:"id"`
CanonicalName string `json:"canonical_name"`
Name string `json:"name"`
}
type Comment struct {
ID int `json:"id"`
Created time.Time `json:"created"`
Submitter Participant `json:"submitter"`
Text string `json:"text"`
ID int `json:"id"`
Created time.Time `json:"created"`
Author Participant `json:"author"`
Text string `json:"text"`
}
type TicketSignatureData struct {
TrackerID int `json:"tracker_id"`
TicketID int `json:"ticket_id"`
Subject string `json:"subject"`
Body string `json:"body"`
SubmitterID int `json:"submitter_id"`
Upstream string `json:"upstream"`
}
type CommentSignatureData struct {
TrackerID int `json:"tracker_id"`
TicketID int `json:"ticket_id"`
Comment string `json:"comment"`
AuthorID int `json:"author_id"`
Upstream string `json:"upstream"`
}
func importParticipant(ctx context.Context, part Participant, upstream, ourUpstream string) (int, error) {
@ -192,9 +196,28 @@ func importTrackerDump(ctx context.Context, trackerID int, dump io.Reader, ourUp
return err
}
// Create labels
labelIDs := map[string]int{}
defer func() {
r := recover()
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
UPDATE tracker
SET import_in_progress = false
WHERE id = $1
`, trackerID)
return err
}); err != nil {
panic(err)
}
if r != nil {
panic(r)
}
}()
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
// Create labels
labelIDs := map[string]int{}
for _, label := range tracker.Labels {
row := tx.QueryRowContext(ctx, `
INSERT INTO label (
@ -202,32 +225,15 @@ func importTrackerDump(ctx context.Context, trackerID int, dump io.Reader, ourUp
) VALUES (
$1, $1, $2, $3, $4, $5
) RETURNING id
`, label.Created, trackerID, label.Name, label.Colors.Background, label.Colors.Foreground)
`, label.Created, trackerID, label.Name,
label.BackgroundColor, label.ForegroundColor)
var labelID int
if err := row.Scan(&labelID); err != nil {
return err
}
labelIDs[label.Name] = labelID
}
return nil
}); err != nil {
return err
}
defer func() {
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
UPDATE tracker
SET import_in_progress = false
WHERE id = $1
`, trackerID)
return err
}); err != nil {
panic(err)
}
}()
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
var nextTicketID int
row := tx.QueryRowContext(ctx,
`SELECT next_ticket_id FROM tracker WHERE id = $1`,
@ -244,11 +250,32 @@ func importTrackerDump(ctx context.Context, trackerID int, dump io.Reader, ourUp
var maxTicketID int
for _, ticket := range tracker.Tickets {
submitterID, err := importParticipant(ctx, *ticket.Submitter, ticket.Upstream, ourUpstream)
submitterID, err := importParticipant(ctx, ticket.Submitter, ticket.Upstream, ourUpstream)
if err != nil {
return err
}
ticketAuthenticity := model.AUTH_UNAUTHENTICATED
if ticket.Upstream == ourUpstream && ticket.Submitter.Type == "user" {
sigdata := TicketSignatureData{
TrackerID: tracker.ID,
TicketID: ticket.ID,
Subject: ticket.Subject,
Body: ticket.Body,
SubmitterID: ticket.Submitter.UserID,
Upstream: ticket.Upstream,
}
payload, err := json.Marshal(sigdata)
if err != nil {
panic(err)
}
if crypto.VerifyWebhook(payload, ticket.Nonce, ticket.Signature) {
ticketAuthenticity = model.AUTH_AUTHENTIC
} else {
ticketAuthenticity = model.AUTH_TAMPERED
}
}
// Compute the max ticket ID. We can't use the number of tickets as
// the next ticket ID because that won't include deleted tickets
if ticket.ID > maxTicketID {
@ -268,9 +295,9 @@ func importTrackerDump(ctx context.Context, trackerID int, dump io.Reader, ourUp
RETURNING id
`, ticket.Created, ticket.Updated, trackerID, ticket.ID,
submitterID, ticket.Subject, ticket.Body,
model.TicketStatus(strings.ToUpper(ticket.Status)).ToInt(),
model.TicketResolution(strings.ToUpper(ticket.Resolution)).ToInt(),
model.AUTH_UNAUTHENTICATED)
model.TicketStatus(ticket.Status).ToInt(),
model.TicketResolution(ticket.Resolution).ToInt(),
ticketAuthenticity)
var ticketPKID int
if err := row.Scan(&ticketPKID); err != nil {
return err
@ -307,26 +334,51 @@ func importTrackerDump(ctx context.Context, trackerID int, dump io.Reader, ourUp
var eventType int
for _, etype := range event.EventType {
etype := model.EventType(etype)
if !etype.IsValid() {
fmt.Errorf("failed to import ticket #%d: invalid event type %s", ticket.ID, etype)
}
eventType |= eventTypeMap[etype]
}
if eventType == 0 {
return fmt.Errorf("failed to import ticket #%d: invalid ticket event", ticket.ID, eventType)
}
if event.User != nil {
userPartID, err := importParticipant(ctx, *event.User, event.Upstream, ourUpstream)
if event.Participant != nil {
eventPartID, err := importParticipant(ctx, *event.Participant, event.Upstream, ourUpstream)
if err != nil {
return err
}
partID = &userPartID
partID = &eventPartID
}
if eventType&model.EVENT_COMMENT != 0 {
submitterID, err := importParticipant(ctx, event.Comment.Submitter, event.Upstream, ourUpstream)
authorID, err := importParticipant(ctx, event.Comment.Author, event.Upstream, ourUpstream)
if err != nil {
return err
}
commentAuthenticity := model.AUTH_UNAUTHENTICATED
if event.Upstream == ourUpstream && event.Comment.Author.Type == "user" {
log.Println(event.Comment.Author.UserID)
sigdata := CommentSignatureData{
TrackerID: tracker.ID,
TicketID: ticket.ID,
Comment: event.Comment.Text,
AuthorID: event.Comment.Author.UserID,
Upstream: event.Upstream,
}
payload, err := json.Marshal(sigdata)
if err != nil {
panic(err)
}
if crypto.VerifyWebhook(payload, event.Nonce, event.Signature) {
commentAuthenticity = model.AUTH_AUTHENTIC
} else {
commentAuthenticity = model.AUTH_TAMPERED
}
}
row := tx.QueryRowContext(ctx, `
INSERT INTO ticket_comment (
created, updated, submitter_id, ticket_id, text,
@ -334,8 +386,8 @@ func importTrackerDump(ctx context.Context, trackerID int, dump io.Reader, ourUp
) VALUES (
$1, $1, $2, $3, $4, $5
) RETURNING id
`, event.Comment.Created, submitterID, ticketPKID, event.Comment.Text,
model.AUTH_UNAUTHENTICATED)
`, event.Comment.Created, authorID, ticketPKID, event.Comment.Text,
commentAuthenticity)
var _commentID int
if err := row.Scan(&_commentID); err != nil {
return err
@ -453,14 +505,14 @@ func convertResolutionToInt(resolution *string) *int {
return &resolutionInt
}
var eventTypeMap = map[string]int{
"created": model.EVENT_CREATED,
"comment": model.EVENT_COMMENT,
"status_change": model.EVENT_STATUS_CHANGE,
"label_added": model.EVENT_LABEL_ADDED,
"label_removed": model.EVENT_LABEL_REMOVED,
"assigned_user": model.EVENT_ASSIGNED_USER,
"unassigned_user": model.EVENT_UNASSIGNED_USER,
"user_mentioned": model.EVENT_USER_MENTIONED,
"ticket_mentioned": model.EVENT_TICKET_MENTIONED,
var eventTypeMap = map[model.EventType]int{
model.EventTypeCreated: model.EVENT_CREATED,
model.EventTypeComment: model.EVENT_COMMENT,
model.EventTypeStatusChange: model.EVENT_STATUS_CHANGE,
model.EventTypeLabelAdded: model.EVENT_LABEL_ADDED,
model.EventTypeLabelRemoved: model.EVENT_LABEL_REMOVED,
model.EventTypeAssignedUser: model.EVENT_ASSIGNED_USER,
model.EventTypeUnassignedUser: model.EVENT_UNASSIGNED_USER,
model.EventTypeUserMentioned: model.EVENT_USER_MENTIONED,
model.EventTypeTicketMentioned: model.EVENT_TICKET_MENTIONED,
}

@ -9,7 +9,7 @@ from srht.crypto import sign_payload
from srht.database import db
from srht.oauth import current_user, loginrequired
from srht.flask import date_handler, session
from srht.graphql import exec_gql
from srht.graphql import exec_gql, GraphQLOperation, GraphQLUpload
from srht.validation import Validation
from tempfile import NamedTemporaryFile
from todosrht.access import get_tracker
@ -17,7 +17,6 @@ from todosrht.trackers import get_recent_users
from todosrht.types import Event, EventType, Ticket, TicketAccess, Visibility
from todosrht.types import ParticipantType, UserAccess, User
from todosrht.urls import tracker_url
from todosrht.tracker_import import tracker_import
settings = Blueprint("settings", __name__)
@ -243,20 +242,57 @@ def export_POST(owner, name):
if current_user.id != tracker.owner_id:
abort(403)
upstream = get_origin("todo.sr.ht", external=True)
def participant_to_dict(self):
if self.participant_type == ParticipantType.user:
return {
"type": "user",
"user_id": self.user.id,
"canonical_name": self.user.canonical_name,
"name": self.user.username,
}
elif self.participant_type == ParticipantType.email:
return {
"type": "email",
"address": self.email,
"name": self.email_name,
}
elif self.participant_type == ParticipantType.external:
return {
"type": "external",
"external_id": self.external_id,
"external_url": self.external_url,
}
assert False
dump = list()
tickets = Ticket.query.filter(Ticket.tracker_id == tracker.id).all()
for ticket in tickets:
td = ticket.to_dict()
td["upstream"] = get_origin("todo.sr.ht", external=True)
td = {
"id": ticket.scoped_id,
"created": ticket.created,
"updated": ticket.updated,
"submitter": participant_to_dict(ticket.submitter),
"ref": ticket.ref(),
"subject": ticket.title,
"body": ticket.description,
"status": ticket.status.name.upper(),
"resolution": ticket.resolution.name.upper(),
"labels": [l.name for l in ticket.labels],
"assignees": [u.to_dict(short=True) for u in ticket.assigned_users],
}
td["upstream"] = upstream
if ticket.submitter.participant_type == ParticipantType.user:
sigdata = OrderedDict({
"description": ticket.description,
"ref": ticket.ref(),
"submitter": ticket.submitter.user.canonical_name,
"title": ticket.title,
"upstream": get_origin("todo.sr.ht", external=True),
"tracker_id": tracker.id,
"ticket_id": ticket.scoped_id,
"subject": ticket.title,
"body": ticket.description,
"submitter_id": ticket.submitter.user.id,
"upstream": upstream,
})
sigdata = json.dumps(sigdata)
sigdata = json.dumps(sigdata, separators=(',',':'))
signature = sign_payload(sigdata)
td.update(signature)
@ -264,27 +300,64 @@ def export_POST(owner, name):
if any(events):
td["events"] = list()
for event in events:
ev = event.to_dict()
ev["upstream"] = get_origin("todo.sr.ht", external=True)
ev = {
"id": event.id,
"created": event.created,
"event_type": [t.name.upper() for t in EventType if t in event.event_type],
"old_status": event.old_status.name.upper()
if event.old_status else None,
"old_resolution": event.old_resolution.name.upper()
if event.old_resolution else None,
"new_status": event.new_status.name.upper()
if event.new_status else None,
"new_resolution": event.new_resolution.name.upper()
if event.new_resolution else None,
"participant": participant_to_dict(event.participant)
if event.participant else None,
"ticket_id": event.ticket.scoped_id
if event.ticket else None,
"comment": {
"id": event.comment.id,
"created": event.comment.created,
"author": participant_to_dict(event.comment.submitter),
"text": event.comment.text,
} if event.comment else None,
"label": event.label.name if event.label else None,
"by_user": participant_to_dict(event.by_participant)
if event.by_participant else None,
"from_ticket_id": event.from_ticket.scoped_id
if event.from_ticket else None,
}
ev["upstream"] = upstream
if (EventType.comment in event.event_type
and event.participant.participant_type == ParticipantType.user):
sigdata = OrderedDict({
"tracker_id": tracker.id,
"ticket_id": ticket.scoped_id,
"comment": event.comment.text,
"id": event.id,
"ticket": event.ticket.ref(),
"user": event.participant.user.canonical_name,
"upstream": get_origin("todo.sr.ht", external=True),
"author_id": event.comment.submitter.user.id,
"upstream": upstream
})
sigdata = json.dumps(sigdata)
sigdata = json.dumps(sigdata, separators=(',',':'))
signature = sign_payload(sigdata)
ev.update(signature)
td["events"].append(ev)
dump.append(td)
dump = json.dumps({
"owner": tracker.owner.to_dict(),
"id": tracker.id,
"owner": tracker.owner.to_dict(short=True),
"created": tracker.created,
"updated": tracker.updated,
"name": tracker.name,
"labels": [l.to_dict() for l in tracker.labels],
"description": tracker.description,
"labels": [{
"id": l.id,
"created": l.created,
"name": l.name,
"background_color": l.color,
"foreground_color": l.text_color,
} for l in tracker.labels],
"tickets": dump,
}, default=date_handler)
with NamedTemporaryFile() as ntf:
@ -304,22 +377,32 @@ def import_POST(owner, name):
if current_user.id != tracker.owner_id:
abort(403)
dump = request.files.get("dump")
valid = Validation(request)
dump = request.files.get("dump")
valid.expect(dump is not None,
"Tracker dump file is required", field="dump")
if not valid.ok:
return render_template("tracker-import-export.html",
view="import/export", tracker=tracker, **valid.kwargs)
try:
dump = dump.stream.read()
dump = gzip.decompress(dump)
dump = json.loads(dump)
except:
abort(400)
tracker_import.delay(dump, tracker.id)
op = GraphQLOperation("""
mutation ImportTrackerDump($trackerId: Int!, $dump: Upload!) {
importTrackerDump(trackerId: $trackerId, dump: $dump)
}
""")
dump = GraphQLUpload(
dump.filename,
dump.stream,
"application/octet-stream",
)
op.var("trackerId", tracker.id)
op.var("dump", dump)
op.execute("todo.sr.ht", valid=valid)
if not valid.ok:
return render_template("tracker-import-export.html",
view="import/export", tracker=tracker, **valid.kwargs)
tracker.import_in_progress = True
db.session.commit()
return redirect(tracker_url(tracker))

@ -36,20 +36,21 @@
after preparing the following JSON data as UTF-8:
</p>
<pre>{
"description": <strong>$description</strong>,
"ref": "{{tracker.ref()}}#<strong>$id</strong>",
"submitter": "~<strong>$username</strong>",
"title": <strong>$title</strong>,
"tracker_id": <strong>$trackerId</strong>,
"ticket_id": <strong>$ticketId</strong>,
"subject": <strong>$subject</strong>,
"body": <strong>$body</strong>,
"submitter_id": "<strong>$userId</strong>",
"upstream": "{{get_origin('todo.sr.ht', external=True)}}",
}</pre>
<p>
Comments use the same process with the following JSON data as UTF-8:
</p>
<pre>{
"tracker_id": <strong>$trackerId</strong>,
"ticket_id": <strong>$ticketId</strong>,
"comment": <strong>$comment</strong>,
"id": <strong>$id</strong>,
"ticket": "{{tracker.ref()}}#<strong>$id</strong>",
"user": "~<strong>$username</strong>",
"author_id": "<strong>$userId</strong>",
"upstream": "{{get_origin('todo.sr.ht', external=True)}}",
}</pre>
<p>

@ -1,207 +0,0 @@
import json
from collections import OrderedDict
from datetime import datetime, timezone
from srht.crypto import verify_payload
from srht.config import get_origin
from srht.database import db
from todosrht.tickets import submit_ticket
from todosrht.tickets import get_participant_for_email
from todosrht.tickets import get_participant_for_external
from todosrht.tickets import get_participant_for_user
from todosrht.types import Event, EventType, Tracker, Ticket, TicketComment
from todosrht.types import ParticipantType, User
from todosrht.types import Label, TicketLabel
from todosrht.types import TicketStatus, TicketResolution
from todosrht.types import TicketAuthenticity
from todosrht.webhooks import worker
our_upstream = get_origin("todo.sr.ht", external=True)
def _parse_date(date):
date = datetime.fromisoformat(date)
date = date.astimezone(timezone.utc).replace(tzinfo=None)
return date
def _import_participant(pdata, upstream):
if pdata["type"] == "user":
if upstream == our_upstream:
user = User.query.filter(User.username == pdata["name"]).first()
else:
user = None
if user:
submitter = get_participant_for_user(user)
else:
submitter = get_participant_for_external(
pdata["canonical_name"],
upstream + "/" + pdata["canonical_name"])
elif pdata["type"] == "email":
submitter = get_participant_for_email(pdata["address"], pdata["name"])
elif pdata["type"] == "external":
submitter = get_participant_for_external(
pdata["external_id"], pdata["external_url"])
return submitter
def _import_comment(ticket, event, edata):
cdata = edata["comment"]
comment = TicketComment()
submitter = _import_participant(cdata["submitter"], edata["upstream"])
comment.submitter_id = submitter.id
comment.ticket_id = ticket.id
comment.text = cdata["text"]
comment.authenticity = TicketAuthenticity.unauthenticated
comment.created = _parse_date(cdata["created"])
comment.updated = comment.created
comment._no_autoupdate = True
signature, nonce = edata.get("X-Payload-Signature"), edata.get("X-Payload-Nonce")
if (signature and nonce
and edata["upstream"] == our_upstream
and submitter.participant_type == ParticipantType.user):
# TODO: Validate signatures from trusted third-parties
sigdata = OrderedDict({
"comment": comment.text,
"id": edata["id"], # not important to verify this
"ticket": edata["ticket"]["ref"], # not important to verify this
"user": submitter.user.canonical_name,
"upstream": edata["upstream"],
})
sigdata = json.dumps(sigdata)
if verify_payload(sigdata, signature, nonce):
comment.authenticity = TicketAuthenticity.authentic
else:
comment.authenticity = TicketAuthenticity.tampered
ticket.comment_count += 1
db.session.add(comment)
db.session.flush()
event.comment_id = comment.id
def _tracker_import(dump, tracker):
ldict = dict()
for ldata in dump["labels"]:
label = Label()
label.tracker_id = tracker.id
label.name = ldata["name"]
label.color = ldata["colors"]["background"]
label.text_color = ldata["colors"]["text"]
db.session.add(label)
db.session.flush()
ldict[label.name] = label.id
tickets = sorted(dump["tickets"], key=lambda t: t["id"])
for tdata in tickets:
for field in [
"id", "title", "created", "description", "status", "resolution",
"labels", "assignees", "upstream", "events", "submitter",
]:
if not field in tdata:
print("Invalid ticket data")
continue
ticket = Ticket.query.filter(
Ticket.tracker_id == tracker.id,
Ticket.scoped_id == tdata["id"]).one_or_none()
if ticket:
print(f"Ticket {tdata['id']} already imported - skipping")
continue
submitter = _import_participant(tdata["submitter"], tdata["upstream"])
ticket = submit_ticket(tracker, submitter,
tdata["title"], tdata["description"], importing=True)
try:
created = _parse_date(tdata["created"])
except ValueError:
created = datetime.utcnow()
ticket._no_autoupdate = True
ticket.created = created
ticket.updated = created
ticket.status = TicketStatus[tdata["status"]]
ticket.resolution = TicketResolution[tdata["resolution"]]
ticket.authenticity = TicketAuthenticity.unauthenticated
for label in tdata["labels"]:
tl = TicketLabel()
tl.ticket_id = ticket.id
tl.label_id = ldict.get(label)
tl.user_id = tracker.owner_id
db.session.add(tl)
# TODO: assignees
signature, nonce = tdata.get("X-Payload-Signature"), tdata.get("X-Payload-Nonce")
if (signature and nonce
and tdata["upstream"] == our_upstream
and submitter.participant_type == ParticipantType.user):
# TODO: Validate signatures from trusted third-parties
sigdata = OrderedDict({
"description": ticket.description,
"ref": tdata["ref"], # not important to verify this
"submitter": ticket.submitter.user.canonical_name,
"title": ticket.title,
"upstream": tdata["upstream"],
})
sigdata = json.dumps(sigdata)
if verify_payload(sigdata, signature, nonce):
ticket.authenticity = TicketAuthenticity.authentic
else:
ticket.authenticity = TicketAuthenticity.tampered
for edata in tdata.get("events", []):
for field in [
"created", "event_type", "old_status", "new_status",
"old_resolution", "new_resolution", "user", "ticket",
"comment", "label", "by_user", "from_ticket", "upstream",
]:
if not field in edata:
print("Invalid ticket event")
return
event = Event()
for etype in edata["event_type"]:
if event.event_type == None:
event.event_type = EventType[etype]
else:
event.event_type |= EventType[etype]
if event.event_type == None:
print("Invalid ticket event")
continue
if EventType.comment in event.event_type:
_import_comment(ticket, event, edata)
if EventType.status_change in event.event_type:
if edata["old_status"]:
event.old_status = TicketStatus[edata["old_status"]]
if edata["new_status"]:
event.new_status = TicketStatus[edata["new_status"]]
if EventType.label_added in event.event_type:
event.label_id = ldict.get(edata["label"])
if not event.label_id:
continue
if EventType.label_removed in event.event_type:
event.label_id = ldict.get(edata["label"])
if not event.label_id:
continue
if EventType.assigned_user in event.event_type:
by_participant = _import_participant(
edata["by_user"], edata["upstream"])
event.by_participant_id = by_participant.id
if EventType.unassigned_user in event.event_type:
by_participant = _import_participant(
edata["by_user"], edata["upstream"])
event.by_participant_id = by_participant.id
if EventType.user_mentioned in event.event_type:
continue # Magic event type, do not import
if EventType.ticket_mentioned in event.event_type:
continue # TODO: Could reference tickets imported in later iters
event.created = _parse_date(edata["created"])
event.updated = event.created
event._no_autoupdate = True
event.ticket_id = ticket.id
participant = _import_participant(edata["user"], edata["upstream"])
event.participant_id = participant.id
db.session.add(event)
print(f"Imported {ticket.ref()}")
@worker.task
def tracker_import(dump, tracker_id):
tracker = Tracker.query.get(tracker_id)
try:
_tracker_import(dump, tracker)
except:
# TODO: Tell user that the import failed?
db.session.rollback()
tracker = Tracker.query.get(tracker_id)
raise
finally:
tracker.import_in_progress = False
db.session.commit()

@ -15,8 +15,6 @@ webhooks_broker = cfg("todo.sr.ht", "webhooks")
worker = make_worker(broker=webhooks_broker)
webhook_metrics_collector = RedisQueueCollector(webhooks_broker, "srht_webhooks", "Webhook queue length")
import todosrht.tracker_import
class UserWebhook(CeleryWebhook):
events = [
Event("tracker:create", "trackers:read"),

Loading…
Cancel
Save