~emersion/hut

ee9502bc376bd929539846864988186a78d5a79d — delthas 5 months ago 549067b
Add the import command

Now that export is supported, we are adding a command to import
that data back to another sourcehut instance or account.

The design is to have export lay out a tree of directories by
service and resource, with each resource containing a info.json
file with common fields (service and resource name) and possibly
additional fields depending on the service. Import builds on top
of that existing design by walking the file tree from the given
folder, finding any info.json file, and passing the folder
containing that file to the relevant importer.

For example, export could write:
  /out/git.sr.ht/kuroneko/info.json
  /out/git.sr.ht/kuroneko/repository.git/...
  /out/meta.sr.ht/info.json
  /out/meta.sr.ht/ssh.keys
  ...

Then, import started on /out would walk the tree, finding:
  /out/git.sr.ht/kuroneko/info.json
  /out/meta.sr.ht/info.json

It passes "/out/git.sr.ht/kuroneko" to the git.sr.ht importer and
"/out/meta.sr.ht" to the meta.sr.ht importer.

import could also be started on /out/git.sr.ht to import only
resources of that service, or even /out/git.sr.ht/kuroneko to
import that project only.

Specific care was given not to abort import in case of an error
while importing a single resource, because this is typically due
to a resource already existing, so we just log and skip to the
next resource. This has the added benefit of being somewhat
idempotent, as running import twice would skip over existing
resources created over the first run.
A particular exception to this are pastes, which are created
with a new ID every time, and therefore are created again on
a second run.

Of note is that the resulting Export and ImportResource are not
symetrical: Export export all resources of the service, while
ImportResource imports a single resource. This is intended for now,
as a intermediate step before enriching the export command with
a way to select individual resources to export (therefore creating
something similar to an ExportResource).

Additionally, meta.sr.ht now outputs an info.json file (just to
let the import code automatically discover it and import SSH and
PGP keys). We store all PGP keys in the same file to reflect what
srht does on its meta/~username.pgp endpoint, at the cost of manual
string processing.
M doc/hut.1.scd => doc/hut.1.scd +3 -0
@@ 86,6 86,9 @@ Additionally, mailing lists can be referred to by their email address.
*export* <directory>
	Export account data.

*import* <directory>
	Import account data.

## builds

*artifacts* <ID>

M export/builds.go => export/builds.go +4 -0
@@ 167,3 167,7 @@ func (ex *BuildsExporter) exportTask(ctx context.Context, client *http.Client, j

	return nil
}

func (ex *BuildsExporter) ImportResource(ctx context.Context, dir string) error {
	panic("not implemented")
}

M export/git.go => export/git.go +43 -1
@@ 15,6 15,8 @@ import (
	"git.sr.ht/~emersion/hut/srht/gitsrht"
)

const gitRepositoryDir = "repository.git"

