todo.sr.ht/api/trackers/import.go

519 lines
14 KiB
Go

package trackers
import (
"context"
"database/sql"
"encoding/json"
"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 {
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 {
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"`
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 {
ID int `json:"id"`
Created time.Time `json:"created"`
EventType []string `json:"event_type"`
OldStatus *string `json:"old_status"`
OldResolution *string `json:"old_resolution"`
NewStatus *string `json:"new_status"`
NewResolution *string `json:"new_resolution"`
Participant *Participant `json:"participant"`
Comment *Comment `json:"comment"`
Label *string `json:"label"`
ByUser *Participant `json:"by_user"`
FromTicket *Ticket `json:"from_ticket"`
Upstream string `json:"upstream"`
Signature string `json:"X-Payload-Signature"`
Nonce string `json:"X-Payload-Nonce"`
}
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"`
ExternalID string `json:"external_id"`
ExternalURL string `json:"external_url"`
}
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"`
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) {
switch part.Type {
case "user":
if upstream == ourUpstream {
part, err := loaders.ForContext(ctx).ParticipantsByUsername.Load(part.Name)
if err == nil {
return part.ID, nil
}
}
return importExternalParticipant(ctx, part.CanonicalName,
upstream+"/"+part.CanonicalName)
case "email":
// TODO: check if the email is registered on this upstream?
return importEmailParticipant(ctx, part.Address, part.Name)
case "external":
// TODO: check if the user is registered on this upstream?
return importExternalParticipant(ctx, part.ExternalID, part.ExternalURL)
default:
return 0, fmt.Errorf("invalid participant type %q", part.Type)
}
}
func importEmailParticipant(ctx context.Context, address, name string) (int, error) {
var partID int
err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
INSERT INTO participant (
created, participant_type, email, email_name
) VALUES (
NOW() at time zone 'utc',
'email',
$1, $2
)
ON CONFLICT ON CONSTRAINT participant_email_key
DO UPDATE SET created = participant.created
RETURNING id
`, address, name)
if err := row.Scan(&partID); err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return partID, nil
}
func importExternalParticipant(ctx context.Context, id, url string) (int, error) {
var partID int
err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
INSERT INTO participant (
created, participant_type, external_id, external_url
) VALUES (
NOW() at time zone 'utc',
'external',
$1, $2
)
ON CONFLICT ON CONSTRAINT participant_external_id_key
DO UPDATE SET created = participant.created
RETURNING id
`, id, url)
if err := row.Scan(&partID); err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return partID, nil
}
func importTrackerDump(ctx context.Context, trackerID int, dump io.Reader, ourUpstream string) error {
b, err := io.ReadAll(dump)
if err != nil {
return err
}
var tracker TrackerDump
if err := json.Unmarshal(b, &tracker); err != nil {
return err
}
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 (
created, updated, tracker_id, name, color, text_color
) VALUES (
$1, $1, $2, $3, $4, $5
) RETURNING id
`, 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
}
var nextTicketID int
row := tx.QueryRowContext(ctx,
`SELECT next_ticket_id FROM tracker WHERE id = $1`,
trackerID)
if err := row.Scan(&nextTicketID); err != nil {
return err
}
// Make sure that the tracker does not have any existing tickets
// to avoid conflicts.
if nextTicketID != 1 {
return errors.New("Tracker must not have any existing tickets")
}
var maxTicketID int
for _, ticket := range tracker.Tickets {
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 {
maxTicketID = ticket.ID
}
// We don't need to check for existing tickets since we ensured that
// the tracker has no tickets.
row := tx.QueryRowContext(ctx, `
INSERT INTO ticket (
created, updated,
tracker_id, scoped_id,
submitter_id, title, description,
status, resolution, authenticity
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
)
RETURNING id
`, ticket.Created, ticket.Updated, trackerID, ticket.ID,
submitterID, ticket.Subject, ticket.Body,
model.TicketStatus(ticket.Status).ToInt(),
model.TicketResolution(ticket.Resolution).ToInt(),
ticketAuthenticity)
var ticketPKID int
if err := row.Scan(&ticketPKID); err != nil {
return err
}
for _, label := range ticket.Labels {
_, err := tx.ExecContext(ctx, `
INSERT INTO ticket_label (
created, ticket_id, label_id, user_id
) VALUES (
NOW() at time zone 'utc',
$1, $2,
(SELECT owner_id FROM tracker WHERE id = $3)
)
`, ticketPKID, labelIDs[label], trackerID)
if err != nil {
return err
}
}
// TODO: assignees
for _, event := range ticket.Events {
var (
commentID *int
labelID *int
partID *int
oldStatus *int
newStatus *int
oldResolution *int
newResolution *int
byParticipantID *int
)
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.Participant != nil {
eventPartID, err := importParticipant(ctx, *event.Participant, event.Upstream, ourUpstream)
if err != nil {
return err
}
partID = &eventPartID
}
if eventType&model.EVENT_COMMENT != 0 {
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,
authenticity
) VALUES (
$1, $1, $2, $3, $4, $5
) RETURNING id
`, event.Comment.Created, authorID, ticketPKID, event.Comment.Text,
commentAuthenticity)
var _commentID int
if err := row.Scan(&_commentID); err != nil {
return err
}
commentID = &_commentID
_, err = tx.ExecContext(ctx, `
UPDATE ticket
SET comment_count = comment_count + 1
WHERE id = $1
`, ticketPKID)
if err != nil {
return err
}
}
if eventType&model.EVENT_STATUS_CHANGE != 0 {
oldStatus = convertStatusToInt(event.OldStatus)
newStatus = convertStatusToInt(event.NewStatus)
oldResolution = convertResolutionToInt(event.OldResolution)
newResolution = convertResolutionToInt(event.NewResolution)
}
if eventType&model.EVENT_LABEL_ADDED != 0 ||
eventType&model.EVENT_LABEL_REMOVED != 0 {
_labelID := labelIDs[*event.Label]
labelID = &_labelID
}
if eventType&model.EVENT_ASSIGNED_USER != 0 ||
eventType&model.EVENT_UNASSIGNED_USER != 0 {
partID, err := importParticipant(ctx, *event.ByUser, event.Upstream, ourUpstream)
if err != nil {
return err
}
byParticipantID = &partID
}
if eventType&model.EVENT_USER_MENTIONED != 0 {
// Magic event type, do not import
continue
}
if eventType&model.EVENT_TICKET_MENTIONED != 0 {
// TODO: Could reference tickets imported in later iterations
continue
}
_, err := tx.ExecContext(ctx, `
INSERT INTO event (
created, event_type, participant_id, ticket_id,
old_status, new_status, old_resolution, new_resolution,
comment_id, label_id, by_participant_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
)
`, event.Created, eventType, partID, ticketPKID,
oldStatus, newStatus, oldResolution, newResolution,
commentID, labelID, byParticipantID)
if err != nil {
return err
}
}
}
// Update tracker.next_ticket_id
if maxTicketID != 0 {
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
UPDATE tracker
SET next_ticket_id = $2 + 1
WHERE id = $1
`, trackerID, maxTicketID)
return err
}); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return nil
}
func convertStatus(status *string) *model.TicketStatus {
if status == nil {
return nil
}
*status = strings.ToUpper(*status)
return (*model.TicketStatus)(status)
}
func convertStatusToInt(status *string) *int {
if status == nil {
statusInt := model.STATUS_REPORTED
return &statusInt
}
*status = strings.ToUpper(*status)
statusInt := (model.TicketStatus)(*status).ToInt()
return &statusInt
}
func convertResolution(resolution *string) *model.TicketResolution {
if resolution == nil {
return nil
}
*resolution = strings.ToUpper(*resolution)
return (*model.TicketResolution)(resolution)
}
func convertResolutionToInt(resolution *string) *int {
if resolution == nil {
resolutionInt := model.RESOLVED_UNRESOLVED
return &resolutionInt
}
*resolution = strings.ToUpper(*resolution)
resolutionInt := (model.TicketResolution)(*resolution).ToInt()
return &resolutionInt
}
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,
}