api: Implement tracker dump imports

This currently does not support authenticating the imported data.
This commit is contained in:
Adnan Maolood 2022-09-06 22:43:42 -04:00 committed by Drew DeVault
parent 4827e61f97
commit e02ddb0888
7 changed files with 567 additions and 15 deletions

View File

@ -4,23 +4,26 @@ go 1.15
require (
git.sr.ht/~sircmpwn/core-go v0.0.0-20220530120843-d0bf1153ada4
git.sr.ht/~sircmpwn/dowork v0.0.0-20210820133136-d3970e97def3
github.com/99designs/gqlgen v0.17.2
github.com/Masterminds/squirrel v1.4.0
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/emersion/go-message v0.15.0
github.com/google/uuid v1.0.0
github.com/google/uuid v1.3.0
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.8.0
github.com/matryer/moq v0.2.6 // indirect
github.com/mitchellh/mapstructure v1.3.2 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/prometheus/common v0.30.0 // indirect
github.com/stretchr/testify v1.7.0 // indirect
github.com/urfave/cli/v2 v2.4.0 // indirect
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e
github.com/vektah/gqlparser/v2 v2.4.1
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
replace github.com/Masterminds/squirrel => github.com/lieut-data/squirrel v1.5.4

View File

@ -173,8 +173,9 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@ -204,6 +205,8 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -238,8 +241,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@ -301,8 +302,9 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I=
@ -335,8 +337,9 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e h1:MUP6MR3rJ7Gk9LEia0LP2ytiH6MuCfs7qYz+47jGdD8=
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -405,8 +408,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -470,9 +474,11 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -619,8 +625,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

View File

@ -954,6 +954,9 @@ type Mutation {
unlabelTicket(trackerId: Int!, ticketId: Int!,
labelId: Int!): Event! @access(scope: TICKETS, kind: RW)
"Imports a gzipped JSON dump of tracker data"
importTrackerDump(trackerId: Int!, dump: Upload!): Boolean! @access(scope: TRACKERS, kind: RW)
"""
Creates a new user webhook subscription. When an event from the
provided list of events occurs, the 'query' parameter (a GraphQL query)

View File

@ -4,6 +4,7 @@ package graph
// will be copied through when generating and any unknown code will be moved to the end.
import (
"compress/gzip"
"context"
"database/sql"
"errors"
@ -21,6 +22,7 @@ import (
corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/api"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/imports"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/loaders"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/webhooks"
"github.com/99designs/gqlgen/graphql"
@ -1993,6 +1995,25 @@ func (r *mutationResolver) UnlabelTicket(ctx context.Context, trackerID int, tic
return &event, nil
}
func (r *mutationResolver) ImportTrackerDump(ctx context.Context, trackerID int, dump graphql.Upload) (bool, error) {
gr, err := gzip.NewReader(dump.File)
if err != nil {
return false, err
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
UPDATE tracker
SET import_in_progress = true
WHERE id = $1 AND owner_id = $2
`, trackerID, auth.ForContext(ctx).UserID)
return err
}); err != nil {
return false, err
}
imports.ImportTrackerDump(ctx, trackerID, gr)
return true, nil
}
func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) {
schema := server.ForContext(ctx).Schema
if err := corewebhooks.Validate(schema, config.Query); err != nil {

466
api/imports/import.go Normal file
View File

@ -0,0 +1,466 @@
package imports
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"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"`
}
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"`
}
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"`
}
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"`
User *Participant `json:"user"`
Ticket *Ticket `json:"ticket"`
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"`
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 {
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"`
}
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
}
// Create labels
labelIDs := map[string]int{}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
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.Colors.Background, label.Colors.Foreground)
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`,
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
}
// 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(strings.ToUpper(ticket.Status)).ToInt(),
model.TicketResolution(strings.ToUpper(ticket.Resolution)).ToInt(),
model.AUTH_UNAUTHENTICATED)
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 {
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 err != nil {
return err
}
partID = &userPartID
}
if eventType&model.EVENT_COMMENT != 0 {
submitterID, err := importParticipant(ctx, event.Comment.Submitter, event.Upstream, ourUpstream)
if err != nil {
return err
}
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, submitterID, ticketPKID, event.Comment.Text,
model.AUTH_UNAUTHENTICATED)
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[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,
}

49
api/imports/middleware.go Normal file
View File

@ -0,0 +1,49 @@
package imports
import (
"context"
"io"
"log"
"net/http"
"time"
"git.sr.ht/~sircmpwn/core-go/config"
work "git.sr.ht/~sircmpwn/dowork"
)
type contextKey struct {
name string
}
var ctxKey = &contextKey{"imports"}
func Middleware(queue *work.Queue) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), ctxKey, queue)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}
// Schedules a tracker import.
func ImportTrackerDump(ctx context.Context, trackerID int, dump io.Reader) {
queue, ok := ctx.Value(ctxKey).(*work.Queue)
if !ok {
panic("No imports worker for this context")
}
cfg := config.ForContext(ctx)
ourUpstream := config.GetOrigin(cfg, "todo.sr.ht", true)
task := work.NewTask(func(ctx context.Context) error {
importCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
err := importTrackerDump(importCtx, trackerID, dump, ourUpstream)
if err != nil {
return err
}
return nil
})
queue.Enqueue(task)
log.Printf("Enqueued tracker import for tracker %d", trackerID)
}

View File

@ -6,11 +6,13 @@ import (
"git.sr.ht/~sircmpwn/core-go/config"
"git.sr.ht/~sircmpwn/core-go/server"
"git.sr.ht/~sircmpwn/core-go/webhooks"
work "git.sr.ht/~sircmpwn/dowork"
"github.com/99designs/gqlgen/graphql"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/api"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/imports"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/loaders"
)
@ -32,6 +34,7 @@ func main() {
scopes[i] = s.String()
}
importsQueue := work.NewQueue("imports")
webhookQueue := webhooks.NewQueue(schema)
legacyWebhooks := webhooks.NewLegacyQueue()
@ -39,10 +42,11 @@ func main() {
WithDefaultMiddleware().
WithMiddleware(
loaders.Middleware,
imports.Middleware(importsQueue),
webhooks.Middleware(webhookQueue),
webhooks.LegacyMiddleware(legacyWebhooks),
).
WithSchema(schema, scopes).
WithQueues(webhookQueue.Queue, legacyWebhooks.Queue).
WithQueues(importsQueue, webhookQueue.Queue, legacyWebhooks.Queue).
Run()
}