type GitExporter struct {
	client  *gqlclient.Client
	baseURL string


@@ 57,7 59,7 @@ func (ex *GitExporter) Export(ctx context.Context, dir string) error {
		for _, repo := range repos.Results {
			repoPath := path.Join(dir, repo.Name)
			infoPath := path.Join(repoPath, infoFilename)
			clonePath := path.Join(repoPath, "repository.git")
			clonePath := path.Join(repoPath, gitRepositoryDir)
			cloneURL := fmt.Sprintf("%s@%s:%s/%s", sshUser, baseURL.Host, repo.Owner.CanonicalName, repo.Name)

			if _, err := os.Stat(clonePath); err == nil {


@@ 103,3 105,43 @@ func (ex *GitExporter) Export(ctx context.Context, dir string) error {

	return nil
}

func (ex *GitExporter) ImportResource(ctx context.Context, dir string) error {
	settings, err := gitsrht.SshSettings(ex.client, ctx)
	if err != nil {
		return fmt.Errorf("failed to get Git SSH settings: %v", err)
	}
	sshUser := settings.Settings.SshUser

	baseURL, err := url.Parse(ex.baseURL)
	if err != nil {
		panic(err)
	}

	var info GitRepoInfo
	if err := readJSON(path.Join(dir, infoFilename), &info); err != nil {
		return err
	}

	g, err := gitsrht.CreateRepository(ex.client, ctx, info.Name, info.Visibility, info.Description, nil)
	if err != nil {
		return fmt.Errorf("failed to create Git repository: %v", err)
	}

	clonePath := path.Join(dir, gitRepositoryDir)
	cloneURL := fmt.Sprintf("%s@%s:%s/%s", sshUser, baseURL.Host, g.Owner.CanonicalName, info.Name)

	cmd := exec.Command("git", "-C", clonePath, "push", "--mirror", cloneURL)
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("failed to push Git repository: %v", err)
	}

	if _, err := gitsrht.UpdateRepository(ex.client, ctx, g.Id, gitsrht.RepoInput{
		Readme: info.Readme,
		HEAD:   info.Head,
	}); err != nil {
		return fmt.Errorf("failed to update Git repository: %v", err)
	}

	return nil
}

M export/hg.go => export/hg.go +40 -0
@@ 93,3 93,43 @@ func (ex *HgExporter) Export(ctx context.Context, dir string) error {

	return nil
}

func (ex *HgExporter) ImportResource(ctx context.Context, dir string) error {
	baseURL, err := url.Parse(ex.baseURL)
	if err != nil {
		panic(err)
	}

	var info HgRepoInfo
	if err := readJSON(path.Join(dir, infoFilename), &info); err != nil {
		return err
	}

	description := ""
	if info.Description != nil {
		description = *info.Description
	}

	h, err := hgsrht.CreateRepository(ex.client, ctx, info.Name, info.Visibility, description)
	if err != nil {
		return fmt.Errorf("failed to create Mercurial repository: %v", err)
	}

	clonePath := path.Join(dir, hgRepositoryDir)
	cloneURL := fmt.Sprintf("ssh://hg@%s/%s/%s", baseURL.Host, h.Owner.CanonicalName, info.Name)

	cmd := exec.Command("hg", "push", "--cwd", clonePath, cloneURL)
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("failed to push Mercurial repository: %v", err)
	}

	if _, err := hgsrht.UpdateRepository(ex.client, ctx, h.Id, hgsrht.RepoInput{
		Readme:        info.Readme,
		NonPublishing: &info.NonPublishing,
	}); err != nil {
		return fmt.Errorf("failed to update Mercurial repository: %v", err)
	}

	return nil
}

M export/iface.go => export/iface.go +50 -0
@@ 3,7 3,9 @@ package export
import (
	"context"
	"encoding/json"
	"io/fs"
	"os"
	"path/filepath"
)

const infoFilename = "info.json"


@@ 15,6 17,7 @@ type Info struct {

type Exporter interface {
	Export(ctx context.Context, dir string) error
	ImportResource(ctx context.Context, dir string) error
}

type partialError struct {


@@ 38,3 41,50 @@ func writeJSON(filename string, v interface{}) error {

	return f.Close()
}

func readJSON(filename string, v interface{}) error {
	f, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer f.Close()

	return json.NewDecoder(f).Decode(v)
}

type DirResource struct {
	Info
	Path string
}

func FindDirResources(dir string) ([]DirResource, error) {
	var l []DirResource
	err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			return nil
		}

		f, err := os.Open(filepath.Join(path, infoFilename))
		if os.IsNotExist(err) {
			return nil
		} else if err != nil {
			return err
		}
		defer f.Close()

		var info Info
		if err := json.NewDecoder(f).Decode(&info); err != nil {
			return err
		}

		l = append(l, DirResource{
			Info: info,
			Path: path,
		})
		return filepath.SkipDir
	})
	return l, err
}

M export/lists.go => export/lists.go +42 -1
@@ 16,6 16,8 @@ import (
	"git.sr.ht/~emersion/hut/srht/listssrht"
)

const archiveFilename = "archive.mbox"

type ListsExporter struct {
	client *gqlclient.Client
	http   *http.Client


@@ 104,7 106,7 @@ func (ex *ListsExporter) exportList(ctx context.Context, list listssrht.MailingL
			list.Name, resp.StatusCode)}
	}

	archive, err := os.Create(path.Join(base, "archive.mbox"))
	archive, err := os.Create(path.Join(base, archiveFilename))
	if err != nil {
		return err
	}


@@ 129,3 131,42 @@ func (ex *ListsExporter) exportList(ctx context.Context, list listssrht.MailingL

	return nil
}

func (ex *ListsExporter) ImportResource(ctx context.Context, dir string) error {
	var info MailingListInfo
	if err := readJSON(path.Join(dir, infoFilename), &info); err != nil {
		return err
	}

	return ex.importList(ctx, &info, dir)
}

func (ex *ListsExporter) importList(ctx context.Context, list *MailingListInfo, base string) error {
	l, err := listssrht.CreateMailingList(ex.client, ctx, list.Name, list.Description, list.Visibility)
	if err != nil {
		return fmt.Errorf("failed to create mailing list: %v", err)
	}

	if _, err := listssrht.UpdateMailingList(ex.client, ctx, l.Id, listssrht.MailingListInput{
		PermitMime: list.PermitMime,
		RejectMime: list.RejectMime,
	}); err != nil {
		return fmt.Errorf("failed to update mailing list: %v", err)
	}

	archive, err := os.Open(path.Join(base, archiveFilename))
	if err != nil {
		return err
	}
	defer archive.Close()

	if _, err := listssrht.ImportMailingListSpool(ex.client, ctx, l.Id, gqlclient.Upload{
		Filename: archiveFilename,
		MIMEType: "application/mbox",
		Body:     archive,
	}); err != nil {
		return fmt.Errorf("failed to import mailing list emails: %v", err)
	}

	return nil
}

M export/meta.go => export/meta.go +63 -2
@@ 1,16 1,24 @@
package export

import (
	"bufio"
	"context"
	"fmt"
	"log"
	"os"
	"path"
	"strings"

	"git.sr.ht/~emersion/gqlclient"

	"git.sr.ht/~emersion/hut/srht/metasrht"
)

const (
	sshKeysFilename = "ssh.keys"
	pgpKeysFilename = "keys.pgp"
)

type MetaExporter struct {
	client *gqlclient.Client
}


@@ 30,7 38,7 @@ func (ex *MetaExporter) Export(ctx context.Context, dir string) error {

	var cursor *metasrht.Cursor

	sshFile, err := os.Create(path.Join(dir, "ssh.keys"))
	sshFile, err := os.Create(path.Join(dir, sshKeysFilename))
	if err != nil {
		return err
	}


@@ 54,7 62,7 @@ func (ex *MetaExporter) Export(ctx context.Context, dir string) error {
		}
	}

	pgpFile, err := os.Create(path.Join(dir, "keys.pgp"))
	pgpFile, err := os.Create(path.Join(dir, pgpKeysFilename))
	if err != nil {
		return err
	}


@@ 87,3 95,56 @@ func (ex *MetaExporter) Export(ctx context.Context, dir string) error {

	return nil
}

