Refactored orchestrator for staged file handling, added structured prompt support, adjusted Feishu file handling
This commit is contained in:
149
tools/git/git.go
Normal file
149
tools/git/git.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"laodingbot/internal/logger"
|
||||
)
|
||||
|
||||
// Tool provides common Git operations through a safe command whitelist.
|
||||
type Tool struct {
|
||||
repoDir string
|
||||
allowedSubcmds map[string]struct{}
|
||||
timeout time.Duration
|
||||
maxOutputChars int
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
// New creates a new git tool.
|
||||
func New(repoDir string, timeout time.Duration, maxOutputChars int, log *logger.Logger) *Tool {
|
||||
absRepo, err := filepath.Abs(strings.TrimSpace(repoDir))
|
||||
if err != nil {
|
||||
absRepo = strings.TrimSpace(repoDir)
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
if maxOutputChars <= 0 {
|
||||
maxOutputChars = 4000
|
||||
}
|
||||
|
||||
allowed := map[string]struct{}{
|
||||
"status": {},
|
||||
"log": {},
|
||||
"show": {},
|
||||
"diff": {},
|
||||
"branch": {},
|
||||
"checkout": {},
|
||||
"switch": {},
|
||||
"restore": {},
|
||||
"add": {},
|
||||
"commit": {},
|
||||
"reset": {},
|
||||
"revert": {},
|
||||
"merge": {},
|
||||
"rebase": {},
|
||||
"cherry-pick": {},
|
||||
"fetch": {},
|
||||
"pull": {},
|
||||
"push": {},
|
||||
"remote": {},
|
||||
"tag": {},
|
||||
"stash": {},
|
||||
"blame": {},
|
||||
"rev-parse": {},
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Infof("git tool initialized repo_dir=%s timeout=%s max_output_chars=%d", absRepo, timeout, maxOutputChars)
|
||||
}
|
||||
|
||||
return &Tool{
|
||||
repoDir: absRepo,
|
||||
allowedSubcmds: allowed,
|
||||
timeout: timeout,
|
||||
maxOutputChars: maxOutputChars,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tool) Name() string { return "git" }
|
||||
|
||||
func (t *Tool) Description() string {
|
||||
return "Run common git commands inside repository. Input examples: status --short | log --oneline -n 10 | add . | commit -m fix | branch"
|
||||
}
|
||||
|
||||
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
if t.log != nil {
|
||||
t.log.Warnf("git tool rejected empty command")
|
||||
}
|
||||
return "", fmt.Errorf("empty git command")
|
||||
}
|
||||
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("empty git command")
|
||||
}
|
||||
|
||||
if strings.EqualFold(parts[0], "git") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("missing git subcommand")
|
||||
}
|
||||
|
||||
subcmd := strings.ToLower(parts[0])
|
||||
if _, ok := t.allowedSubcmds[subcmd]; !ok {
|
||||
if t.log != nil {
|
||||
t.log.Warnf("git tool rejected subcommand=%s", subcmd)
|
||||
}
|
||||
return "", fmt.Errorf("unsupported git subcommand: %s", subcmd)
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||
defer cancel()
|
||||
|
||||
args := append([]string{subcmd}, parts[1:]...)
|
||||
if t.log != nil {
|
||||
t.log.Infof("git command start subcommand=%s args=%d full=%q", subcmd, len(parts)-1, strings.Join(args, " "))
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "git", args...)
|
||||
cmd.Dir = t.repoDir
|
||||
cmd.Env = append(cmd.Environ(),
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_PAGER=cat",
|
||||
"GIT_EDITOR=true",
|
||||
)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
outText := strings.TrimSpace(string(out))
|
||||
if len(outText) > t.maxOutputChars {
|
||||
outText = outText[:t.maxOutputChars]
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if t.log != nil {
|
||||
t.log.Errorf("git command failed subcommand=%s err=%v output=%q", subcmd, err, outText)
|
||||
}
|
||||
if outText == "" {
|
||||
return "", err
|
||||
}
|
||||
return outText, err
|
||||
}
|
||||
|
||||
if t.log != nil {
|
||||
t.log.Infof("git command success subcommand=%s output_bytes=%d", subcmd, len(out))
|
||||
}
|
||||
if outText == "" {
|
||||
return "ok", nil
|
||||
}
|
||||
return outText, nil
|
||||
}
|
||||
129
tools/git/git_test.go
Normal file
129
tools/git/git_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCallRejectsEmptyCommand(t *testing.T) {
|
||||
tool := New(t.TempDir(), 3*time.Second, 4000, nil)
|
||||
_, err := tool.Call(context.Background(), " ")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallRejectsUnsupportedSubcommand(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
tool := New(repo, 5*time.Second, 4000, nil)
|
||||
|
||||
_, err := tool.Call(context.Background(), "bisect start")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported subcommand error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported git subcommand") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAndLog(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
tool := New(repo, 5*time.Second, 4000, nil)
|
||||
|
||||
status, err := tool.Call(context.Background(), "status --short")
|
||||
if err != nil {
|
||||
t.Fatalf("status failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(status) != "" && status != "ok" {
|
||||
t.Fatalf("expected clean status output, got: %q", status)
|
||||
}
|
||||
|
||||
logOut, err := tool.Call(context.Background(), "git log --oneline -n 1")
|
||||
if err != nil {
|
||||
t.Fatalf("log failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(logOut, "initial commit") {
|
||||
t.Fatalf("expected initial commit in log output, got: %q", logOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommitAndDiff(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
tool := New(repo, 8*time.Second, 8000, nil)
|
||||
|
||||
file := filepath.Join(repo, "note.txt")
|
||||
if err := os.WriteFile(file, []byte("v1\n"), 0o644); err != nil {
|
||||
t.Fatalf("write file failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := tool.Call(context.Background(), "add note.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("add failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = tool.Call(context.Background(), "commit -m add-note")
|
||||
if err != nil {
|
||||
t.Fatalf("commit failed: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(file, []byte("v2\n"), 0o644); err != nil {
|
||||
t.Fatalf("rewrite file failed: %v", err)
|
||||
}
|
||||
|
||||
diffOut, err := tool.Call(context.Background(), "diff -- note.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("diff failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(diffOut, "-v1") || !strings.Contains(diffOut, "+v2") {
|
||||
t.Fatalf("expected diff output with v1/v2 changes, got: %q", diffOut)
|
||||
}
|
||||
}
|
||||
|
||||
func initTestRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git is not installed")
|
||||
}
|
||||
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.name", "test-user")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
|
||||
readme := filepath.Join(repo, "README.md")
|
||||
if err := os.WriteFile(readme, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write readme failed: %v", err)
|
||||
}
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "initial commit")
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(cmd.Environ(),
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_PAGER=cat",
|
||||
"GIT_EDITOR=true",
|
||||
)
|
||||
if runtime.GOOS == "windows" {
|
||||
// Keep default behavior; this branch documents cross-platform intent.
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(out))
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,9 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows 下使用 cmd /C 执行,兼容 date、dir 等内建命令。
|
||||
cmd = exec.CommandContext(runCtx, "cmd", "/C", trimmed)
|
||||
} else if requiresShellParsing(trimmed) {
|
||||
// 包含管道、重定向等语法时,必须交给 shell 解释。
|
||||
cmd = exec.CommandContext(runCtx, "sh", "-c", trimmed)
|
||||
} else {
|
||||
cmd = exec.CommandContext(runCtx, base, parts[1:]...)
|
||||
}
|
||||
@@ -126,3 +129,7 @@ func normalizeWindowsCommand(command string) string {
|
||||
return command
|
||||
}
|
||||
}
|
||||
|
||||
func requiresShellParsing(command string) bool {
|
||||
return strings.ContainsAny(command, "|&;<>()$`\\\n")
|
||||
}
|
||||
|
||||
@@ -40,3 +40,27 @@ func TestCallWindowsDateIsNonInteractive(t *testing.T) {
|
||||
t.Fatal("expected non-empty output for date command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiresShellParsing(t *testing.T) {
|
||||
if !requiresShellParsing("echo hi | cat") {
|
||||
t.Fatal("expected pipe command to require shell parsing")
|
||||
}
|
||||
if requiresShellParsing("echo hello") {
|
||||
t.Fatal("expected simple command to not require shell parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallSupportsPipeOnUnix(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix-only behavior test")
|
||||
}
|
||||
tool := New([]string{"echo"}, ".", 3*time.Second, 4000, nil)
|
||||
out, err := tool.Call(context.Background(), "printf hello | wc -c")
|
||||
if err != nil {
|
||||
t.Fatalf("expected pipeline command success, got err=%v output=%q", err, out)
|
||||
}
|
||||
trimmed := strings.TrimSpace(out)
|
||||
if trimmed != "5" {
|
||||
t.Fatalf("expected output 5, got %q", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user