From 0a6162ad05900394d381eef9ec05704b3941c5c4 Mon Sep 17 00:00:00 2001 From: John Olheiser Date: Fri, 17 Apr 2020 03:59:12 +0000 Subject: [PATCH] Add release support and CSV output (#16) Add CSV output Refactor requireToken and add release creation Start releases Signed-off-by: jolheiser Co-authored-by: jolheiser Reviewed-on: https://gitea.com/jolheiser/sip/pulls/16 --- .drone.yml | 1 + Makefile | 4 ++ cmd/cmd.go | 56 +++++++++++++------------- cmd/issues.go | 78 ++++++++++++++++++++++++++---------- cmd/issues_create.go | 4 +- cmd/pulls.go | 6 +++ cmd/pulls_checkout.go | 7 +++- cmd/pulls_create.go | 4 +- cmd/pulls_status.go | 6 ++- cmd/release.go | 80 +++++++++++++++++++++++++++++++++++++ cmd/release_create.go | 87 ++++++++++++++++++++++++++++++++++++++++ cmd/repo.go | 5 ++- cmd/repo_create.go | 4 +- go.mod | 2 +- go.sum | 8 +--- main.go | 1 + modules/csv/csv.go | 93 +++++++++++++++++++++++++++++++++++++++++++ 17 files changed, 379 insertions(+), 67 deletions(-) create mode 100644 cmd/release.go create mode 100644 cmd/release_create.go create mode 100644 modules/csv/csv.go diff --git a/.drone.yml b/.drone.yml index b2337b5..f92bf32 100644 --- a/.drone.yml +++ b/.drone.yml @@ -27,6 +27,7 @@ steps: GOPROXY: https://goproxy.cn commands: - make lint + - make vet --- kind: pipeline diff --git a/Makefile b/Makefile index 1a39649..1d47a79 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,10 @@ lint: fi golangci-lint run --timeout 5m +.PHONY: vet +vet: + $(GO) vet ./... + .PHONY: fmt fmt: $(GO) fmt ./... diff --git a/cmd/cmd.go b/cmd/cmd.go index 38b1e57..f3e1f02 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -54,31 +54,6 @@ var ( upstreamRepo []string ) -func requireToken(ctx *cli.Context) (string, error) { - if ctx.IsSet("token") { - return getToken(ctx.String("token")), nil - } - if len(config.Tokens) == 0 { - return "", errors.New(color.Error.Wrap("No tokens found! Add one with #{sip token create}", color.New(color.FgMagenta))) - } - tokenMap := make(map[string]config.Token) - opts := make([]string, len(config.Tokens)) - for idx, token := range config.Tokens { - key := fmt.Sprintf("%s (%s)", token.Name, token.URL) - tokenMap[key] = token - opts[idx] = key - } - - question := &survey.Select{Message: "This action requires an access token", Options: opts} - var answer string - - if err := survey.AskOne(question, &answer); err != nil { - return "", err - } - - return tokenMap[answer].Token, nil -} - func getToken(name string) string { for _, token := range config.Tokens { if name == token.Name { @@ -88,8 +63,35 @@ func getToken(name string) string { return "" } -func getClient(ctx *cli.Context) *gitea.Client { - return gitea.NewClient(ctx.String("url"), getToken(ctx.String("token"))) +func getClient(ctx *cli.Context, requireToken bool) (*gitea.Client, error) { + if ctx.IsSet("token") { + return gitea.NewClient(ctx.String("url"), getToken(ctx.String("token"))), nil + } + + var token string + if requireToken { + if len(config.Tokens) == 0 { + return nil, errors.New(color.Error.Wrap("No tokens found! Add one with #{sip token create}", color.New(color.FgMagenta))) + } + tokenMap := make(map[string]config.Token) + opts := make([]string, len(config.Tokens)) + for idx, token := range config.Tokens { + key := fmt.Sprintf("%s (%s)", token.Name, token.URL) + tokenMap[key] = token + opts[idx] = key + } + + question := &survey.Select{Message: "This action requires an access token", Options: opts} + var answer string + + if err := survey.AskOne(question, &answer); err != nil { + return nil, err + } + + token = tokenMap[answer].Token + } + + return gitea.NewClient(ctx.String("url"), token), nil } func defRemote(remote, def string) string { diff --git a/cmd/issues.go b/cmd/issues.go index f36ba9e..34ea414 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -4,12 +4,14 @@ import ( "code.gitea.io/sdk/gitea" "errors" "fmt" + "gitea.com/jolheiser/sip/modules/csv" "gitea.com/jolheiser/sip/modules/markdown" "gitea.com/jolheiser/sip/modules/sdk" "github.com/AlecAivazis/survey/v2" "github.com/urfave/cli/v2" "go.jolheiser.com/beaver" "go.jolheiser.com/beaver/color" + "os" "strconv" "strings" ) @@ -22,6 +24,12 @@ var Issues = cli.Command{ Subcommands: []*cli.Command{ &IssuesCreate, }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "csv", + Usage: "Output results to a CSV file", + }, + }, } func doIssuesSearch(ctx *cli.Context) error { @@ -32,11 +40,16 @@ func doIssuesSearch(ctx *cli.Context) error { } func issuesSearch(ctx *cli.Context, pulls bool) (*gitea.Issue, error) { + client, err := getClient(ctx, false) + if err != nil { + return nil, err + } + typ := "issues" if pulls { typ = "pulls" } - issues, err := queryIssues(ctx, getClient(ctx), pulls) + issues, err := queryIssues(ctx, client, pulls) if err != nil { return nil, err } @@ -46,6 +59,18 @@ func issuesSearch(ctx *cli.Context, pulls bool) (*gitea.Issue, error) { return nil, nil } + if ctx.String("csv") != "" { + fi, err := os.Create(ctx.String("csv")) + if err != nil { + return nil, err + } + if _, err := fi.WriteString(csv.Issues(issues)); err != nil { + return nil, err + } + fmt.Println(color.FgCyan.Formatf("Matching %s were exported to", typ), color.Info.Format(ctx.String("csv"))) + return nil, fi.Close() + } + issueMap := make(map[string]*gitea.Issue) for _, issue := range issues { index := color.New(color.FgCyan).Format("#" + strconv.Itoa(int(issue.Index))) @@ -87,29 +112,20 @@ func issuesSearch(ctx *cli.Context, pulls bool) (*gitea.Issue, error) { } func queryIssues(ctx *cli.Context, client *gitea.Client, pulls bool) ([]*gitea.Issue, error) { - questions := []*survey.Question{ - { - Name: "repo", - Prompt: &survey.Input{Message: "Full repository name", Default: fullName(ctx)}, - Validate: validateFullName, - }, - { - Name: "query", - Prompt: &survey.Input{Message: "Search query"}, - }, - } - answers := struct { - Repo string - Query string - }{} - - if err := survey.Ask(questions, &answers); err != nil { + owner, repo, err := askOwnerRepo(ctx) + if err != nil { return nil, err } - filter := sdk.NewIssueFilter(answers.Query) - ownerRepo := strings.Split(answers.Repo, "/") + + question := &survey.Input{Message: "Search query"} + var answer string + if err := survey.AskOne(question, &answer); err != nil { + return nil, err + } + + filter := sdk.NewIssueFilter(answer) opts := gitea.ListIssueOption{KeyWord: filter.Query, State: "all"} - issues, err := sdk.GetIssues(client, ownerRepo[0], ownerRepo[1], opts) + issues, err := sdk.GetIssues(client, owner, repo, opts) if err != nil { return nil, err } @@ -132,6 +148,26 @@ func queryIssues(ctx *cli.Context, client *gitea.Client, pulls bool) ([]*gitea.I return filtered, nil } +func askOwnerRepo(ctx *cli.Context) (string, string, error) { + question := []*survey.Question{ + { + Name: "repo", + Prompt: &survey.Input{Message: "Full repository name", Default: fullName(ctx)}, + Validate: validateFullName, + }, + } + answer := struct { + Repo string + }{} + + if err := survey.Ask(question, &answer); err != nil { + return "", "", err + } + + ownerRepo := strings.Split(answer.Repo, "/") + return ownerRepo[0], ownerRepo[1], nil +} + func validateFullName(ans interface{}) error { fullName := ans.(string) ownerRepo := strings.Split(fullName, "/") diff --git a/cmd/issues_create.go b/cmd/issues_create.go index d2c6977..7818fa5 100644 --- a/cmd/issues_create.go +++ b/cmd/issues_create.go @@ -21,7 +21,7 @@ func doIssueCreate(ctx *cli.Context) error { url := color.New(color.FgYellow).Format(fmt.Sprintf("%s/%s/%s", ctx.String("url"), ctx.String("owner"), ctx.String("repo"))) fmt.Println(color.New(color.FgCyan).Format("Creating a new issue for"), url) - token, err := requireToken(ctx) + client, err := getClient(ctx, true) if err != nil { return err } @@ -65,8 +65,6 @@ func doIssueCreate(ctx *cli.Context) error { } } - client := gitea.NewClient(ctx.String("url"), token) - issue, err := client.CreateIssue(ctx.String("owner"), ctx.String("repo"), gitea.CreateIssueOption{Title: title, Body: body}) if err != nil { return err diff --git a/cmd/pulls.go b/cmd/pulls.go index 456037d..c42fb19 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -14,6 +14,12 @@ var Pulls = cli.Command{ &PullsStatus, &PullsCheckout, }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "csv", + Usage: "Output results to a CSV file", + }, + }, } func doPullsSearch(ctx *cli.Context) error { diff --git a/cmd/pulls_checkout.go b/cmd/pulls_checkout.go index 1e55d04..c13807e 100644 --- a/cmd/pulls_checkout.go +++ b/cmd/pulls_checkout.go @@ -21,6 +21,11 @@ var PullsCheckout = cli.Command{ } func doPullCheckout(ctx *cli.Context) error { + client, err := getClient(ctx, true) + if err != nil { + return err + } + var issue *gitea.Issue questions := []*survey.Question{ { @@ -50,7 +55,7 @@ func doPullCheckout(ctx *cli.Context) error { } } } else { - iss, err := getClient(ctx).GetIssue(upstreamRepo[1], upstreamRepo[2], prNum.Index) + iss, err := client.GetIssue(upstreamRepo[1], upstreamRepo[2], prNum.Index) if err != nil { return err } diff --git a/cmd/pulls_create.go b/cmd/pulls_create.go index bd5d70f..cf0867e 100644 --- a/cmd/pulls_create.go +++ b/cmd/pulls_create.go @@ -22,13 +22,11 @@ func doPullCreate(ctx *cli.Context) error { url := color.New(color.FgYellow).Format(fmt.Sprintf("%s/%s/%s", ctx.String("url"), ctx.String("owner"), ctx.String("repo"))) fmt.Println(color.New(color.FgCyan).Format("Creating a new pull request for"), url) - token, err := requireToken(ctx) + client, err := getClient(ctx, true) if err != nil { return err } - client := gitea.NewClient(ctx.String("url"), token) - upstreams, err := client.ListRepoBranches(getUpstreamRepo()[1], getUpstreamRepo()[2]) if err != nil { return err diff --git a/cmd/pulls_status.go b/cmd/pulls_status.go index 64b78a8..a38c611 100644 --- a/cmd/pulls_status.go +++ b/cmd/pulls_status.go @@ -17,7 +17,11 @@ var PullsStatus = cli.Command{ } func doPullStatus(ctx *cli.Context) error { - client := getClient(ctx) + client, err := getClient(ctx, false) + if err != nil { + return err + } + head := fmt.Sprintf("%s:%s", getOriginRepo()[1], git.Branch()) pulls, err := sdk.GetPulls(client, getUpstreamRepo()[1], getUpstreamRepo()[2], gitea.ListPullRequestsOptions{State: "all"}) diff --git a/cmd/release.go b/cmd/release.go new file mode 100644 index 0000000..320a65d --- /dev/null +++ b/cmd/release.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "code.gitea.io/sdk/gitea" + "fmt" + "gitea.com/jolheiser/sip/modules/csv" + "gitea.com/jolheiser/sip/modules/markdown" + "github.com/AlecAivazis/survey/v2" + "github.com/urfave/cli/v2" + "go.jolheiser.com/beaver/color" + "os" +) + +var Release = cli.Command{ + Name: "releases", + Aliases: []string{"release"}, + Usage: "Commands for interacting with releases", + Action: doRelease, + Subcommands: []*cli.Command{ + &ReleaseCreate, + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "csv", + Usage: "Output results to a CSV file", + }, + }, +} + +func doRelease(ctx *cli.Context) error { + client, err := getClient(ctx, false) + if err != nil { + return err + } + + owner, repo, err := askOwnerRepo(ctx) + if err != nil { + return err + } + + releases, err := client.ListReleases(owner, repo) + if err != nil { + return err + } + + if ctx.String("csv") != "" { + fi, err := os.Create(ctx.String("csv")) + if err != nil { + return err + } + if _, err := fi.WriteString(csv.Releases(releases)); err != nil { + return err + } + fmt.Println(color.FgCyan.Format("Releases were exported to"), color.Info.Format(ctx.String("csv"))) + return fi.Close() + } + + releaseMap := make(map[string]*gitea.Release) + releaseList := make([]string, len(releases)) + for idx, release := range releases { + key := fmt.Sprintf("%s (%s)", release.Title, release.TagName) + releaseMap[key] = release + releaseList[idx] = key + } + + sel := &survey.Select{Options: releaseList, Message: "Releases"} + var selection string + if err := survey.AskOne(sel, &selection); err != nil { + return err + } + + note, err := markdown.Render(releaseMap[selection].Note) + if err != nil { + return err + } + + fmt.Println(note) + fmt.Printf("Release Date: %s\n", releaseMap[selection].PublishedAt.Format(csv.TimeFormat)) + return nil +} diff --git a/cmd/release_create.go b/cmd/release_create.go new file mode 100644 index 0000000..27bc2a6 --- /dev/null +++ b/cmd/release_create.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "code.gitea.io/sdk/gitea" + "fmt" + "gitea.com/jolheiser/sip/modules/git" + "github.com/AlecAivazis/survey/v2" + "github.com/urfave/cli/v2" + "go.jolheiser.com/beaver/color" +) + +var ReleaseCreate = cli.Command{ + Name: "create", + Aliases: []string{"new"}, + Usage: "Create a new release", + Action: doReleaseCreate, +} + +func doReleaseCreate(ctx *cli.Context) error { + client, err := getClient(ctx, true) + if err != nil { + return err + } + + questions := []*survey.Question{ + { + Name: "tag", + Prompt: &survey.Input{Message: "Tag Name"}, + Validate: survey.Required, + }, + { + Name: "target", + Prompt: &survey.Input{Message: "Target", Default: git.Branch(), Help: "Target ref, branch name or commit SHA"}, + Validate: survey.Required, + }, + { + Name: "title", + Prompt: &survey.Input{Message: "Title"}, + Validate: survey.Required, + }, + { + Name: "note", + Prompt: &survey.Multiline{Message: "Notes"}, + }, + { + Name: "draft", + Prompt: &survey.Confirm{Message: "Draft", Default: false}, + Validate: survey.Required, + }, + { + Name: "pre", + Prompt: &survey.Confirm{Message: "Pre-Release", Default: false}, + Validate: survey.Required, + }, + } + answers := struct { + Tag string + Target string + Title string + Note string + Draft bool + Pre bool + }{} + + if err := survey.Ask(questions, &answers); err != nil { + return err + } + + release, err := client.CreateRelease(ctx.String("owner"), ctx.String("repo"), gitea.CreateReleaseOption{ + TagName: answers.Tag, + Target: answers.Target, + Title: answers.Title, + Note: answers.Note, + IsDraft: answers.Draft, + IsPrerelease: answers.Pre, + }) + if err != nil { + return err + } + + info := color.Info + cyan := color.New(color.FgCyan) + fmt.Println(info.Format("Release"), cyan.Format(release.TagName), info.Format("created!")) + // TODO Change to specific release page once supported + fmt.Println(cyan.Format(fmt.Sprintf("%s/%s/%s/releases/", ctx.String("url"), ctx.String("owner"), ctx.String("repo")))) + return nil +} diff --git a/cmd/repo.go b/cmd/repo.go index 2d51cb6..4e514fa 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -19,7 +19,10 @@ var Repo = cli.Command{ } func doRepo(ctx *cli.Context) error { - client := getClient(ctx) + client, err := getClient(ctx, false) + if err != nil { + return err + } issues, err := sdk.GetIssues(client, ctx.String("owner"), ctx.String("repo"), gitea.ListIssueOption{State: "open"}) if err != nil { diff --git a/cmd/repo_create.go b/cmd/repo_create.go index aaeea0a..4b591fc 100644 --- a/cmd/repo_create.go +++ b/cmd/repo_create.go @@ -16,13 +16,11 @@ var RepoCreate = cli.Command{ } func doRepoCreate(ctx *cli.Context) error { - token, err := requireToken(ctx) + client, err := getClient(ctx, true) if err != nil { return err } - client := gitea.NewClient(ctx.String("url"), token) - questions := []*survey.Question{ { Name: "name", diff --git a/go.mod b/go.mod index c23face..d8f1fec 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module gitea.com/jolheiser/sip go 1.13 require ( - code.gitea.io/sdk/gitea v0.11.0 + code.gitea.io/sdk/gitea v0.11.2 github.com/AlecAivazis/survey/v2 v2.0.5 github.com/BurntSushi/toml v0.3.1 github.com/charmbracelet/glamour v0.1.0 diff --git a/go.sum b/go.sum index 05d5532..5e6a8bc 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -code.gitea.io/sdk/gitea v0.11.0 h1:XgZtmImZsjMC+Z1WBfO6bYTCOJiGp+7w0HKmfhTwytw= -code.gitea.io/sdk/gitea v0.11.0/go.mod h1:z3uwDV/b9Ls47NGukYM9XhnHtqPh/J+t40lsUrR6JDY= -go.jolheiser.com/beaver v1.0.0 h1:IfNMhp7+DUaM0kaNwho4RWfuebCsa8A/kxtZBngFjHk= -go.jolheiser.com/beaver v1.0.0/go.mod h1:2mUGl6ZGKY/Y9u36iR4bqOPrHhr4C22cxkR8ei2G06I= +code.gitea.io/sdk/gitea v0.11.2 h1:D0xIRlHv3IckzdYOWzHK1bPvlkXdA4LD909UYyBdi1o= +code.gitea.io/sdk/gitea v0.11.2/go.mod h1:z3uwDV/b9Ls47NGukYM9XhnHtqPh/J+t40lsUrR6JDY= github.com/AlecAivazis/survey/v2 v2.0.5 h1:xpZp+Q55wi5C7Iaze+40onHnEkex1jSc34CltJjOoPM= github.com/AlecAivazis/survey/v2 v2.0.5/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= @@ -127,8 +125,6 @@ golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4 h1:sfkvUWPNGwSV+8/fNqctR5lS2AqCSqYwXdrjCxp/dXo= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index f9d0b02..1edc83e 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ func main() { &cmd.Repo, &cmd.Issues, &cmd.Pulls, + &cmd.Release, } app.Flags = cmd.Flags app.EnableBashCompletion = true diff --git a/modules/csv/csv.go b/modules/csv/csv.go new file mode 100644 index 0000000..e4e3e4f --- /dev/null +++ b/modules/csv/csv.go @@ -0,0 +1,93 @@ +package csv + +import ( + "fmt" + "sort" + "strings" + + "code.gitea.io/sdk/gitea" +) + +const TimeFormat = "Jan 2, 2006 at 03:04 PM" + +func Issues(issues []*gitea.Issue) string { + headers := "ID,Index,Author,Title,Body,Labels,Milestone,Assignee,State,Created,Updated,Closed\r\n" + + // Sort the issues coming in + sort.Slice(issues, func(i, j int) bool { + return issues[i].ID < issues[j].ID + }) + + rows := make([]string, 0) + for _, issue := range issues { + cols := make([]string, 12) + cols[0] = fmt.Sprintf("%d", issue.ID) + cols[1] = fmt.Sprintf("%d", issue.Index) + if issue.Poster != nil { + cols[2] = issue.Poster.UserName + } + cols[3] = issue.Title + cols[4] = issue.Body + labels := make([]string, 0) + for _, lbl := range issue.Labels { + labels = append(labels, lbl.Name) + } + cols[5] = strings.Join(labels, ",") + if issue.Milestone != nil { + cols[6] = issue.Milestone.Title + } + if issue.Assignee != nil { + cols[7] = issue.Assignee.UserName + } + state := string(issue.State) + if issue.PullRequest != nil && issue.PullRequest.HasMerged { + state = "merged" + } + cols[8] = state + cols[9] = issue.Created.Format(TimeFormat) + cols[10] = issue.Updated.Format(TimeFormat) + if issue.Closed != nil { + cols[11] = issue.Closed.Format(TimeFormat) + } + for idx, col := range cols { + cols[idx] = escape(col) + } + rows = append(rows, strings.Join(cols, ",")) + } + return headers + strings.Join(rows, "\r\n") +} + +func Releases(releases []*gitea.Release) string { + headers := "ID,Tag Name,Target,Title,Body,Draft,Pre-Release,Created,Published,Publisher\r\n" + + // Sort the releases coming in + sort.Slice(releases, func(i, j int) bool { + return releases[i].ID < releases[j].ID + }) + + rows := make([]string, 0) + for _, release := range releases { + cols := make([]string, 10) + cols[0] = fmt.Sprintf("%d", release.ID) + cols[1] = release.TagName + cols[2] = release.Target + cols[3] = release.Title + cols[4] = release.Note + cols[5] = fmt.Sprintf("%t", release.IsDraft) + cols[6] = fmt.Sprintf("%t", release.IsPrerelease) + cols[7] = release.CreatedAt.Format(TimeFormat) + cols[8] = release.PublishedAt.Format(TimeFormat) + if release.Publisher != nil { + cols[9] = release.Publisher.UserName + } + for idx, col := range cols { + cols[idx] = escape(col) + } + rows = append(rows, strings.Join(cols, ",")) + } + return headers + strings.Join(rows, "\r\n") +} + +func escape(input string) string { + return fmt.Sprintf(`"%s"`, strings.ReplaceAll(input, `"`, `""`)) +}