func (ex *MetaExporter) ImportResource(ctx context.Context, dir string) error {
	sshFile, err := os.Open(path.Join(dir, sshKeysFilename))
	if err != nil {
		return err
	}
	defer sshFile.Close()

	sshScanner := bufio.NewScanner(sshFile)
	for sshScanner.Scan() {
		if sshScanner.Text() == "" {
			continue
		}
		if _, err := metasrht.CreateSSHKey(ex.client, ctx, sshScanner.Text()); err != nil {
			log.Printf("Error importing SSH key: %v", err)
			continue
		}
	}
	if sshScanner.Err() != nil {
		return err
	}

	pgpFile, err := os.Open(path.Join(dir, pgpKeysFilename))
	if err != nil {
		return err
	}
	defer pgpFile.Close()

	var key strings.Builder
	pgpScanner := bufio.NewScanner(pgpFile)
	for pgpScanner.Scan() {
		if strings.HasPrefix(pgpScanner.Text(), "-----BEGIN") {
			key.Reset()
		}
		key.WriteString(pgpScanner.Text())
		key.WriteByte('\n')
		if strings.HasPrefix(pgpScanner.Text(), "-----END") {
			if _, err := metasrht.CreatePGPKey(ex.client, ctx, key.String()); err != nil {
				log.Printf("Error importing PGP key: %v", err)
				continue
			}
			key.Reset()
		}
	}
	if pgpScanner.Err() != nil {
		return err
	}
	if strings.TrimSpace(key.String()) != "" {
		log.Printf("Error importing PGP key: malformed file")
	}

	return nil
}

M export/paste.go => export/paste.go +53 -1
@@ 16,6 16,8 @@ import (
	"git.sr.ht/~emersion/hut/srht/pastesrht"
)

const pasteFilesDir = "files"

type PasteExporter struct {
	client *gqlclient.Client
	http   *http.Client


@@ 75,7 77,7 @@ func (ex *PasteExporter) exportPaste(ctx context.Context, paste *pastesrht.Paste
	}

	log.Printf("\t%s", paste.Id)
	files := path.Join(base, "files")
	files := path.Join(base, pasteFilesDir)
	if err := os.MkdirAll(files, 0o755); err != nil {
		return err
	}


@@ 134,3 136,53 @@ func (ex *PasteExporter) exportFile(ctx context.Context, paste *pastesrht.Paste,

	return nil
}

func (ex *PasteExporter) ImportResource(ctx context.Context, dir string) error {
	var info PasteInfo
	if err := readJSON(path.Join(dir, infoFilename), &info); err != nil {
		return err
	}

	return ex.importPaste(ctx, &info, dir)
}

func (ex *PasteExporter) importPaste(ctx context.Context, paste *PasteInfo, base string) error {
	filesPath := path.Join(base, pasteFilesDir)
	items, err := os.ReadDir(filesPath)
	if err != nil {
		return err
	}

	var files []gqlclient.Upload
	for _, item := range items {
		if item.IsDir() {
			continue
		}

		f, err := os.Open(path.Join(filesPath, item.Name()))
		if err != nil {
			return err
		}
		defer f.Close()

		var name string
		if item.Name() != paste.Name {
			name = item.Name()
		}

		files = append(files, gqlclient.Upload{
			Filename: name,
			// MIMEType is not used by the API, except for checking that it is a "text".
			// Parsing the MIME type from the extension would cause issues: ".json" is parsed as "application/json",
			// which gets rejected because it is not a "text/".
			// Since the API does not use the type besides that, always send a dummy text value.
			MIMEType: "text/plain",
			Body:     f,
		})
	}

	if _, err := pastesrht.CreatePaste(ex.client, ctx, files, paste.Visibility); err != nil {
		return fmt.Errorf("failed to create paste: %v", err)
	}
	return nil
}

M export/todo.go => export/todo.go +31 -1
@@ 16,6 16,8 @@ import (
	"git.sr.ht/~emersion/hut/srht/todosrht"
)

const trackerFilename = "tracker.json.gz"

type TodoExporter struct {
	client *gqlclient.Client
	http   *http.Client


@@ 76,7 78,7 @@ func (ex *TodoExporter) exportTracker(ctx context.Context, tracker todosrht.Trac
		return nil
	}

	dataPath := path.Join(base, "tracker.json.gz")
	dataPath := path.Join(base, trackerFilename)
	log.Printf("\t%s", tracker.Name)
	if err := os.MkdirAll(base, 0o755); err != nil {
		return err


@@ 119,3 121,31 @@ func (ex *TodoExporter) exportTracker(ctx context.Context, tracker todosrht.Trac

	return nil
}

func (ex *TodoExporter) ImportResource(ctx context.Context, dir string) error {
	var info TrackerInfo
	if err := readJSON(path.Join(dir, infoFilename), &info); err != nil {
		return err
	}

	return ex.importTracker(ctx, &info, dir)
}

func (ex *TodoExporter) importTracker(ctx context.Context, tracker *TrackerInfo, base string) error {
	f, err := os.Open(path.Join(base, trackerFilename))
	if err != nil {
		return err
	}
	defer f.Close()

	_, err = todosrht.ImportTracker(ex.client, ctx, tracker.Name, tracker.Description, tracker.Visibility, gqlclient.Upload{
		Filename: trackerFilename,
		MIMEType: "application/gzip",
		Body:     f,
	})
	if err != nil {
		return fmt.Errorf("failed to import issue tracker: %v", err)
	}

	return nil
}

A import.go => import.go +84 -0
@@ 0,0 1,84 @@
package main

import (
	"github.com/spf13/cobra"
	"log"
	"os"
	"path/filepath"

	"git.sr.ht/~emersion/hut/export"
)

func newImportCommand() *cobra.Command {
	run := func(cmd *cobra.Command, args []string) {
		importers := make(map[string]export.Exporter)

		mc := createClient("meta", cmd)
		meta := export.NewMetaExporter(mc.Client)
		importers["meta.sr.ht"] = meta

		gc := createClient("git", cmd)
		git := export.NewGitExporter(gc.Client, gc.BaseURL)
		importers["git.sr.ht"] = git

		hc := createClient("hg", cmd)
		hg := export.NewHgExporter(hc.Client, hc.BaseURL)
		importers["hg.sr.ht"] = hg

		pc := createClient("paste", cmd)
		paste := export.NewPasteExporter(pc.Client, pc.HTTP)
		importers["paste.sr.ht"] = paste

		lc := createClient("lists", cmd)
		lists := export.NewListsExporter(lc.Client, lc.HTTP)
		importers["lists.sr.ht"] = lists

		tc := createClient("todo", cmd)
		todo := export.NewTodoExporter(tc.Client, tc.HTTP)
		importers["todo.sr.ht"] = todo

		if _, ok := os.LookupEnv("SSH_AUTH_SOCK"); !ok {
			log.Println("Warning! SSH_AUTH_SOCK is not set in your environment.")
			log.Println("Using an SSH agent is advised to avoid unlocking your SSH keys repeatedly during the import.")
		}

		resources, err := export.FindDirResources(args[0])
		if err != nil {
			log.Fatalf("Failed to find resources to import: %v", err)
		} else if len(resources) == 0 {
			log.Fatal("No data found in directory")
		}

		ctx := cmd.Context()
		log.Println("Importing account data...")

		var lastService string
		for _, res := range resources {
			importer, ok := importers[res.Service]
			if !ok {
				continue // Some services are exported but never imported
			}

			if lastService != res.Service {
				log.Println(res.Service)
				lastService = res.Service
			}

			log.Printf("\t%s", res.Name)
			if err := importer.ImportResource(ctx, filepath.Dir(res.Path)); err != nil {
				log.Printf("Error importing %q: %v", res.Path, err)
			}
		}

		log.Println("Import complete.")
	}
	return &cobra.Command{
		Use:   "import <directory>",
		Short: "Imports your account data",
		Args:  cobra.ExactArgs(1),
		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
			return nil, cobra.ShellCompDirectiveFilterDirs
		},
		Run: run,
	}
}

M main.go => main.go +1 -0
@@ 45,6 45,7 @@ func main() {
	cmd.AddCommand(newGitCommand())
	cmd.AddCommand(newGraphqlCommand())
	cmd.AddCommand(newHgCommand())
	cmd.AddCommand(newImportCommand())
	cmd.AddCommand(newInitCommand())
	cmd.AddCommand(newListsCommand())
	cmd.AddCommand(newMetaCommand())

M srht/gitsrht/gql.go => srht/gitsrht/gql.go +1 -1
@@ 790,7 790,7 @@ func DeleteArtifact(client *gqlclient.Client, ctx context.Context, id int32) (de
}

func CreateRepository(client *gqlclient.Client, ctx context.Context, name string, visibility Visibility, description *string, cloneUrl *string) (createRepository *Repository, err error) {
	op := gqlclient.NewOperation("mutation createRepository ($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {\n\tcreateRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t\tname\n\t}\n}\n")
	op := gqlclient.NewOperation("mutation createRepository ($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {\n\tcreateRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {\n\t\tid\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t\tname\n\t}\n}\n")
	op.Var("name", name)
	op.Var("visibility", visibility)
	op.Var("description", description)

M srht/gitsrht/operations.graphql => srht/gitsrht/operations.graphql +1 -0
@@ 242,6 242,7 @@ mutation createRepository(
        description: $description
        cloneUrl: $cloneUrl
    ) {
        id
        owner {
            canonicalName
        }

M srht/hgsrht/gql.go => srht/hgsrht/gql.go +11 -0
@@ 471,6 471,17 @@ func CreateRepository(client *gqlclient.Client, ctx context.Context, name string
	return respData.CreateRepository, err
}

func UpdateRepository(client *gqlclient.Client, ctx context.Context, id int32, input RepoInput) (updateRepository *Repository, err error) {
	op := gqlclient.NewOperation("mutation updateRepository ($id: Int!, $input: RepoInput!) {\n\tupdateRepository(id: $id, input: $input) {\n\t\tid\n\t}\n}\n")
	op.Var("id", id)
	op.Var("input", input)
	var respData struct {
		UpdateRepository *Repository
	}
	err = client.Execute(ctx, op, &respData)
	return respData.UpdateRepository, err
}

func DeleteRepository(client *gqlclient.Client, ctx context.Context, id int32) (deleteRepository *Repository, err error) {
	op := gqlclient.NewOperation("mutation deleteRepository ($id: Int!) {\n\tdeleteRepository(id: $id) {\n\t\tname\n\t}\n}\n")
	op.Var("id", id)

M srht/hgsrht/operations.graphql => srht/hgsrht/operations.graphql +10 -0
@@ 76,7 76,17 @@ mutation createRepository(
        visibility: $visibility
        description: $description
    ) {
        id
        name
        owner {
            canonicalName
        }
    }
}

mutation updateRepository($id: Int!, $input: RepoInput!) {
    updateRepository(id: $id, input: $input) {
        id
    }
}


M srht/listssrht/gql.go => srht/listssrht/gql.go +23 -1
@@ 983,7 983,7 @@ func DeleteACL(client *gqlclient.Client, ctx context.Context, id int32) (deleteA
}

func CreateMailingList(client *gqlclient.Client, ctx context.Context, name string, description *string, visibility Visibility) (createMailingList *MailingList, err error) {
	op := gqlclient.NewOperation("mutation createMailingList ($name: String!, $description: String, $visibility: Visibility!) {\n\tcreateMailingList(name: $name, description: $description, visibility: $visibility) {\n\t\tname\n\t}\n}\n")
	op := gqlclient.NewOperation("mutation createMailingList ($name: String!, $description: String, $visibility: Visibility!) {\n\tcreateMailingList(name: $name, description: $description, visibility: $visibility) {\n\t\tid\n\t\tname\n\t}\n}\n")
	op.Var("name", name)
	op.Var("description", description)
	op.Var("visibility", visibility)


@@ 994,6 994,28 @@ func CreateMailingList(client *gqlclient.Client, ctx context.Context, name strin
	return respData.CreateMailingList, err
}

func UpdateMailingList(client *gqlclient.Client, ctx context.Context, id int32, input MailingListInput) (updateMailingList *MailingList, err error) {
	op := gqlclient.NewOperation("mutation updateMailingList ($id: Int!, $input: MailingListInput!) {\n\tupdateMailingList(id: $id, input: $input) {\n\t\tid\n\t}\n}\n")
	op.Var("id", id)
	op.Var("input", input)
	var respData struct {
		UpdateMailingList *MailingList
	}
	err = client.Execute(ctx, op, &respData)
	return respData.UpdateMailingList, err
}

func ImportMailingListSpool(client *gqlclient.Client, ctx context.Context, id int32, spool gqlclient.Upload) (importMailingListSpool bool, err error) {
	op := gqlclient.NewOperation("mutation importMailingListSpool ($id: Int!, $spool: Upload!) {\n\timportMailingListSpool(listID: $id, spool: $spool)\n}\n")
	op.Var("id", id)
	op.Var("spool", spool)
	var respData struct {
		ImportMailingListSpool bool
	}
	err = client.Execute(ctx, op, &respData)
	return respData.ImportMailingListSpool, err
}

func CreateUserWebhook(client *gqlclient.Client, ctx context.Context, config UserWebhookInput) (createUserWebhook *WebhookSubscription, err error) {
	op := gqlclient.NewOperation("mutation createUserWebhook ($config: UserWebhookInput!) {\n\tcreateUserWebhook(config: $config) {\n\t\tid\n\t}\n}\n")
	op.Var("config", config)

M srht/listssrht/operations.graphql => srht/listssrht/operations.graphql +11 -0
@@ 341,10 341,21 @@ mutation createMailingList(
        description: $description
        visibility: $visibility
    ) {
        id
        name
    }
}

mutation updateMailingList($id: Int!, $input: MailingListInput!) {
    updateMailingList(id: $id, input: $input) {
        id
    }
}

mutation importMailingListSpool($id: Int!, $spool: Upload!) {
    importMailingListSpool(listID: $id, spool: $spool)
}

mutation createUserWebhook($config: UserWebhookInput!) {
    createUserWebhook(config: $config) {
        id

M srht/todosrht/gql.go => srht/todosrht/gql.go +13 -0
@@ 1396,6 1396,19 @@ func CreateTracker(client *gqlclient.Client, ctx context.Context, name string, d
	return respData.CreateTracker, err
}

func ImportTracker(client *gqlclient.Client, ctx context.Context, name string, description *string, visibility Visibility, dump gqlclient.Upload) (createTracker *Tracker, err error) {
	op := gqlclient.NewOperation("mutation importTracker ($name: String!, $description: String, $visibility: Visibility!, $dump: Upload!) {\n\tcreateTracker(name: $name, description: $description, visibility: $visibility, importUpload: $dump) {\n\t\tid\n\t}\n}\n")
	op.Var("name", name)
	op.Var("description", description)
	op.Var("visibility", visibility)
	op.Var("dump", dump)
	var respData struct {
		CreateTracker *Tracker
	}
	err = client.Execute(ctx, op, &respData)
	return respData.CreateTracker, err
}

func DeleteTicket(client *gqlclient.Client, ctx context.Context, trackerId int32, ticketId int32) (deleteTicket *Ticket, err error) {
	op := gqlclient.NewOperation("mutation deleteTicket ($trackerId: Int!, $ticketId: Int!) {\n\tdeleteTicket(trackerId: $trackerId, ticketId: $ticketId) {\n\t\tsubject\n\t}\n}\n")
	op.Var("trackerId", trackerId)

M srht/todosrht/operations.graphql => srht/todosrht/operations.graphql +16 -0
@@ 592,6 592,22 @@ mutation createTracker(
    }
}

mutation importTracker(
    $name: String!
    $description: String
    $visibility: Visibility!
    $dump: Upload!
) {
    createTracker(
        name: $name
        description: $description
        visibility: $visibility
        importUpload: $dump
    ) {
        id
    }
}

mutation deleteTicket($trackerId: Int!, $ticketId: Int!) {
    deleteTicket(trackerId: $trackerId, ticketId: $ticketId) {
        subject