api: Implement tracker dump imports
This currently does not support authenticating the imported data.
This commit is contained in:
parent
4827e61f97
commit
e02ddb0888
11
api/go.mod
11
api/go.mod
|
@ -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
|
||||
|
|
26
api/go.sum
26
api/go.sum
|
@ -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=
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue