mirror of https://gitlab.com/gitlab-org/cli.git
feat: add command that generates Git commands from natural language
This commit is contained in:
parent
f7de92dda2
commit
1dca58075a
|
@ -55,7 +55,6 @@ proper-names:
|
|||
"git-annex",
|
||||
"git-credential",
|
||||
"git-sizer",
|
||||
"Git",
|
||||
"Gitaly",
|
||||
"GitHub",
|
||||
"GitLab Geo",
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
```
|
|
@ -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
|
||||
```
|
|
@ -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)
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue