├── .gitignore ├── internal ├── cli │ ├── testdata │ │ ├── repo │ │ │ ├── file1.txt │ │ │ ├── file2.log │ │ │ └── binary_file.png │ │ ├── repo_with_ignore │ │ │ ├── file1.txt │ │ │ └── .gitignore │ │ ├── repo_with_aiignore │ │ │ ├── .aiignore │ │ │ ├── file1.txt │ │ │ └── ai_ignored_file.txt │ │ ├── repo_with_binaries │ │ │ ├── file1.txt │ │ │ └── binary_file.png │ │ └── test.txt │ ├── export_test.go │ ├── cli_test.go │ ├── dump_test.go │ ├── dump.go │ └── cli.go ├── openai │ ├── testdata │ │ ├── chat_fail.json │ │ └── chat_ok.json │ ├── client_test.go │ └── client.go └── gemini │ ├── testdata │ └── gemini_success.json │ ├── client_test.go │ └── client.go ├── go.mod ├── main.go ├── .github └── workflows │ ├── security.yml │ ├── go.yml │ ├── release.yml │ ├── codeql.yml │ └── auto-review.yml ├── .goreleaser.yml ├── Makefile ├── renovate.json ├── LICENSE ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | -------------------------------------------------------------------------------- /internal/cli/testdata/repo/file1.txt: -------------------------------------------------------------------------------- 1 | This is a text file. 2 | -------------------------------------------------------------------------------- /internal/cli/testdata/repo_with_ignore/file1.txt: -------------------------------------------------------------------------------- 1 | This is a text file. -------------------------------------------------------------------------------- /internal/cli/testdata/repo_with_aiignore/.aiignore: -------------------------------------------------------------------------------- 1 | ai_ignored_file.txt -------------------------------------------------------------------------------- /internal/cli/testdata/repo_with_aiignore/file1.txt: -------------------------------------------------------------------------------- 1 | This is a text file. -------------------------------------------------------------------------------- /internal/cli/testdata/repo_with_binaries/file1.txt: -------------------------------------------------------------------------------- 1 | This is a text file. -------------------------------------------------------------------------------- /internal/cli/testdata/repo_with_ignore/.gitignore: -------------------------------------------------------------------------------- 1 | ignored_file.txt 2 | *.log -------------------------------------------------------------------------------- /internal/cli/testdata/test.txt: -------------------------------------------------------------------------------- 1 | This is a test. 2 | Another line without dot 3 | -------------------------------------------------------------------------------- /internal/cli/testdata/repo/file2.log: -------------------------------------------------------------------------------- 1 | The output represents a Git repository's content 2 | -------------------------------------------------------------------------------- /internal/cli/testdata/repo_with_aiignore/ai_ignored_file.txt: -------------------------------------------------------------------------------- 1 | This file should be ignored by .aiignore. -------------------------------------------------------------------------------- /internal/cli/testdata/repo/binary_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catatsuy/bento/HEAD/internal/cli/testdata/repo/binary_file.png -------------------------------------------------------------------------------- /internal/cli/testdata/repo_with_binaries/binary_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catatsuy/bento/HEAD/internal/cli/testdata/repo_with_binaries/binary_file.png -------------------------------------------------------------------------------- /internal/openai/testdata/chat_fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "message": "The model `gpt-3` does not exist", 4 | "type": "invalid_request_error", 5 | "param": null, 6 | "code": "model_not_found" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/catatsuy/bento 2 | 3 | go 1.25.1 4 | 5 | require ( 6 | github.com/google/go-cmp v0.7.0 7 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 8 | golang.org/x/term v0.38.0 9 | ) 10 | 11 | require golang.org/x/sys v0.39.0 // indirect 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/catatsuy/bento/internal/cli" 7 | "golang.org/x/term" 8 | ) 9 | 10 | func main() { 11 | cl := cli.NewCLI(os.Stdout, os.Stderr, os.Stdin, nil, term.IsTerminal(int(os.Stdin.Fd()))) 12 | os.Exit(cl.Run(os.Args)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/gemini/testdata/gemini_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gemini123", 3 | "object": "chat.completion", 4 | "created": 1677652288, 5 | "model": "gemini-2.0-flash-lite", 6 | "choices": [ 7 | { 8 | "index": 0, 9 | "message": { 10 | "role": "assistant", 11 | "content": "Here's a joke: Why did the chicken cross the road? To get to the other side!" 12 | }, 13 | "finish_reason": "completed" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: govulncheck 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | schedule: 9 | - cron: "0 0 * * *" 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | govulncheck_job: 16 | runs-on: ubuntu-latest 17 | name: Run govulncheck 18 | steps: 19 | - id: govulncheck 20 | uses: golang/govulncheck-action@v1 21 | with: 22 | go-version-input: 1.25.5 23 | go-package: ./... 24 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: bento 2 | before: 3 | hooks: 4 | - go mod tidy 5 | builds: 6 | - main: ./main.go 7 | binary: bento 8 | ldflags: 9 | - -s -w 10 | - -X github.com/catatsuy/bento/internal/cli.Version=v{{.Version}} 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - darwin 15 | - linux 16 | goarch: 17 | - amd64 18 | - arm64 19 | archives: 20 | - name_template: '{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}' 21 | release: 22 | prerelease: auto 23 | -------------------------------------------------------------------------------- /internal/openai/testdata/chat_ok.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "resp_67b73f697ba4819183a15cc17d011509", 3 | "output": [ 4 | { 5 | "id": "msg_67b73f697ba4819183a15cc17d011509", 6 | "type": "message", 7 | "role": "assistant", 8 | "content": [ 9 | { 10 | "type": "text", 11 | "text": "\n\nHello there, how may I assist you today?", 12 | "annotations": [] 13 | } 14 | ] 15 | } 16 | ], 17 | "usage": { 18 | "prompt_tokens": 9, 19 | "completion_tokens": 12, 20 | "total_tokens": 21 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: bin/bento 3 | 4 | go.mod go.sum: 5 | go mod tidy 6 | 7 | bin/bento: main.go go.mod $(wildcard internal/**/*.go) 8 | go build -ldflags "-X github.com/catatsuy/bento/internal/cli.Version=`git rev-list HEAD -n1`" -o bin/bento main.go 9 | 10 | .PHONY: test 11 | test: 12 | go test -cover -count 1 ./... 13 | 14 | .PHONY: vet 15 | vet: 16 | go vet ./... 17 | 18 | .PHONY: errcheck 19 | errcheck: 20 | errcheck ./... 21 | 22 | .PHONY: staticcheck 23 | staticcheck: 24 | staticcheck -checks="all,-ST1000" ./... 25 | 26 | .PHONY: clean 27 | clean: 28 | rm -rf bin/* 29 | -------------------------------------------------------------------------------- /internal/cli/export_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "context" 4 | 5 | type MockTranslator struct { 6 | TranslateTextFunc func(ctx context.Context, systemPrompt, prompt, text, model string) (string, error) 7 | } 8 | 9 | func (m *MockTranslator) request(ctx context.Context, systemPrompt, prompt, text, model string) (string, error) { 10 | return m.TranslateTextFunc(ctx, systemPrompt, prompt, text, model) 11 | } 12 | 13 | func (c *CLI) MultiRequest(ctx context.Context, systemPrompt, prompt, useModel string, limit int) error { 14 | return c.multiRequest(ctx, systemPrompt, prompt, useModel, limit) 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v6 23 | with: 24 | go-version: 1.25.5 25 | 26 | - name: Build 27 | run: make 28 | 29 | - name: Test 30 | run: make test 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | 7 | permissions: 8 | contents: write # Required for creating releases and uploading assets 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v6 16 | - name: Setup Go 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: 1.25.5 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v6 22 | with: 23 | version: latest 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "regexManagers": [ 6 | { 7 | "fileMatch": [ 8 | ".github/workflows/go.yml" 9 | ], 10 | "datasourceTemplate": "golang-version", 11 | "depNameTemplate": "golang", 12 | "matchStrings": [ 13 | "go-version: (?[0-9]*.[0-9]*.[0-9]*)" 14 | ] 15 | }, 16 | { 17 | "fileMatch": [ 18 | ".github/workflows/security.yml" 19 | ], 20 | "datasourceTemplate": "golang-version", 21 | "depNameTemplate": "golang", 22 | "matchStrings": [ 23 | "go-version-input: (?[0-9]*.[0-9]*.[0-9]*)" 24 | ] 25 | }, 26 | { 27 | "fileMatch": [ 28 | ".github/workflows/release.yml" 29 | ], 30 | "datasourceTemplate": "golang-version", 31 | "depNameTemplate": "golang", 32 | "matchStrings": [ 33 | "go-version: (?[0-9]*.[0-9]*.[0-9]*)" 34 | ] 35 | } 36 | ], 37 | "postUpdateOptions": [ 38 | "gomodTidy", 39 | "gomodUpdateImportPaths" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tatsuya Kaneko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 4 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= 8 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 11 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 12 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 13 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 14 | golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 15 | golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 18 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /internal/cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | . "github.com/catatsuy/bento/internal/cli" 12 | ) 13 | 14 | func TestRun_versionFlg(t *testing.T) { 15 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 16 | cl := NewCLI(outStream, errStream, inputStream, nil, false) 17 | 18 | args := strings.Split("bento -version", " ") 19 | status := cl.Run(args) 20 | 21 | if status != ExitCodeOK { 22 | t.Errorf("ExitStatus=%d, want %d", status, ExitCodeOK) 23 | } 24 | 25 | expected := fmt.Sprintf("bento version %s", Version) 26 | if !strings.Contains(errStream.String(), expected) { 27 | t.Errorf("Output=%q, want %q", errStream.String(), expected) 28 | } 29 | } 30 | 31 | func TestRun_helpSuccess(t *testing.T) { 32 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 33 | cl := NewCLI(outStream, errStream, inputStream, nil, false) 34 | 35 | args := strings.Split("bento -help", " ") 36 | status := cl.Run(args) 37 | 38 | if status != ExitCodeOK { 39 | t.Errorf("ExitStatus=%d, want %d", status, ExitCodeOK) 40 | } 41 | 42 | expected := fmt.Sprintf("bento version %s", Version) 43 | if !strings.Contains(errStream.String(), expected) { 44 | t.Errorf("Output=%q, want %q", errStream.String(), expected) 45 | } 46 | } 47 | 48 | func TestRun_hSuccess(t *testing.T) { 49 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 50 | cl := NewCLI(outStream, errStream, inputStream, nil, false) 51 | 52 | args := strings.Split("bento -h", " ") 53 | status := cl.Run(args) 54 | 55 | if status != ExitCodeOK { 56 | t.Errorf("ExitStatus=%d, want %d", status, ExitCodeOK) 57 | } 58 | 59 | expected := fmt.Sprintf("bento version %s", Version) 60 | if !strings.Contains(errStream.String(), expected) { 61 | t.Errorf("Output=%q, want %q", errStream.String(), expected) 62 | } 63 | } 64 | 65 | func TestCLI_translateFile(t *testing.T) { 66 | ctx := context.TODO() 67 | 68 | tmpFile, err := os.Open("testdata/test.txt") 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | defer tmpFile.Close() 73 | 74 | mockTranslator := &MockTranslator{ 75 | TranslateTextFunc: func(ctx context.Context, systemPrompt, prompt, text, model string) (string, error) { 76 | return "これはテストです。\nドットなしの別の行", nil 77 | }, 78 | } 79 | 80 | outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) 81 | cl := NewCLI(outStream, errStream, tmpFile, mockTranslator, false) 82 | 83 | err = cl.MultiRequest(ctx, "", "", "test", 1000) 84 | if err != nil { 85 | t.Errorf("unexpected error: %v", err) 86 | } 87 | 88 | expected := "これはテストです。\nドットなしの別の行\n" 89 | if outStream.String() != expected { 90 | t.Errorf("expected %q, but got %q", expected, outStream.String()) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internal/cli/dump_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/catatsuy/bento/internal/cli" 9 | ) 10 | 11 | func TestRunDump(t *testing.T) { 12 | repoPath := "testdata/repo" 13 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 14 | cli := NewCLI(outStream, errStream, inputStream, nil, false) 15 | 16 | err := cli.RunDump(repoPath, "") 17 | if err != nil { 18 | t.Fatalf("RunDump failed: %v", err) 19 | } 20 | 21 | if !strings.Contains(outStream.String(), "This is a text file.") { 22 | t.Errorf("expected header to be included in output") 23 | } 24 | 25 | if !strings.Contains(outStream.String(), "--END--") { 26 | t.Errorf("output should end with --END--") 27 | } 28 | } 29 | 30 | func TestRunDumpWithIgnorePatterns(t *testing.T) { 31 | repoPath := "testdata/repo_with_ignore" 32 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 33 | cli := NewCLI(outStream, errStream, inputStream, nil, false) 34 | 35 | err := cli.RunDump(repoPath, "") 36 | if err != nil { 37 | t.Fatalf("RunDump failed: %v", err) 38 | } 39 | 40 | if strings.Contains(outStream.String(), "----\nignored_file.txt") { 41 | t.Errorf("ignored files should not be included in the output") 42 | } 43 | } 44 | 45 | func TestRunDumpIgnoresBinaryFiles(t *testing.T) { 46 | repoPath := "testdata/repo_with_binaries" 47 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 48 | cli := NewCLI(outStream, errStream, inputStream, nil, false) 49 | 50 | err := cli.RunDump(repoPath, "") 51 | if err != nil { 52 | t.Fatalf("RunDump failed: %v", err) 53 | } 54 | 55 | if strings.Contains(outStream.String(), "binary_file.png") { 56 | t.Errorf("binary files should not be included in the output") 57 | } 58 | } 59 | 60 | func TestRunDumpAIIgnore(t *testing.T) { 61 | repoPath := "testdata/repo_with_aiignore" 62 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 63 | cli := NewCLI(outStream, errStream, inputStream, nil, false) 64 | 65 | err := cli.RunDump(repoPath, "") 66 | if err != nil { 67 | t.Fatalf("RunDump failed: %v", err) 68 | } 69 | 70 | if strings.Contains(outStream.String(), "----\nai_ignored_file.txt") { 71 | t.Errorf("files specified in .aiignore should not be included in the output") 72 | } 73 | } 74 | 75 | func TestRunDump_WithDescription(t *testing.T) { 76 | repoPath := "testdata/repo" 77 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 78 | cli := NewCLI(outStream, errStream, inputStream, nil, false) 79 | 80 | description := "This is a test description." 81 | err := cli.RunDump(repoPath, description) 82 | if err != nil { 83 | t.Fatalf("RunDump failed: %v", err) 84 | } 85 | 86 | // Check that the output includes the description 87 | if !strings.Contains(outStream.String(), description) { 88 | t.Fatalf("Expected description %q to be in output, got: %q", description, outStream.String()) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["main"] 20 | schedule: 21 | - cron: "23 0 * * 6" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go", "actions"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v6 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v4 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v4 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v4 72 | with: 73 | category: "/language:${{matrix.language}}" 74 | -------------------------------------------------------------------------------- /internal/openai/client_test.go: -------------------------------------------------------------------------------- 1 | package openai_test 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | 12 | . "github.com/catatsuy/bento/internal/openai" 13 | "github.com/google/go-cmp/cmp" 14 | ) 15 | 16 | func TestPostText_Success(t *testing.T) { 17 | muxAPI := http.NewServeMux() 18 | testAPIServer := httptest.NewServer(muxAPI) 19 | defer testAPIServer.Close() 20 | 21 | param := &Payload{ 22 | Model: "gpt-3.5-turbo", 23 | Input: "Hello. I am a student.", 24 | } 25 | 26 | token := "token" 27 | 28 | muxAPI.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 29 | contentType := r.Header.Get("Content-Type") 30 | expectedType := "application/json" 31 | if contentType != expectedType { 32 | t.Fatalf("Content-Type expected %s, but %s", expectedType, contentType) 33 | } 34 | 35 | authorization := r.Header.Get("Authorization") 36 | expectedAuth := "Bearer " + token 37 | if authorization != expectedAuth { 38 | t.Fatalf("Authorization expected %s, but %s", expectedAuth, authorization) 39 | } 40 | 41 | bodyBytes, err := io.ReadAll(r.Body) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | defer r.Body.Close() 46 | 47 | actualBody := &Payload{} 48 | err = json.Unmarshal(bodyBytes, actualBody) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | if !reflect.DeepEqual(actualBody, param) { 54 | t.Fatalf("expected %q to equal %q", actualBody, param) 55 | } 56 | 57 | http.ServeFile(w, r, "testdata/chat_ok.json") 58 | }) 59 | 60 | c, err := NewClient(testAPIServer.URL, token) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | res, err := c.Chat(t.Context(), param) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | expected := &Response{ 71 | ID: "resp_67b73f697ba4819183a15cc17d011509", 72 | Output: []OutputMessage{ 73 | { 74 | ID: "msg_67b73f697ba4819183a15cc17d011509", 75 | Type: "message", 76 | Role: "assistant", 77 | Content: []Content{ 78 | { 79 | Type: "text", 80 | Text: "\n\nHello there, how may I assist you today?", 81 | Annotations: []string{}, 82 | }, 83 | }, 84 | }, 85 | }, 86 | Usage: Usage{ 87 | PromptTokens: 9, 88 | CompletionTokens: 12, 89 | TotalTokens: 21, 90 | }, 91 | } 92 | 93 | if diff := cmp.Diff(res, expected); diff != "" { 94 | t.Errorf("file list mismatch (-actual +expected):\n%s", diff) 95 | } 96 | } 97 | 98 | func TestPostText_Fail(t *testing.T) { 99 | muxAPI := http.NewServeMux() 100 | testAPIServer := httptest.NewServer(muxAPI) 101 | defer testAPIServer.Close() 102 | 103 | param := &Payload{ 104 | Model: "gpt-3", 105 | Input: "Hello. I am a student.", 106 | } 107 | 108 | muxAPI.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 109 | w.WriteHeader(http.StatusNotFound) 110 | http.ServeFile(w, r, "testdata/chat_fail.json") 111 | }) 112 | 113 | c, err := NewClient(testAPIServer.URL, "token") 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | _, err = c.Chat(t.Context(), param) 119 | 120 | if err == nil { 121 | t.Fatal("expected error, but nothing was returned") 122 | } 123 | 124 | expected := "status code: 404" 125 | if !strings.Contains(err.Error(), expected) { 126 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /internal/gemini/client_test.go: -------------------------------------------------------------------------------- 1 | package gemini_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | 13 | . "github.com/catatsuy/bento/internal/gemini" 14 | "github.com/google/go-cmp/cmp" 15 | ) 16 | 17 | func TestChat_Success(t *testing.T) { 18 | mux := http.NewServeMux() 19 | server := httptest.NewServer(mux) 20 | defer server.Close() 21 | 22 | // Use the Gemini API default model now. 23 | param := &Payload{ 24 | Model: "gemini-2.0-flash-lite", 25 | Messages: []Message{ 26 | { 27 | Role: "user", 28 | Content: "Tell me a joke.", 29 | }, 30 | }, 31 | } 32 | 33 | token := "test-token" 34 | 35 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 36 | // Check headers. 37 | if r.Header.Get("Content-Type") != "application/json" { 38 | t.Fatalf("Content-Type expected 'application/json', got %s", r.Header.Get("Content-Type")) 39 | } 40 | expectedAuth := "Bearer " + token 41 | if r.Header.Get("Authorization") != expectedAuth { 42 | t.Fatalf("Authorization expected '%s', got %s", expectedAuth, r.Header.Get("Authorization")) 43 | } 44 | 45 | // Check the request body. 46 | bodyBytes, err := io.ReadAll(r.Body) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | defer r.Body.Close() 51 | 52 | actualPayload := &Payload{} 53 | if err := json.Unmarshal(bodyBytes, actualPayload); err != nil { 54 | t.Fatal(err) 55 | } 56 | if !reflect.DeepEqual(actualPayload, param) { 57 | t.Fatalf("expected %+v, got %+v", param, actualPayload) 58 | } 59 | 60 | // Serve the success response from the testdata file. 61 | http.ServeFile(w, r, "testdata/gemini_success.json") 62 | }) 63 | 64 | client, err := NewClient(server.URL, token) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | res, err := client.Chat(context.Background(), param) 70 | if err != nil { 71 | t.Fatal(err) 72 | } 73 | 74 | expected := &Response{ 75 | ID: "gemini123", 76 | Object: "chat.completion", 77 | Created: 1677652288, 78 | Model: "gemini-2.0-flash-lite", 79 | Choices: []Choice{ 80 | { 81 | Index: 0, 82 | Message: Message{ 83 | Role: "assistant", 84 | Content: "Here's a joke: Why did the chicken cross the road? To get to the other side!", 85 | }, 86 | FinishReason: "completed", 87 | }, 88 | }, 89 | } 90 | 91 | if diff := cmp.Diff(expected, res); diff != "" { 92 | t.Errorf("mismatch (-expected +actual):\n%s", diff) 93 | } 94 | } 95 | 96 | func TestChat_Fail(t *testing.T) { 97 | mux := http.NewServeMux() 98 | server := httptest.NewServer(mux) 99 | defer server.Close() 100 | 101 | param := &Payload{ 102 | Model: "gemini-2.0-flash-lite", 103 | Messages: []Message{ 104 | { 105 | Role: "user", 106 | Content: "Tell me a fortune.", 107 | }, 108 | }, 109 | } 110 | token := "test-token" 111 | 112 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 113 | w.WriteHeader(http.StatusNotFound) 114 | // Serve a failure response from the testdata file. 115 | http.ServeFile(w, r, "testdata/gemini_fail.json") 116 | }) 117 | 118 | client, err := NewClient(server.URL, token) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | _, err = client.Chat(context.Background(), param) 124 | if err == nil { 125 | t.Fatal("expected error, got nil") 126 | } 127 | if !strings.Contains(err.Error(), "status code: 404") { 128 | t.Fatalf("expected error to contain 'status code: 404', got %s", err.Error()) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /internal/gemini/client.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "time" 12 | ) 13 | 14 | // GeminiAPIURL is the correct API endpoint for Gemini. 15 | var GeminiAPIURL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" 16 | 17 | // Client handles requests to the Gemini API. 18 | type Client struct { 19 | URL *url.URL 20 | HTTPClient *http.Client 21 | APIKey string 22 | } 23 | 24 | // Payload is the request body for the Gemini API. 25 | // Note the use of "messages" to match the API specification. 26 | type Payload struct { 27 | Model string `json:"model"` 28 | Messages []Message `json:"messages"` 29 | } 30 | 31 | // Message represents a chat message. 32 | type Message struct { 33 | Role string `json:"role"` 34 | Content string `json:"content"` 35 | } 36 | 37 | // Response is the API response from Gemini. 38 | type Response struct { 39 | ID string `json:"id"` 40 | Object string `json:"object"` 41 | Created int64 `json:"created"` 42 | Model string `json:"model"` 43 | Choices []Choice `json:"choices"` 44 | } 45 | 46 | // Choice represents an answer choice in the response. 47 | type Choice struct { 48 | Index int `json:"index"` 49 | Message Message `json:"message"` 50 | FinishReason string `json:"finish_reason"` 51 | } 52 | 53 | // NewClient creates a new Gemini client. 54 | func NewClient(urlStr, apiKey string) (*Client, error) { 55 | if urlStr == "" { 56 | return nil, fmt.Errorf("gemini client: missing url") 57 | } 58 | if apiKey == "" { 59 | return nil, fmt.Errorf("gemini client: missing api key") 60 | } 61 | parsedURL, err := url.ParseRequestURI(urlStr) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to parse url %s: %w", urlStr, err) 64 | } 65 | return &Client{ 66 | URL: parsedURL, 67 | HTTPClient: &http.Client{Timeout: 30 * time.Second}, 68 | APIKey: apiKey, 69 | }, nil 70 | } 71 | 72 | // newRequest creates a new HTTP request. 73 | func (c *Client) newRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) { 74 | req, err := http.NewRequest(method, c.URL.String(), body) 75 | if err != nil { 76 | return nil, err 77 | } 78 | req = req.WithContext(ctx) 79 | return req, nil 80 | } 81 | 82 | // Chat sends a request to the Gemini API and returns the response. 83 | func (c *Client) Chat(ctx context.Context, param *Payload) (*Response, error) { 84 | if len(param.Messages) == 0 || param.Messages[0].Content == "" { 85 | return nil, fmt.Errorf("missing message content") 86 | } 87 | 88 | b, err := json.Marshal(param) 89 | if err != nil { 90 | return nil, fmt.Errorf("marshal error: %w", err) 91 | } 92 | 93 | req, err := c.newRequest(ctx, http.MethodPost, bytes.NewBuffer(b)) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | req.Header.Set("Authorization", "Bearer "+c.APIKey) 99 | req.Header.Set("Content-Type", "application/json") 100 | 101 | res, err := c.HTTPClient.Do(req) 102 | if err != nil { 103 | return nil, err 104 | } 105 | defer res.Body.Close() 106 | 107 | if res.StatusCode != http.StatusOK { 108 | bodyBytes, err := io.ReadAll(res.Body) 109 | if err != nil { 110 | return nil, fmt.Errorf("read body error: %w", err) 111 | } 112 | return nil, fmt.Errorf("status code: %d; body: %s", res.StatusCode, bodyBytes) 113 | } 114 | 115 | response := &Response{} 116 | err = json.NewDecoder(res.Body).Decode(response) 117 | if err != nil { 118 | return nil, fmt.Errorf("decode error: %w", err) 119 | } 120 | 121 | return response, nil 122 | } 123 | -------------------------------------------------------------------------------- /internal/openai/client.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "time" 12 | ) 13 | 14 | var ( 15 | OpenAIAPIURL = "https://api.openai.com/v1/responses" 16 | ) 17 | 18 | type Client struct { 19 | URL *url.URL 20 | HTTPClient *http.Client 21 | APIKey string 22 | } 23 | 24 | type Payload struct { 25 | Model string `json:"model"` 26 | Input string `json:"input,omitempty"` // Can also be an array of Message objects 27 | Instructions string `json:"instructions,omitempty"` 28 | } 29 | 30 | type Message struct { 31 | Role string `json:"role"` 32 | Content string `json:"content"` 33 | } 34 | 35 | type Response struct { 36 | ID string `json:"id"` 37 | Output []OutputMessage `json:"output"` 38 | Usage Usage `json:"usage"` 39 | } 40 | 41 | type OutputMessage struct { 42 | ID string `json:"id"` 43 | Type string `json:"type"` 44 | Role string `json:"role"` 45 | Content []Content `json:"content"` 46 | } 47 | 48 | type Content struct { 49 | Type string `json:"type"` 50 | Text string `json:"text"` 51 | Annotations []string `json:"annotations"` 52 | } 53 | 54 | type Usage struct { 55 | PromptTokens int `json:"prompt_tokens"` 56 | CompletionTokens int `json:"completion_tokens"` 57 | TotalTokens int `json:"total_tokens"` 58 | } 59 | 60 | // OutputText returns the first text content from the response 61 | func (r *Response) OutputText() string { 62 | for _, output := range r.Output { 63 | if output.Type == "message" { 64 | for _, content := range output.Content { 65 | if content.Type == "text" || content.Type == "output_text" { 66 | return content.Text 67 | } 68 | } 69 | } 70 | } 71 | return "" 72 | } 73 | 74 | func NewClient(urlStr, apiKey string) (*Client, error) { 75 | if len(urlStr) == 0 { 76 | return nil, fmt.Errorf("client: missing url") 77 | } 78 | 79 | if len(apiKey) == 0 { 80 | return nil, fmt.Errorf("client: missing api key") 81 | } 82 | 83 | parsedURL, err := url.ParseRequestURI(urlStr) 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to parse url: %s: %w", urlStr, err) 86 | } 87 | 88 | client := &Client{ 89 | URL: parsedURL, 90 | HTTPClient: &http.Client{Timeout: 30 * time.Second}, 91 | APIKey: apiKey, 92 | } 93 | 94 | return client, nil 95 | } 96 | 97 | func (c *Client) newRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) { 98 | u := *c.URL 99 | 100 | req, err := http.NewRequest(method, u.String(), body) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | req = req.WithContext(ctx) 106 | 107 | return req, nil 108 | } 109 | 110 | func (c *Client) Chat(ctx context.Context, param *Payload) (*Response, error) { 111 | if param.Input == "" { 112 | return nil, nil 113 | } 114 | 115 | b, _ := json.Marshal(param) 116 | 117 | req, err := c.newRequest(ctx, http.MethodPost, bytes.NewBuffer(b)) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | req.Header.Set("Authorization", "Bearer "+c.APIKey) 123 | req.Header.Set("Content-Type", "application/json") 124 | 125 | res, err := c.HTTPClient.Do(req) 126 | if err != nil { 127 | return nil, err 128 | } 129 | defer res.Body.Close() 130 | 131 | if res.StatusCode != http.StatusOK { 132 | b, err := io.ReadAll(res.Body) 133 | if err != nil { 134 | return nil, fmt.Errorf("failed to read res.Body and the status code of the response from slack was not 200: %w", err) 135 | } 136 | return nil, fmt.Errorf("status code: %d; body: %s", res.StatusCode, b) 137 | } 138 | 139 | response := &Response{} 140 | err = json.NewDecoder(res.Body).Decode(response) 141 | if err != nil { 142 | return nil, fmt.Errorf("failed to decode response body: %w", err) 143 | } 144 | 145 | return response, nil 146 | } 147 | -------------------------------------------------------------------------------- /.github/workflows/auto-review.yml: -------------------------------------------------------------------------------- 1 | name: Auto Review 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | review: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout code with shallow clone 20 | uses: actions/checkout@v6 21 | with: 22 | fetch-depth: 1 23 | fetch-tags: false 24 | - name: Download bento 25 | run: | 26 | # Download and extract the bento binary 27 | curl -sL https://github.com/catatsuy/bento/releases/latest/download/bento-linux-amd64.tar.gz | tar xz -C /tmp 28 | sudo mv /tmp/bento /usr/local/bin/ 29 | 30 | - name: Fetch necessary commits 31 | run: | 32 | # Get base and head commit SHAs from the pull_request event payload 33 | BASE_SHA="${{ github.event.pull_request.base.sha }}" 34 | HEAD_SHA="${{ github.event.pull_request.head.sha }}" 35 | echo "Base SHA: ${BASE_SHA}" 36 | echo "Head SHA: ${HEAD_SHA}" 37 | # Use the GitHub Compare API to get the merge base commit SHA 38 | MERGE_BASE_SHA=$(curl -s \ 39 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 40 | -H "Accept: application/vnd.github+json" \ 41 | "https://api.github.com/repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}" | jq -r '.merge_base_commit.sha') 42 | # Exit if the merge base SHA is not found 43 | if [ "$MERGE_BASE_SHA" = "null" ] || [ -z "$MERGE_BASE_SHA" ]; then 44 | echo "Error: Could not retrieve merge base commit SHA." 45 | exit 1 46 | fi 47 | echo "Merge base commit SHA: ${MERGE_BASE_SHA}" 48 | echo "MERGE_BASE_SHA=${MERGE_BASE_SHA}" >> $GITHUB_ENV 49 | # Fetch the merge base commit if it is not already present in the shallow clone 50 | git fetch --depth=1 origin ${MERGE_BASE_SHA} 51 | 52 | - name: Check for diffs and set SKIP_REVIEW 53 | run: | 54 | # Retrieve the merge base commit SHA from the environment variable 55 | MERGE_BASE=${{ env.MERGE_BASE_SHA }} 56 | echo "Using merge base: ${MERGE_BASE}" 57 | # Generate a diff from the merge base to HEAD 58 | DIFF_OUTPUT=$(git diff ${MERGE_BASE} ${HEAD_SHA} -- ':!go.sum') 59 | DIFF_LINES=$(echo "$DIFF_OUTPUT" | wc -l) 60 | # If no changes are detected, skip review 61 | if [ -z "$DIFF_OUTPUT" ]; then 62 | echo "No changes detected, skipping review." 63 | echo "SKIP_REVIEW=true" >> $GITHUB_ENV 64 | exit 0 65 | # If the diff is too large (more than 500 lines), skip review 66 | elif [ "$DIFF_LINES" -gt 500 ]; then 67 | echo "Diff too large (${DIFF_LINES} lines), skipping review." 68 | echo "SKIP_REVIEW=true" >> $GITHUB_ENV 69 | exit 0 70 | fi 71 | 72 | - name: Run Review 73 | if: env.SKIP_REVIEW != 'true' 74 | env: 75 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 76 | run: | 77 | # Use the merge base commit to calculate the diff for review 78 | MERGE_BASE=${{ env.MERGE_BASE_SHA }} 79 | git diff ${MERGE_BASE} ${HEAD_SHA} -- ':!go.sum' | bento -model gpt-4.1-mini -review > review.txt 80 | # Set the review contents as an environment variable 81 | REVIEW_CONTENT=$(cat review.txt) 82 | echo "REVIEW_CONTENT<> $GITHUB_ENV 83 | echo '### Automatic Review' >> $GITHUB_ENV 84 | echo "$REVIEW_CONTENT" >> $GITHUB_ENV 85 | echo "EOF" >> $GITHUB_ENV 86 | 87 | - name: Find Existing Comment 88 | if: env.SKIP_REVIEW != 'true' 89 | env: 90 | GH_TOKEN: ${{ github.token }} 91 | run: | 92 | gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ 93 | --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("Automatic Review")) | .id' \ 94 | > comment_id.txt 95 | if [ -s comment_id.txt ]; then 96 | echo "comment_id=$(cat comment_id.txt)" >> $GITHUB_ENV 97 | fi 98 | 99 | - name: Post Review as a Comment 100 | if: env.SKIP_REVIEW != 'true' 101 | env: 102 | GH_TOKEN: ${{ github.token }} 103 | REVIEW_BODY: ${{ env.REVIEW_CONTENT }} 104 | run: | 105 | if [ -n "${{ env.comment_id }}" ]; then 106 | # Update existing comment 107 | gh api \ 108 | -X PATCH \ 109 | repos/${{ github.repository }}/issues/comments/${{ env.comment_id }} \ 110 | -f body="${REVIEW_BODY}" 111 | else 112 | # Create a new comment 113 | gh api \ 114 | -X POST \ 115 | repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ 116 | -f body="${REVIEW_BODY}" 117 | fi 118 | -------------------------------------------------------------------------------- /internal/cli/dump.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | gitignore "github.com/sabhiram/go-gitignore" 14 | ) 15 | 16 | // RunDump processes the repository path and writes its contents to standard output. 17 | func (c *CLI) RunDump(repoPath, description string) error { 18 | ignorePatterns := make([]string, 0, 10) 19 | ignorePatterns = append(ignorePatterns, ".git/") // Default patterns to ignore the .git directory 20 | 21 | // Check for .aiignore file 22 | aiIgnorePath := filepath.Join(repoPath, ".aiignore") 23 | patterns, err := readIgnoreFile(aiIgnorePath, "") 24 | if err != nil { 25 | return fmt.Errorf("failed to read .aiignore file: %w", err) 26 | } 27 | ignorePatterns = append(ignorePatterns, patterns...) 28 | 29 | // Check for .gitignore file 30 | gitIgnorePath := filepath.Join(repoPath, ".gitignore") 31 | patterns, err = readIgnoreFile(gitIgnorePath, "") 32 | if err != nil { 33 | return fmt.Errorf("failed to read .gitignore file: %w", err) 34 | } 35 | ignorePatterns = append(ignorePatterns, patterns...) 36 | 37 | err = filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error { 38 | if err != nil { 39 | return err 40 | } 41 | 42 | relPath, err := filepath.Rel(repoPath, path) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | for _, pattern := range ignorePatterns { 48 | if strings.HasPrefix(relPath, pattern) { 49 | return filepath.SkipDir 50 | } 51 | } 52 | 53 | if d.IsDir() { 54 | gitIgnorePath := filepath.Join(path, ".gitignore") 55 | if patterns, err := readIgnoreFile(gitIgnorePath, relPath); err == nil { 56 | ignorePatterns = append(ignorePatterns, patterns...) 57 | } 58 | } 59 | return nil 60 | }) 61 | 62 | if err != nil { 63 | return fmt.Errorf("failed to walk the path %s: %w", repoPath, err) 64 | } 65 | 66 | ignores := gitignore.CompileIgnoreLines(ignorePatterns...) 67 | 68 | dumpPrompt := `The output represents a Git repository's content in the following format: 69 | 70 | 1. Each section begins with ----. 71 | 2. The first line after ---- contains the file path and name. 72 | 3. The subsequent lines contain the file contents. 73 | 4. The repository content ends with --END--. 74 | ` 75 | 76 | if description != "" { 77 | dumpPrompt += "\n" + unescapeString(description) + "\n" 78 | } 79 | 80 | dumpPrompt += "\nAny text after --END-- should be treated as instructions, using the repository content as context.\n" 81 | 82 | // Write the initial explanation text 83 | if _, err := fmt.Fprintln(c.outStream, dumpPrompt); err != nil { 84 | return fmt.Errorf("failed to write header: %w", err) 85 | } 86 | 87 | // Walk through the repository and process files 88 | err = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error { 89 | if err != nil { 90 | return fmt.Errorf("error accessing path %s: %w", path, err) 91 | } 92 | 93 | // Skip symlinks 94 | if info.Mode()&os.ModeSymlink != 0 { 95 | return nil 96 | } 97 | 98 | // Skip directories 99 | if info.IsDir() { 100 | return nil 101 | } 102 | 103 | relPath, err := filepath.Rel(repoPath, path) 104 | if err != nil { 105 | return fmt.Errorf("failed to calculate relative path: %w", err) 106 | } 107 | 108 | // Check if the file should be ignored 109 | if ignores.MatchesPath(relPath) { 110 | return nil 111 | } 112 | 113 | // Check if the file is binary 114 | if isBinaryFile(path) { 115 | return nil 116 | } 117 | 118 | file, err := os.Open(path) 119 | if err != nil { 120 | return fmt.Errorf("failed to open file %s: %w", path, err) 121 | } 122 | defer file.Close() 123 | 124 | if _, err := fmt.Fprintf(c.outStream, "----\n%s\n", relPath); err != nil { 125 | return fmt.Errorf("failed to write file header: %w", err) 126 | } 127 | 128 | if _, err := io.Copy(c.outStream, file); err != nil { 129 | return fmt.Errorf("failed to write file content: %w", err) 130 | } 131 | 132 | if _, err := fmt.Fprintln(c.outStream); err != nil { 133 | return fmt.Errorf("failed to write newline after file content: %w", err) 134 | } 135 | 136 | return nil 137 | }) 138 | 139 | if err != nil { 140 | return fmt.Errorf("error walking the repository: %w", err) 141 | } 142 | 143 | // Write the ending marker 144 | if _, err := fmt.Fprintln(os.Stdout, "--END--"); err != nil { 145 | return fmt.Errorf("failed to write footer: %w", err) 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func unescapeString(input string) string { 152 | replacer := strings.NewReplacer( 153 | `\\`, `\`, 154 | `\n`, "\n", 155 | `\t`, "\t", 156 | `\r`, "\r", 157 | ) 158 | return replacer.Replace(input) 159 | } 160 | 161 | // readIgnoreFile reads ignore patterns from the specified file and optionally prepends the provided directory to the patterns. 162 | func readIgnoreFile(filePath string, dir string) ([]string, error) { 163 | file, err := os.Open(filePath) 164 | if err != nil { 165 | if os.IsNotExist(err) { 166 | return nil, nil // File does not exist, not an error 167 | } 168 | return nil, err 169 | } 170 | defer file.Close() 171 | 172 | var patterns []string 173 | scanner := bufio.NewScanner(file) 174 | for scanner.Scan() { 175 | line := strings.TrimSpace(scanner.Text()) 176 | if line != "" && !strings.HasPrefix(line, "#") { 177 | patterns = append(patterns, filepath.Join(dir, line)) 178 | } 179 | } 180 | 181 | if err := scanner.Err(); err != nil { 182 | return nil, err 183 | } 184 | 185 | return patterns, nil 186 | } 187 | 188 | // isBinaryFile checks if the file at the given path is binary by analyzing its content. 189 | func isBinaryFile(path string) bool { 190 | file, err := os.Open(path) 191 | if err != nil { 192 | // If we can't open the file, assume it's not binary to avoid skipping unnecessarily. 193 | return false 194 | } 195 | defer file.Close() 196 | 197 | // Read a small portion of the file to determine its nature. 198 | buffer := make([]byte, 512) 199 | n, err := file.Read(buffer) 200 | if err != nil && err != io.EOF { 201 | return false 202 | } 203 | 204 | // Use the Content-Type to check for binary files. 205 | contentType := http.DetectContentType(buffer[:n]) 206 | return !strings.HasPrefix(contentType, "text/") 207 | } 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bento 2 | 3 | 🍱 4 | bento is a CLI tool that uses AI APIs to assist with everyday tasks. By default it uses OpenAI's API, but you can switch to Gemini's API with the `-backend` flag. It is especially useful for suggesting Git branch names, commit messages, translating text, and extracting repository contents. 5 | 6 | ## Features 7 | 8 | - Uses **OpenAI's API** by default; support for **Gemini's API** is available via the `-backend gemini` flag. 9 | - Extracts repository content with the `-dump` command. 10 | - Easy-to-use commands: `-branch`, `-commit`, `-translate`, `-review`, and `-dump`. 11 | - Supports **multi mode** and **single mode**: 12 | - **Single Mode**: Sends one request to the API (used for `-branch`, `-commit`, and `-review`). 13 | - **Multi Mode**: Sends multiple requests to the API (used for `-translate`). 14 | 15 | ## Name Origin of "bento" 🍱 16 | 17 | The name "bento" stands for **Bundled ENhancements for Tasks and Operations**. This means the tool provides many useful features for different tasks, making it a helpful and flexible solution for various needs. 18 | 19 | In Japanese, "bento" refers to a lunch box that contains a variety of different dishes. This analogy is perfect for our tool because it combines multiple functionalities into one compact tool, much like a bento box 🍱 that offers a variety of foods in one compact container. Thus, our "bento" tool is designed to be a versatile and efficient assistant in your workflow, packing numerous features in an organized manner. 20 | 21 | ## Installation 22 | 23 | It is recommended that you use the binaries available on [GitHub Releases](https://github.com/catatsuy/bento/releases). Download and use the latest version. 24 | 25 | Alternatively, if you have Go installed, compile and install bento with: 26 | 27 | ```bash 28 | go install github.com/catatsuy/bento@latest 29 | ``` 30 | 31 | To build for development, use: 32 | 33 | ```bash 34 | make 35 | ``` 36 | 37 | *(When built via `make`, `bento -version` outputs the current git commit ID.)* 38 | 39 | ## Additional Information 40 | 41 | - **API Token**: 42 | - OpenAI: passed via the environment variable `OPENAI_API_KEY`. 43 | - Gemini: use the `-backend gemini` flag and set the token via `GEMINI_API_KEY`. 44 | - **Repository Dump**: The `-dump` command extracts repository content while respecting `.gitignore` and `.aiignore`. 45 | - **Customization**: Use `-multi` or `-single` and override prompts with `-prompt`. 46 | - **Default Model**: 47 | - For OpenAI: default is `gpt-4o-mini`. 48 | - For Gemini (with `-backend gemini`): default is `gemini-2.0-flash-lite`. 49 | - **Translation**: The `-translate` command translates to English by default; change target language with `-language`. 50 | - **Code Review**: Use `-review` to get code feedback. Specify the output language with `-language`. 51 | - **File Handling**: Provide a filename with `-file` or use standard input. 52 | 53 | ## Usage Examples 54 | 55 | ``` 56 | Usage of bento: 57 | -backend string 58 | Backend to use: openai or gemini (default "openai") 59 | -branch 60 | Suggest branch name 61 | -commit 62 | Suggest commit message 63 | -description string 64 | Description of the repository (dump mode) 65 | -dump 66 | Dump repository contents 67 | -file string 68 | Specify a target file 69 | -h Print help information and quit 70 | -help 71 | Print help information and quit 72 | -language string 73 | Specify the output language 74 | -limit int 75 | Limit the number of characters to translate (default 4000) 76 | -model string 77 | Use models such as gpt-4o-mini, gpt-4-turbo, and gpt-4o. (When using the gemini backend, the default model becomes gemini-2.0-flash-lite) (default "gpt-4o-mini") 78 | -multi 79 | Multi mode 80 | -prompt string 81 | Prompt text 82 | -review 83 | Review source code 84 | -single 85 | Single mode (default) 86 | -system string 87 | System prompt text 88 | -translate 89 | Translate text 90 | -version 91 | Print version information and quit 92 | ``` 93 | 94 | ### Using `-dump` 95 | 96 | The `-dump` command is used to extract the contents of a Git repository in a structured format. Binary files are excluded, and `.gitignore` and `.aiignore` rules are respected. 97 | 98 | To dump the contents of a repository, use: 99 | 100 | ```bash 101 | bento -dump /path/to/repo 102 | ``` 103 | 104 | The output will follow this format: 105 | 106 | 1. Each section begins with `----`. 107 | 2. The first line after `----` contains the file path and name. 108 | 3. The subsequent lines contain the file contents. 109 | 4. The repository content ends with `--END--`. 110 | 111 | #### Description Flag 112 | 113 | The `-description` flag allows you to provide a specific description of the repository when using the dump mode. This description will be included in the output. 114 | 115 | ```bash 116 | bento -dump -description "This is a sample repository description." 117 | ``` 118 | 119 | ### Using `-branch` and `-commit` 120 | 121 | - **`-branch`**: Use this when you haven't created a branch yet. It suggests a branch name based on the current Git diff. 122 | - Large new files can be problematic for the API to handle. By default, Git diff excludes new files, which is convenient. If necessary, add new files with `git add -N`. 123 | - **`-commit`**: Use this when you are ready to commit. It suggests a commit message based on the staged files. 124 | - If new files cause large diffs, generate the commit message before staging them to avoid exceeding API limits. 125 | 126 | Here is an example of setting up bento as a Git alias on `~/.gitconfig`. This allows you to generate branch names and commit messages from Git diffs automatically. 127 | 128 | ```.gitconfig 129 | [alias] 130 | sb = !git diff -w | bento -branch 131 | sc = !git diff -w --staged | bento -commit 132 | ``` 133 | 134 | To show new files in git diff, use the `git add -N` command. This stages the new files without adding content. 135 | 136 | ```bash 137 | git add -N . 138 | ``` 139 | 140 | ### Using Review Mode with `-review` 141 | 142 | The `-review` option is used when you need to review the source code. This mode focuses on identifying issues in various aspects such as Completeness, Bugs, Security, Code Style, etc. 143 | 144 | You can also use the `-language` option to specify the output language of the review results. 145 | 146 | To review code, use the following command: 147 | 148 | ```sh 149 | git diff -w | bento -review -model gpt-4o -language Japanese 150 | ``` 151 | 152 | In this example, the review results will be in Japanese. You can change the output language by specifying a different language with `-language`. 153 | 154 | For automation, you can use a GitHub Actions workflow. Below is an example workflow configuration file, [`.github/workflows/auto-review.yml`](/.github/workflows/auto-review.yml), which automatically runs a code review whenever there is a new pull request: 155 | 156 | This workflow will trigger on every pull request and run a code review using the `bento` tool. 157 | 158 | #### Important Security Considerations 159 | 160 | - **API Key Storage**: Store your OpenAI API key as a repository secret named `OPENAI_API_KEY`: 161 | 1. Go to your repository's Settings. 162 | 2. Navigate to Secrets and variables > Actions. 163 | 3. Click "New repository secret" to add the key. 164 | 165 | - **Permissions**: Set proper permissions for Actions in open-source projects: 166 | 1. In the repository settings, adjust the Actions permissions to "Allow OWNER, and select non-OWNER, actions and reusable workflows". 167 | 2. For details, refer to the GitHub documentation [here](https://docs.github.com/github/administering-a-repository/disabling-or-limiting-github-actions-for-a-repository#allowing-select-actions-and-reusable-workflows-to-run). 168 | 169 | ### Using System Prompt with `-system` 170 | 171 | The `-system` option allows you to define a system prompt text. This can be useful for customizing the initial instructions. 172 | 173 | ### Using `-translate` 174 | 175 | The `-translate` option allows you to translate text to a target language. You can specify the target language using the `-language` option. By default, the target language is English (`en`). 176 | 177 | #### Translating Text from a File 178 | 179 | To translate text from a file named `example.txt` to Japanese, use the following command: 180 | 181 | ```sh 182 | bento -translate -file example.txt -language ja 183 | ``` 184 | 185 | #### Translating Text from Standard Input 186 | 187 | To translate text from standard input to French, use the following command. 188 | 189 | ```sh 190 | echo 'hello' | bento -translate -language fr 191 | ``` 192 | 193 | ### Using Multi Mode with `-multi` 194 | 195 | To proofread a text and correct obvious errors while maintaining the original meaning and tone, use the following command: 196 | 197 | ```sh 198 | bento -file textfile.txt -multi -prompt "Please correct only the obvious errors in the following text while maintaining the original meaning and tone as much as possible:\n\n" 199 | ``` 200 | 201 | ### Using Single Mode with `-single` 202 | 203 | The Single Mode is default. You don't need to specify `-single`. 204 | 205 | The `-single` option is used when you need to send a single request to the API. This is useful for tasks that must be processed as a whole. 206 | 207 | To summarize text from a file named example.txt, use the following command: 208 | 209 | ```sh 210 | bento -single -prompt 'Please summarize the following text:\n\n' -file example.txt 211 | ``` 212 | 213 | ## Tips 214 | 215 | - **Prompt Suggestions**: The default prompts are optimized to produce minimal extra text. If using custom prompts, consider appending "without any additional text or formatting". 216 | - **Backend Switching**: Use the `-backend` flag to switch between OpenAI and Gemini (token is provided via the corresponding environment variable). 217 | - **Git Integration**: Set up Git aliases as shown above to generate branch names or commit messages directly from diffs. 218 | -------------------------------------------------------------------------------- /internal/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/signal" 11 | "runtime" 12 | "runtime/debug" 13 | "strings" 14 | "syscall" 15 | 16 | "github.com/catatsuy/bento/internal/gemini" 17 | "github.com/catatsuy/bento/internal/openai" 18 | ) 19 | 20 | const ( 21 | ExitCodeOK = 0 22 | ExitCodeFail = 1 23 | 24 | DefaultExceedThreshold = 4000 25 | 26 | DefaultOpenAIModel = "gpt-5-nano" 27 | DefaultGeminiModel = "gemini-2.0-flash-lite" 28 | ) 29 | 30 | var ( 31 | Version string 32 | ) 33 | 34 | // CLI holds the input/output streams and the Translator. 35 | type CLI struct { 36 | outStream, errStream io.Writer 37 | inputStream io.Reader 38 | 39 | isStdinTerminal bool 40 | 41 | appVersion string 42 | 43 | translator Translator 44 | } 45 | 46 | // Translator is the interface used to request a response. 47 | type Translator interface { 48 | request(ctx context.Context, systemPrompt, prompt, input, model string) (string, error) 49 | } 50 | 51 | // NewCLI returns a new CLI instance. 52 | func NewCLI(outStream, errStream io.Writer, inputStream io.Reader, tr Translator, isStdinTerminal bool) *CLI { 53 | return &CLI{ 54 | appVersion: version(), 55 | outStream: outStream, 56 | errStream: errStream, 57 | inputStream: inputStream, 58 | translator: tr, 59 | isStdinTerminal: isStdinTerminal, 60 | } 61 | } 62 | 63 | // Run parses CLI arguments and executes the appropriate functionality. 64 | func (c *CLI) Run(args []string) int { 65 | if len(args) <= 1 { 66 | fmt.Fprintf(c.errStream, "Error: Insufficient arguments provided\n") 67 | return ExitCodeFail 68 | } 69 | 70 | var ( 71 | version bool 72 | help bool 73 | 74 | branchSuggestion bool 75 | commitMessage bool 76 | translate bool 77 | review bool 78 | 79 | language string 80 | prompt string 81 | systemPrompt string 82 | useModel string 83 | targetFile string 84 | repoPath string 85 | 86 | dump bool 87 | description string 88 | 89 | isMultiMode bool 90 | isSingleMode bool 91 | 92 | limit int 93 | 94 | backend string 95 | ) 96 | 97 | flags := flag.NewFlagSet("bento", flag.ContinueOnError) 98 | flags.SetOutput(c.errStream) 99 | 100 | flags.BoolVar(&version, "version", false, "Print version information and quit") 101 | flags.BoolVar(&help, "help", false, "Print help information and quit") 102 | flags.BoolVar(&help, "h", false, "Print help information and quit") 103 | 104 | flags.StringVar(&targetFile, "file", "", "Specify a target file") 105 | 106 | flags.BoolVar(&branchSuggestion, "branch", false, "Suggest branch name") 107 | flags.BoolVar(&commitMessage, "commit", false, "Suggest commit message") 108 | flags.BoolVar(&translate, "translate", false, "Translate text") 109 | flags.BoolVar(&review, "review", false, "Review source code") 110 | 111 | flags.BoolVar(&dump, "dump", false, "Dump repository contents") 112 | flags.StringVar(&description, "description", "", "Description of the repository (dump mode)") 113 | 114 | flags.IntVar(&limit, "limit", DefaultExceedThreshold, "Limit the number of characters to translate") 115 | 116 | flags.BoolVar(&isMultiMode, "multi", false, "Multi mode") 117 | flags.BoolVar(&isSingleMode, "single", false, "Single mode (default)") 118 | 119 | flags.StringVar(&language, "language", "", "Specify the output language") 120 | flags.StringVar(&prompt, "prompt", "", "Prompt text") 121 | flags.StringVar(&systemPrompt, "system", "", "System prompt text") 122 | flags.StringVar(&useModel, "model", DefaultOpenAIModel, "Use models such as gpt-4o-mini, gpt-4-turbo, and gpt-4o. (When using the gemini backend, the default model becomes "+DefaultGeminiModel+")") 123 | flags.StringVar(&backend, "backend", "openai", "Backend to use: openai or gemini") 124 | 125 | err := flags.Parse(args[1:]) 126 | if err != nil { 127 | fmt.Fprintf(c.errStream, "Error: %v\n", err) 128 | return ExitCodeFail 129 | } 130 | 131 | if version { 132 | fmt.Fprintf(c.errStream, "bento version %s; %s\n", c.appVersion, runtime.Version()) 133 | return ExitCodeOK 134 | } 135 | 136 | if help { 137 | fmt.Fprintf(c.errStream, "bento version %s; %s\n", c.appVersion, runtime.Version()) 138 | fmt.Fprintf(c.errStream, "Usage of bento:\n") 139 | flags.PrintDefaults() 140 | return ExitCodeOK 141 | } 142 | 143 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) 144 | defer stop() 145 | 146 | if isSingleMode && isMultiMode { 147 | fmt.Fprintf(c.errStream, "Error: Both 'multi' and 'single' modes cannot be specified simultaneously.\n") 148 | return ExitCodeFail 149 | } 150 | 151 | if !isMultiMode && !isSingleMode { 152 | // Default to single mode if no mode is specified. 153 | isSingleMode = true 154 | } 155 | 156 | if (!translate && !review) && language != "" { 157 | fmt.Fprintf(c.errStream, "Error: The '-language' option can only be used with '-translate' or '-review'.\n") 158 | return ExitCodeFail 159 | } 160 | 161 | // If not in dump mode, ensure a translator is set. 162 | if !dump { 163 | if c.translator == nil { 164 | // Choose translator based on the backend flag. 165 | switch strings.ToLower(backend) { 166 | case "gemini": 167 | apiKey := os.Getenv("GEMINI_API_KEY") 168 | if apiKey == "" { 169 | fmt.Fprintln(c.errStream, "Error: You need to set GEMINI_API_KEY") 170 | return ExitCodeFail 171 | } 172 | if useModel == DefaultOpenAIModel { 173 | useModel = DefaultGeminiModel 174 | } 175 | gt, err := NewGeminiTranslator(apiKey) 176 | if err != nil { 177 | fmt.Fprintf(c.errStream, "Error creating Gemini translator: %v\n", err) 178 | return ExitCodeFail 179 | } 180 | c.translator = gt 181 | default: 182 | apiKey := os.Getenv("OPENAI_API_KEY") 183 | if apiKey == "" { 184 | fmt.Fprintln(c.errStream, "Error: You need to set OPENAI_API_KEY") 185 | return ExitCodeFail 186 | } 187 | ot, err := NewOpenAITranslator(apiKey) 188 | if err != nil { 189 | fmt.Fprintf(c.errStream, "Error creating OpenAI translator: %v\n", err) 190 | return ExitCodeFail 191 | } 192 | c.translator = ot 193 | } 194 | } 195 | } 196 | 197 | if dump { 198 | if flags.NArg() < 1 { 199 | repoPath, err = os.Getwd() 200 | if err != nil { 201 | fmt.Fprintf(c.errStream, "Error: A repository path must be specified for dump mode.\n") 202 | return ExitCodeFail 203 | } 204 | } else { 205 | repoPath = flags.Arg(0) 206 | } 207 | 208 | if err := c.RunDump(repoPath, description); err != nil { 209 | fmt.Fprintf(c.errStream, "Error: %v\n", err) 210 | return ExitCodeFail 211 | } 212 | 213 | return ExitCodeOK 214 | } 215 | 216 | if c.isStdinTerminal && targetFile == "" { 217 | fmt.Fprintf(c.errStream, "Error: The '-file' option is required when reading from standard input.\n") 218 | return ExitCodeFail 219 | } 220 | 221 | if !c.isStdinTerminal && targetFile != "" { 222 | fmt.Fprintf(c.errStream, "Error: The '-file' option cannot be used when reading from a file.\n") 223 | return ExitCodeFail 224 | } 225 | 226 | if branchSuggestion { 227 | isSingleMode = true 228 | isMultiMode = false 229 | prompt = "Generate a branch name directly from the provided source code differences without any additional text or formatting:\n\n" 230 | } else if commitMessage { 231 | isSingleMode = true 232 | isMultiMode = false 233 | prompt = "Generate a commit message directly from the provided source code differences without any additional text or formatting within 72 characters:\n\n" 234 | } else if translate { 235 | if language == "" { 236 | language = "en" 237 | } 238 | isMultiMode = true 239 | isSingleMode = false 240 | prompt = "Translate the following text to " + language + " without any additional text or formatting:\n\n" 241 | } else if review { 242 | isSingleMode = true 243 | isMultiMode = false 244 | prompt = `Please review the following code as an experienced engineer, focusing only on areas where there are issues. The code is provided as a Git diff, where lines prefixed with + represent additions and lines prefixed with - represent deletions. Analyze the changes accordingly. 245 | Provide feedback only if there is a problem in any of the following aspects: Completeness, Bugs, Security, Code Style, Performance, Readability, Documentation, Testing, Scalability, Dependencies, or Error Handling. 246 | If you find a problem, briefly explain the issue and provide a specific suggestion for improvement. When possible, include a code example that demonstrates how to fix the issue. If there are no issues in a particular area, you do not need to mention it. Avoid numbering the feedback items.` 247 | 248 | if language != "" { 249 | prompt += " Please provide the feedback in " + language + "." 250 | } 251 | 252 | prompt += "\n\n" 253 | } 254 | 255 | if targetFile != "" { 256 | f, err := os.Open(targetFile) 257 | if err != nil { 258 | fmt.Fprintf(c.errStream, "Error: %v\n", err) 259 | return ExitCodeFail 260 | } 261 | defer f.Close() 262 | c.inputStream = f 263 | } 264 | 265 | if isSingleMode { 266 | content, err := io.ReadAll(c.inputStream) 267 | if err != nil { 268 | fmt.Fprintf(c.errStream, "Error: %v\n", err) 269 | return ExitCodeFail 270 | } 271 | 272 | suggestion, err := c.translator.request(ctx, systemPrompt, prompt, string(content), useModel) 273 | if err != nil { 274 | fmt.Fprintf(c.errStream, "Error: %v\n", err) 275 | return ExitCodeFail 276 | } 277 | fmt.Fprintf(c.outStream, "%s\n", suggestion) 278 | return ExitCodeOK 279 | } 280 | 281 | if isMultiMode { 282 | err = c.multiRequest(ctx, systemPrompt, prompt, useModel, limit) 283 | if err != nil { 284 | fmt.Fprintf(c.errStream, "Error: %v\n", err) 285 | return ExitCodeFail 286 | } 287 | return ExitCodeOK 288 | } 289 | 290 | return ExitCodeOK 291 | } 292 | 293 | func version() string { 294 | if Version != "" { 295 | return Version 296 | } 297 | info, ok := debug.ReadBuildInfo() 298 | if !ok { 299 | return "(devel)" 300 | } 301 | return info.Main.Version 302 | } 303 | 304 | // multiRequest processes multi-line input in chunks. 305 | func (c *CLI) multiRequest(ctx context.Context, systemPrompt, prompt, useModel string, limit int) error { 306 | var b strings.Builder 307 | reader := bufio.NewReader(c.inputStream) 308 | for { 309 | line, err := reader.ReadBytes('\n') 310 | if err != nil { 311 | if err == io.EOF { 312 | break 313 | } 314 | return fmt.Errorf("error reading input: %w", err) 315 | } 316 | b.Write(line) 317 | if b.Len() > limit { 318 | translatedText, err := c.translator.request(ctx, systemPrompt, prompt, b.String(), useModel) 319 | if err != nil { 320 | return fmt.Errorf("failed to translate text: %w", err) 321 | } 322 | fmt.Fprintf(c.outStream, "%s\n", translatedText) 323 | b.Reset() 324 | } 325 | } 326 | if b.Len() > 0 { 327 | translatedText, err := c.translator.request(ctx, systemPrompt, prompt, b.String(), useModel) 328 | if err != nil { 329 | return fmt.Errorf("failed to translate text: %w", err) 330 | } 331 | fmt.Fprintf(c.outStream, "%s\n", translatedText) 332 | b.Reset() 333 | } 334 | return nil 335 | } 336 | 337 | // GeminiTranslator implements the Translator interface using the Gemini API client. 338 | type GeminiTranslator struct { 339 | client *gemini.Client 340 | } 341 | 342 | // NewGeminiTranslator creates a new GeminiTranslator with the given API key. 343 | func NewGeminiTranslator(apiKey string) (*GeminiTranslator, error) { 344 | client, err := gemini.NewClient(gemini.GeminiAPIURL, apiKey) 345 | if err != nil { 346 | return nil, fmt.Errorf("NewClient: %w", err) 347 | } 348 | return &GeminiTranslator{client: client}, nil 349 | } 350 | 351 | // request sends a request to the Gemini API and returns the response text. 352 | // It constructs a Payload using the provided system prompt (if any) and user prompt. 353 | func (gt *GeminiTranslator) request(ctx context.Context, systemPrompt, prompt, input, useModel string) (string, error) { 354 | if len(input) == 0 { 355 | return "", fmt.Errorf("no input") 356 | } 357 | var data *gemini.Payload 358 | if systemPrompt != "" { 359 | data = &gemini.Payload{ 360 | Model: useModel, // e.g., "gemini-2.0-flash" 361 | Messages: []gemini.Message{ 362 | {Role: "user", Content: prompt + input}, 363 | }, 364 | } 365 | } else { 366 | data = &gemini.Payload{ 367 | Model: useModel, 368 | Messages: []gemini.Message{ 369 | {Role: "user", Content: prompt + input}, 370 | }, 371 | } 372 | } 373 | resp, err := gt.client.Chat(ctx, data) 374 | if err != nil { 375 | return "", fmt.Errorf("http request: %w", err) 376 | } 377 | if len(resp.Choices) > 0 { 378 | return resp.Choices[0].Message.Content, nil 379 | } 380 | return "", fmt.Errorf("no translation found") 381 | } 382 | 383 | // NewOpenAITranslator creates a new translator using the OpenAI API. 384 | func NewOpenAITranslator(apiKey string) (Translator, error) { 385 | client, err := openai.NewClient(openai.OpenAIAPIURL, apiKey) 386 | if err != nil { 387 | return nil, fmt.Errorf("NewClient: %w", err) 388 | } 389 | return &openaiTranslator{client: client}, nil 390 | } 391 | 392 | type openaiTranslator struct { 393 | client *openai.Client 394 | } 395 | 396 | func (ot *openaiTranslator) request(ctx context.Context, systemPrompt, prompt, input, useModel string) (string, error) { 397 | if len(input) == 0 { 398 | return "", fmt.Errorf("no input") 399 | } 400 | var data *openai.Payload 401 | if systemPrompt != "" { 402 | data = &openai.Payload{ 403 | Model: useModel, 404 | Input: prompt + input, 405 | Instructions: systemPrompt, 406 | } 407 | } else { 408 | data = &openai.Payload{ 409 | Model: useModel, 410 | Input: prompt + input, 411 | } 412 | } 413 | resp, err := ot.client.Chat(ctx, data) 414 | if err != nil { 415 | return "", fmt.Errorf("http request: %w", err) 416 | } 417 | outputText := resp.OutputText() 418 | if outputText != "" { 419 | return outputText, nil 420 | } 421 | return "", fmt.Errorf("no translation found: Response=%+v", resp) 422 | } 423 | --------------------------------------------------------------------------------