From 241050b0c2987166edd6a13f37ba9de465c14446 Mon Sep 17 00:00:00 2001 From: Erik Seliger Date: Mon, 6 Mar 2023 12:30:51 +0100 Subject: [PATCH] Implement CLI for ingested codeowners (#943) --- cmd/src/codeowners.go | 67 ++++++++++++++++++++ cmd/src/codeowners_create.go | 118 +++++++++++++++++++++++++++++++++++ cmd/src/codeowners_delete.go | 86 +++++++++++++++++++++++++ cmd/src/codeowners_get.go | 87 ++++++++++++++++++++++++++ cmd/src/codeowners_update.go | 116 ++++++++++++++++++++++++++++++++++ cmd/src/main.go | 1 + 6 files changed, 475 insertions(+) create mode 100644 cmd/src/codeowners.go create mode 100644 cmd/src/codeowners_create.go create mode 100644 cmd/src/codeowners_delete.go create mode 100644 cmd/src/codeowners_get.go create mode 100644 cmd/src/codeowners_update.go diff --git a/cmd/src/codeowners.go b/cmd/src/codeowners.go new file mode 100644 index 0000000000..47b9cee19b --- /dev/null +++ b/cmd/src/codeowners.go @@ -0,0 +1,67 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" +) + +var codeownersCommands commander + +func init() { + usage := `'src codeowners' is a tool that manages ingested code ownership data in a Sourcegraph instance. + +Usage: + + src codeowners command [command options] + +The commands are: + + get returns the codeowners file for a repository, if exists + create create a codeowners file + update update a codeowners file + delete delete a codeowners file + +Use "src codeowners [command] -h" for more information about a command. +` + + flagSet := flag.NewFlagSet("codeowners", flag.ExitOnError) + handler := func(args []string) error { + codeownersCommands.run(flagSet, "src codeowners", usage, args) + return nil + } + + // Register the command. + commands = append(commands, &command{ + flagSet: flagSet, + aliases: []string{"codeowner"}, + handler: handler, + usageFunc: func() { + fmt.Println(usage) + }, + }) +} + +const codeownersFragment = ` +fragment CodeownersFileFields on CodeownersIngestedFile { + contents + repository { + name + } +} +` + +type CodeownersIngestedFile struct { + Contents string `json:"contents"` + Repository struct { + Name string `json:"name"` + } `json:"repository"` +} + +func readFile(f string) (io.Reader, error) { + if f == "-" { + return os.Stdin, nil + } + return os.Open(f) +} diff --git a/cmd/src/codeowners_create.go b/cmd/src/codeowners_create.go new file mode 100644 index 0000000000..7daf00ba21 --- /dev/null +++ b/cmd/src/codeowners_create.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + "github.com/sourcegraph/sourcegraph/lib/errors" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +func init() { + usage := ` +Examples: + + Create a codeowners file for the repository "github.com/sourcegraph/sourcegraph": + + $ src codeowners create -repo='github.com/sourcegraph/sourcegraph' -f CODEOWNERS + + Create a codeowners file for the repository "github.com/sourcegraph/sourcegraph" from stdin: + + $ src codeowners create -repo='github.com/sourcegraph/sourcegraph' -f - +` + + flagSet := flag.NewFlagSet("create", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src codeowners %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + var ( + repoFlag = flagSet.String("repo", "", "The repository to attach the data to") + fileFlag = flagSet.String("file", "", "File path to read ownership information from (- for stdin)") + fileShortFlag = flagSet.String("f", "", "File path to read ownership information from (- for stdin). Alias for -file") + + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + if *repoFlag == "" { + return errors.New("provide a repo name using -repo") + } + + if *fileFlag == "" && *fileShortFlag == "" { + return errors.New("provide a file using -file") + } + if *fileFlag != "" && *fileShortFlag != "" { + return errors.New("have to provide either -file or -f") + } + if *fileShortFlag != "" { + *fileFlag = *fileShortFlag + } + + file, err := readFile(*fileFlag) + if err != nil { + return err + } + + content, err := io.ReadAll(file) + if err != nil { + return err + } + + client := cfg.apiClient(apiFlags, flagSet.Output()) + + query := `mutation CreateCodeownersFile( + $repoName: String!, + $content: String! +) { + addCodeownersFile(input: { + repoName: $repoName, + fileContents: $content, + } + ) { + ...CodeownersFileFields + } +} +` + codeownersFragment + + var result struct { + AddCodeownersFile CodeownersIngestedFile + } + if ok, err := client.NewRequest(query, map[string]interface{}{ + "repoName": *repoFlag, + "content": string(content), + }).Do(context.Background(), &result); err != nil || !ok { + var gqlErr api.GraphQlErrors + if errors.As(err, &gqlErr) { + for _, e := range gqlErr { + if strings.Contains(e.Error(), "repo not found:") { + return cmderrors.ExitCode(2, errors.Newf("repository %q not found", *repoFlag)) + } + if strings.Contains(e.Error(), "codeowners file has already been ingested for this repository") { + return cmderrors.ExitCode(2, errors.New("codeowners file has already been ingested for this repository")) + } + } + } + return err + } + + return nil + } + + // Register the command. + codeownersCommands = append(codeownersCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/codeowners_delete.go b/cmd/src/codeowners_delete.go new file mode 100644 index 0000000000..00ab581023 --- /dev/null +++ b/cmd/src/codeowners_delete.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "flag" + "fmt" + "strings" + + "github.com/sourcegraph/sourcegraph/lib/errors" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +func init() { + usage := ` +Examples: + + Delete a codeowners file for the repository "github.com/sourcegraph/sourcegraph": + + $ src codeowners delete -repo='github.com/sourcegraph/sourcegraph' +` + + flagSet := flag.NewFlagSet("delete", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src codeowners %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + var ( + repoFlag = flagSet.String("repo", "", "The repository to delete the data for") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + if *repoFlag == "" { + return errors.New("provide a repo name using -repo") + } + + client := cfg.apiClient(apiFlags, flagSet.Output()) + + query := `mutation DeleteCodeownersFile( + $repoName: String!, +) { + deleteCodeownersFiles(repositories: [{ + repoName: $repoName, + }]) { + alwaysNil + } +} +` + + var result struct { + DeleteCodeownersFile CodeownersIngestedFile + } + if ok, err := client.NewRequest(query, map[string]interface{}{ + "repoName": *repoFlag, + }).Do(context.Background(), &result); err != nil || !ok { + var gqlErr api.GraphQlErrors + if errors.As(err, &gqlErr) { + for _, e := range gqlErr { + if strings.Contains(e.Error(), "repo not found:") { + return cmderrors.ExitCode(2, errors.Newf("repository %q not found", *repoFlag)) + } + if strings.Contains(e.Error(), "codeowners file not found:") { + return cmderrors.ExitCode(2, errors.Newf("no data found for repository %q", *repoFlag)) + } + } + } + return err + } + + return nil + } + + // Register the command. + codeownersCommands = append(codeownersCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/codeowners_get.go b/cmd/src/codeowners_get.go new file mode 100644 index 0000000000..ce4e78a855 --- /dev/null +++ b/cmd/src/codeowners_get.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/sourcegraph/sourcegraph/lib/errors" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +func init() { + usage := ` +Examples: + + Read the current codeowners file for the repository "github.com/sourcegraph/sourcegraph": + + $ src codeowners get -repo='github.com/sourcegraph/sourcegraph' +` + + flagSet := flag.NewFlagSet("get", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src codeowners %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + var ( + repoFlag = flagSet.String("repo", "", "The repository to attach the data to") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + if *repoFlag == "" { + return errors.New("provide a repo name using -repo") + } + + client := cfg.apiClient(apiFlags, flagSet.Output()) + + query := `query GetCodeownersFile( + $repoName: String! +) { + repository(name: $repoName) { + ingestedCodeowners { + ...CodeownersFileFields + } + } +} +` + codeownersFragment + + var result struct { + Repository *struct { + IngestedCodeowners *CodeownersIngestedFile + } + } + if ok, err := client.NewRequest(query, map[string]interface{}{ + "repoName": *repoFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + if result.Repository == nil { + return cmderrors.ExitCode(2, errors.Newf("repository %q not found", *repoFlag)) + } + + if result.Repository.IngestedCodeowners == nil { + return cmderrors.ExitCode(2, errors.Newf("no codeowners data found for %q", *repoFlag)) + } + + fmt.Fprintf(os.Stdout, "%s", result.Repository.IngestedCodeowners.Contents) + + return nil + } + + // Register the command. + codeownersCommands = append(codeownersCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/codeowners_update.go b/cmd/src/codeowners_update.go new file mode 100644 index 0000000000..d27292cd0c --- /dev/null +++ b/cmd/src/codeowners_update.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + "github.com/sourcegraph/sourcegraph/lib/errors" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +func init() { + usage := ` +Examples: + + Update a codeowners file for the repository "github.com/sourcegraph/sourcegraph": + + $ src codeowners update -repo='github.com/sourcegraph/sourcegraph' -f CODEOWNERS + + Update a codeowners file for the repository "github.com/sourcegraph/sourcegraph" from stdin: + + $ src codeowners update -repo='github.com/sourcegraph/sourcegraph' -f - +` + + flagSet := flag.NewFlagSet("update", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src codeowners %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + var ( + repoFlag = flagSet.String("repo", "", "The repository to attach the data to") + fileFlag = flagSet.String("file", "", "File path to read ownership information from (- for stdin)") + fileShortFlag = flagSet.String("f", "", "File path to read ownership information from (- for stdin). Alias for -file") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + if *repoFlag == "" { + return errors.New("provide a repo name using -repo") + } + + if *fileFlag == "" && *fileShortFlag == "" { + return errors.New("provide a file using -file") + } + if *fileFlag != "" && *fileShortFlag != "" { + return errors.New("have to provide either -file or -f") + } + if *fileShortFlag != "" { + *fileFlag = *fileShortFlag + } + + file, err := readFile(*fileFlag) + if err != nil { + return err + } + + content, err := io.ReadAll(file) + if err != nil { + return err + } + + client := cfg.apiClient(apiFlags, flagSet.Output()) + + query := `mutation UpdateCodeownersFile( + $repoName: String!, + $content: String! +) { + updateCodeownersFile(input: { + repoName: $repoName, + fileContents: $content, + }) { + ...CodeownersFileFields + } +} +` + codeownersFragment + + var result struct { + UpdateCodeownersFile CodeownersIngestedFile + } + if ok, err := client.NewRequest(query, map[string]interface{}{ + "repoName": *repoFlag, + "content": string(content), + }).Do(context.Background(), &result); err != nil || !ok { + var gqlErr api.GraphQlErrors + if errors.As(err, &gqlErr) { + for _, e := range gqlErr { + if strings.Contains(e.Error(), "repo not found:") { + return cmderrors.ExitCode(2, errors.Newf("repository %q not found", *repoFlag)) + } + if strings.Contains(e.Error(), "could not update codeowners file: codeowners file not found:") { + return cmderrors.ExitCode(2, errors.New("no codeowners data has been found for this repository")) + } + } + } + return err + } + + return nil + } + + // Register the command. + codeownersCommands = append(codeownersCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/main.go b/cmd/src/main.go index 177a622672..269f20b424 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -47,6 +47,7 @@ The commands are: serve-git serves your local git repositories over HTTP for Sourcegraph to pull upload upload an index to a Sourcegraph instance users,user manages users + codeowners manages code ownership information version display and compare the src-cli version against the recommended version for your instance Use "src [command] -h" for more information about a command.