diff --git a/.markdownlint.yml b/.markdownlint.yml index 2e84c7c1..3b8b8e1b 100644 --- a/.markdownlint.yml +++ b/.markdownlint.yml @@ -55,7 +55,6 @@ proper-names: "git-annex", "git-credential", "git-sizer", - "Git", "Gitaly", "GitHub", "GitLab Geo", diff --git a/commands/ask/ask.go b/commands/ask/ask.go new file mode 100644 index 00000000..977551ee --- /dev/null +++ b/commands/ask/ask.go @@ -0,0 +1,20 @@ +package ask + +import ( + "gitlab.com/gitlab-org/cli/commands/ask/git" + "gitlab.com/gitlab-org/cli/commands/cmdutils" + + "github.com/spf13/cobra" +) + +func NewCmd(f *cmdutils.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "ask prompt", + Short: "Generate terminal commands from natural language. (Experimental.)", + Long: ``, + } + + cmd.AddCommand(git.NewCmd(f)) + + return cmd +} diff --git a/commands/ask/git/git.go b/commands/ask/git/git.go new file mode 100644 index 00000000..0720d832 --- /dev/null +++ b/commands/ask/git/git.go @@ -0,0 +1,210 @@ +package git + +import ( + "encoding/json" + "fmt" + "net/http" + "os/exec" + "regexp" + "strings" + + "gitlab.com/gitlab-org/cli/commands/cmdutils" + "gitlab.com/gitlab-org/cli/internal/run" + "gitlab.com/gitlab-org/cli/pkg/iostreams" + "gitlab.com/gitlab-org/cli/pkg/prompt" + + "github.com/MakeNowJust/heredoc" + "github.com/google/shlex" + "github.com/spf13/cobra" + "github.com/xanzy/go-gitlab" +) + +type request struct { + Prompt string `json:"prompt"` +} + +type response struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +type result struct { + Commands []string `json:"commands"` + Explanation string `json:"explanation"` +} + +type opts struct { + Prompt string + IO *iostreams.IOStreams + HttpClient func() (*gitlab.Client, error) +} + +var cmdRegexp = regexp.MustCompile("`([^`]*)`") + +const ( + runCmdsQuestion = "Would you like to run these Git commands" + gitCmd = "git" + gitCmdAPIPath = "ai/llm/git_command" + spinnerText = "Generating Git commands..." + aiResponseErr = "Error: AI response has not been generated correctly" + apiUnreachableErr = "Error: API is unreachable" +) + +func NewCmd(f *cmdutils.Factory) *cobra.Command { + opts := &opts{ + IO: f.IO, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "git ", + Short: "Generate Git commands from natural language (Experimental).", + Long: heredoc.Doc(` + Generate Git commands from natural language. + + This experimental feature converts natural language descriptions into + executable Git commands. + + We'd love your feedback in [issue 409636](https://gitlab.com/gitlab-org/gitlab/-/issues/409636). + `), + Example: heredoc.Doc(` + $ glab ask git list last 10 commit titles + # => A list of Git commands to show the titles of the latest 10 commits with an explanation and an option to execute the commands. + `), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + + opts.Prompt = strings.Join(args, " ") + + result, err := opts.Result() + if err != nil { + return err + } + + opts.displayResult(result) + + if err := opts.executeCommands(result.Commands); err != nil { + return err + } + + return nil + }, + } + + return cmd +} + +func (opts *opts) Result() (*result, error) { + opts.IO.StartSpinner(spinnerText) + defer opts.IO.StopSpinner("") + + client, err := opts.HttpClient() + if err != nil { + return nil, cmdutils.WrapError(err, "failed to get http client") + } + + body := request{Prompt: opts.Prompt} + request, err := client.NewRequest(http.MethodPost, gitCmdAPIPath, body, nil) + if err != nil { + return nil, cmdutils.WrapError(err, "failed to create a request") + } + + var r response + _, err = client.Do(request, &r) + if err != nil { + return nil, cmdutils.WrapError(err, apiUnreachableErr) + } + + if len(r.Choices) == 0 { + return nil, fmt.Errorf(aiResponseErr) + } + + var result result + if err := json.Unmarshal([]byte(r.Choices[0].Message.Content), &result); err != nil { + return nil, fmt.Errorf(aiResponseErr) + } + + return &result, nil +} + +func (opts *opts) displayResult(result *result) { + color := opts.IO.Color() + + opts.IO.LogInfo(color.Bold("Commands:\n")) + + for _, cmd := range result.Commands { + opts.IO.LogInfo(color.Green(cmd)) + } + + opts.IO.LogInfo(color.Bold("\nExplanation:\n")) + explanation := cmdRegexp.ReplaceAllString(result.Explanation, color.Green("$1")) + opts.IO.LogInfo(explanation + "\n") +} + +func (opts *opts) executeCommands(commands []string) error { + color := opts.IO.Color() + + var confirmed bool + question := color.Bold(runCmdsQuestion) + if err := prompt.Confirm(&confirmed, question, true); err != nil { + return err + } + + if !confirmed { + return nil + } + + for _, command := range commands { + if err := opts.executeCommand(command); err != nil { + return err + } + } + + return nil +} + +func (opts *opts) executeCommand(cmd string) error { + gitArgs, err := shlex.Split(cmd) + if err != nil { + return nil + } + + if gitArgs[0] != gitCmd { + return nil + } + + color := opts.IO.Color() + question := fmt.Sprintf("Run `%s`", color.Green(cmd)) + var confirmed bool + if err := prompt.Confirm(&confirmed, question, true); err != nil { + return err + } + + if !confirmed { + return nil + } + + execCmd := exec.Command("git", gitArgs[1:]...) + output, err := run.PrepareCmd(execCmd).Output() + if err != nil { + return err + } + + if len(output) == 0 { + return nil + } + + if opts.IO.StartPager() != nil { + return fmt.Errorf("failed to start pager: %q", err) + } + defer opts.IO.StopPager() + + opts.IO.LogInfo(string(output)) + + return nil +} diff --git a/commands/ask/git/git_test.go b/commands/ask/git/git_test.go new file mode 100644 index 00000000..d30c98a5 --- /dev/null +++ b/commands/ask/git/git_test.go @@ -0,0 +1,129 @@ +package git + +import ( + "net/http" + "testing" + + "gitlab.com/gitlab-org/cli/commands/cmdtest" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/cli/pkg/httpmock" + "gitlab.com/gitlab-org/cli/pkg/prompt" + "gitlab.com/gitlab-org/cli/test" +) + +func runCommand(rt http.RoundTripper, isTTY bool, args string) (*test.CmdOut, error) { + ios, _, stdout, stderr := cmdtest.InitIOStreams(isTTY, "") + + factory := cmdtest.InitFactory(ios, rt) + + _, _ = factory.HttpClient() + + cmd := NewCmd(factory) + + return cmdtest.ExecuteCommand(cmd, args, stdout, stderr) +} + +func TestGitCmd(t *testing.T) { + outputWithoutExecution := `Commands: + +git log --pretty=format:'%h' +non-git cmd +git show + +Explanation: + +The appropriate Git command for listing commit SHAs. + +` + tests := []struct { + desc string + withExecution bool + expectedResult string + }{ + { + desc: "agree to run commands", + withExecution: true, + expectedResult: outputWithoutExecution + "git log executed\ngit show executed\n", + }, + { + desc: "disagree to run commands", + withExecution: false, + expectedResult: outputWithoutExecution, + }, + } + cmdLogResult := "git log executed" + cmdShowResult := "git show executed" + content := `{\"commands\": [\"git log --pretty=format:'%h'\", \"non-git cmd\", \"git show\"], \"explanation\":\"The appropriate Git command for listing commit SHAs.\"}` + body := `{"choices": [{"message": {"content": "` + content + `"}}]}` + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/llm/git_command", response) + + restore := prompt.StubConfirm(tc.withExecution) + defer restore() + + cs, restore := test.InitCmdStubber() + defer restore() + cs.Stub(cmdLogResult) + cs.Stub(cmdShowResult) + + output, err := runCommand(fakeHTTP, false, "git list 10 commits") + require.Nil(t, err) + + require.Equal(t, tc.expectedResult, output.String()) + require.Empty(t, output.Stderr()) + }) + } +} + +func TestFailedHttpResponse(t *testing.T) { + tests := []struct { + desc string + code int + response string + expectedMsg string + }{ + { + desc: "API error", + code: http.StatusNotFound, + response: `{"message": "Error message"}`, + expectedMsg: "404 {message: Error message}", + }, + { + desc: "Empty response", + code: http.StatusOK, + response: `{"choices": []}`, + expectedMsg: aiResponseErr, + }, + { + desc: "Bad JSON", + code: http.StatusOK, + response: `{"choices": [{"message": {"content": "hello"}}]}`, + expectedMsg: aiResponseErr, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + response := httpmock.NewStringResponse(tc.code, tc.response) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/llm/git_command", response) + + _, err := runCommand(fakeHTTP, false, "git list 10 commits") + require.NotNil(t, err) + require.Contains(t, err.Error(), tc.expectedMsg) + }) + } +} diff --git a/commands/root.go b/commands/root.go index 51a4f616..2dbdbe96 100644 --- a/commands/root.go +++ b/commands/root.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/pflag" aliasCmd "gitlab.com/gitlab-org/cli/commands/alias" apiCmd "gitlab.com/gitlab-org/cli/commands/api" + askCmd "gitlab.com/gitlab-org/cli/commands/ask" authCmd "gitlab.com/gitlab-org/cli/commands/auth" pipelineCmd "gitlab.com/gitlab-org/cli/commands/ci" "gitlab.com/gitlab-org/cli/commands/cmdutils" @@ -120,6 +121,7 @@ func NewCmdRoot(f *cmdutils.Factory, version, buildDate string) *cobra.Command { rootCmd.AddCommand(apiCmd.NewCmdApi(f, nil)) rootCmd.AddCommand(scheduleCmd.NewCmdSchedule(f)) rootCmd.AddCommand(snippetCmd.NewCmdSnippet(f)) + rootCmd.AddCommand(askCmd.NewCmd(f)) rootCmd.Flags().BoolP("version", "v", false, "show glab version information") return rootCmd diff --git a/docs/source/ask/git.md b/docs/source/ask/git.md new file mode 100644 index 00000000..f4933e1d --- /dev/null +++ b/docs/source/ask/git.md @@ -0,0 +1,41 @@ +--- +stage: Create +group: Code Review +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + + + +# `glab ask git` + +Generate Git commands from natural language (Experimental). + +## Synopsis + +Generate Git commands from natural language. + +This experimental feature converts natural language descriptions into +executable Git commands. + +We'd love your feedback in [issue 409636](https://gitlab.com/gitlab-org/gitlab/-/issues/409636). + +```plaintext +glab ask git [flags] +``` + +## Examples + +```plaintext +$ glab ask git list last 10 commit titles +# => A list of Git commands to show the titles of the latest 10 commits with an explanation and an option to execute the commands. + +``` + +## Options inherited from parent commands + +```plaintext + --help Show help for command +``` diff --git a/docs/source/ask/help.md b/docs/source/ask/help.md new file mode 100644 index 00000000..9072abe8 --- /dev/null +++ b/docs/source/ask/help.md @@ -0,0 +1,24 @@ +--- +stage: Create +group: Code Review +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + + + +# `glab ask help` + +Help about any command + +```plaintext +glab ask help [command] [flags] +``` + +## Options inherited from parent commands + +```plaintext + --help Show help for command +``` diff --git a/docs/source/ask/index.md b/docs/source/ask/index.md new file mode 100644 index 00000000..05498cb5 --- /dev/null +++ b/docs/source/ask/index.md @@ -0,0 +1,24 @@ +--- +stage: Create +group: Code Review +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + + + +# `glab ask` + +Generate terminal commands from natural language. (Experimental.) + +## Options inherited from parent commands + +```plaintext + --help Show help for command +``` + +## Subcommands + +- [git](git.md) diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 237e34ee..8839fe10 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -30,6 +30,7 @@ type IOStreams struct { pagerCommand string pagerProcess *os.Process + systemStdOut io.Writer spinner *spinner.Spinner @@ -55,6 +56,7 @@ func Init() *IOStreams { In: os.Stdin, StdOut: NewColorable(os.Stdout), StdErr: NewColorable(os.Stderr), + systemStdOut: NewColorable(os.Stdout), pagerCommand: pagerCommand, IsaTTY: stdoutIsTTY, IsErrTTY: stderrIsTTY, @@ -165,6 +167,12 @@ func (s *IOStreams) StartPager() error { return err } s.pagerProcess = pagerCmd.Process + + go func() { + _, _ = s.pagerProcess.Wait() + _ = pipeWriter.Close() + }() + return nil } @@ -175,6 +183,7 @@ func (s *IOStreams) StopPager() { _ = s.StdOut.(io.WriteCloser).Close() _, _ = s.pagerProcess.Wait() + s.StdOut = s.systemStdOut s.pagerProcess = nil }