feat: add command that generates Git commands from natural language

This commit is contained in:
Igor Drozdov 2023-05-04 02:54:41 +00:00 committed by Jay McCure
parent f7de92dda2
commit 1dca58075a
9 changed files with 459 additions and 1 deletions

View File

@ -55,7 +55,6 @@ proper-names:
"git-annex",
"git-credential",
"git-sizer",
"Git",
"Gitaly",
"GitHub",
"GitLab Geo",

20
commands/ask/ask.go Normal file
View File

@ -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 <command> prompt",
Short: "Generate terminal commands from natural language. (Experimental.)",
Long: ``,
}
cmd.AddCommand(git.NewCmd(f))
return cmd
}

210
commands/ask/git/git.go Normal file
View File

@ -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 <prompt>",
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
}

View File

@ -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)
})
}
}

View File

@ -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

41
docs/source/ask/git.md Normal file
View File

@ -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
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly. Run `make gen-docs` instead.
-->
# `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 <prompt> [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
```

24
docs/source/ask/help.md Normal file
View File

@ -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
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly. Run `make gen-docs` instead.
-->
# `glab ask help`
Help about any command
```plaintext
glab ask help [command] [flags]
```
## Options inherited from parent commands
```plaintext
--help Show help for command
```

24
docs/source/ask/index.md Normal file
View File

@ -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
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly. Run `make gen-docs` instead.
-->
# `glab ask`
Generate terminal commands from natural language. (Experimental.)
## Options inherited from parent commands
```plaintext
--help Show help for command
```
## Subcommands
- [git](git.md)

View File

@ -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
}