diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-19 09:26:26 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-19 09:26:26 +0200 |
| commit | 15bc73d103259b492f8b77a422f8649bdf3d7c24 (patch) | |
| tree | 58a834d1cab371ce9acc2b25780524223c06980c /cmd | |
| parent | 5642eaf74a4a70e5c82646bef3e0dd42846baea8 (diff) | |
Add ask Taskwarrior wrapper
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ask/main.go | 46 | ||||
| -rw-r--r-- | cmd/ask/main_test.go | 52 | ||||
| -rw-r--r-- | cmd/hexai/task_command.go | 95 | ||||
| -rw-r--r-- | cmd/hexai/task_command_test.go | 128 |
4 files changed, 101 insertions, 220 deletions
diff --git a/cmd/ask/main.go b/cmd/ask/main.go new file mode 100644 index 0000000..73d3e04 --- /dev/null +++ b/cmd/ask/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + + "codeberg.org/snonux/hexai/internal/taskproxy" +) + +type taskRunner func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) + +type askRunner struct { + runTask taskRunner +} + +func newAskRunner() askRunner { + return askRunner{runTask: runAskTask} +} + +func main() { + if exitCode := newAskRunner().run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); exitCode != 0 { + os.Exit(exitCode) + } +} + +func (r askRunner) run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + runner := normalizeAskRunner(r) + exitCode, err := runner.runTask(context.Background(), args, stdin, stdout, stderr) + if err != nil { + fmt.Fprintln(stderr, err) + } + return exitCode +} + +func normalizeAskRunner(r askRunner) askRunner { + if r.runTask == nil { + r.runTask = runAskTask + } + return r +} + +func runAskTask(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + return taskproxy.NewRunner("ask").Run(ctx, args, stdin, stdout, stderr) +} diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go new file mode 100644 index 0000000..65551cb --- /dev/null +++ b/cmd/ask/main_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "context" + "errors" + "io" + "reflect" + "strings" + "testing" +) + +func TestAskRunnerRun_ForwardsArgs(t *testing.T) { + var gotArgs []string + var stdout bytes.Buffer + runner := askRunner{ + runTask: func(_ context.Context, args []string, stdin io.Reader, out, errOut io.Writer) (int, error) { + gotArgs = append([]string(nil), args...) + _, _ = io.WriteString(out, "task output") + return 0, nil + }, + } + + exitCode := runner.run([]string{"list", "limit:1"}, strings.NewReader(""), &stdout, &bytes.Buffer{}) + if exitCode != 0 { + t.Fatalf("exitCode = %d, want 0", exitCode) + } + wantArgs := []string{"list", "limit:1"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("args = %v, want %v", gotArgs, wantArgs) + } + if stdout.String() != "task output" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "task output") + } +} + +func TestAskRunnerRun_WritesErrorToStderr(t *testing.T) { + var stderr bytes.Buffer + runner := askRunner{ + runTask: func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) { + return 1, errors.New("ask: must be run inside a git repository") + }, + } + + exitCode := runner.run([]string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &stderr) + if exitCode != 1 { + t.Fatalf("exitCode = %d, want 1", exitCode) + } + if !strings.Contains(stderr.String(), "must be run inside a git repository") { + t.Fatalf("stderr = %q, want actionable repo guidance", stderr.String()) + } +} diff --git a/cmd/hexai/task_command.go b/cmd/hexai/task_command.go index efd3a92..a942f71 100644 --- a/cmd/hexai/task_command.go +++ b/cmd/hexai/task_command.go @@ -2,104 +2,15 @@ package main import ( "context" - "errors" - "fmt" "io" - "os/exec" - "path/filepath" - "strings" -) - -type taskBinaryFinder func() (string, error) - -type repoTopLevelDetector func(context.Context) (string, error) - -type taskCommandRunner func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error -type taskRunner struct { - findTaskBinary taskBinaryFinder - detectRepoRoot repoTopLevelDetector - runCommand taskCommandRunner -} - -func newTaskRunner() taskRunner { - return taskRunner{ - findTaskBinary: findTaskBinary, - detectRepoRoot: detectRepoRoot, - runCommand: runTaskCommand, - } -} + "codeberg.org/snonux/hexai/internal/taskproxy" +) func runTaskSubcommandIfRequested(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, int, error) { if len(args) == 0 || args[0] != "task" { return false, 0, nil } - code, err := newTaskRunner().run(context.Background(), args[1:], stdin, stdout, stderr) + code, err := taskproxy.NewRunner("hexai task").Run(context.Background(), args[1:], stdin, stdout, stderr) return true, code, err } - -func (r taskRunner) run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { - runner := normalizeTaskRunner(r) - taskPath, err := runner.findTaskBinary() - if err != nil { - return 1, fmt.Errorf("hexai task: Taskwarrior binary lookup failed: %w", err) - } - repoRoot, err := runner.detectRepoRoot(ctx) - if err != nil { - return 1, fmt.Errorf("hexai task: must be run inside a git repository so project:<repo> can be derived: %w", err) - } - projectName := filepath.Base(strings.TrimSpace(repoRoot)) - if projectName == "" || projectName == "." || projectName == string(filepath.Separator) { - return 1, fmt.Errorf("hexai task: could not derive project name from git root %q", repoRoot) - } - taskArgs := append([]string{"project:" + projectName, "+agent"}, args...) - if err := runner.runCommand(ctx, taskPath, taskArgs, stdin, stdout, stderr); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return exitErr.ExitCode(), nil - } - return 1, fmt.Errorf("hexai task: failed to run Taskwarrior: %w", err) - } - return 0, nil -} - -func normalizeTaskRunner(r taskRunner) taskRunner { - if r.findTaskBinary == nil { - r.findTaskBinary = findTaskBinary - } - if r.detectRepoRoot == nil { - r.detectRepoRoot = detectRepoRoot - } - if r.runCommand == nil { - r.runCommand = runTaskCommand - } - return r -} - -func findTaskBinary() (string, error) { - path, err := exec.LookPath("task") - if err != nil { - return "", fmt.Errorf("Taskwarrior binary 'task' not found in PATH; install Taskwarrior and retry") - } - return path, nil -} - -func detectRepoRoot(ctx context.Context) (string, error) { - out, err := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel").Output() - if err != nil { - return "", fmt.Errorf("must be run inside a git repository so project:<repo> can be derived") - } - root := strings.TrimSpace(string(out)) - if root == "" { - return "", fmt.Errorf("git returned an empty repository root") - } - return root, nil -} - -func runTaskCommand(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Stdin = stdin - cmd.Stdout = stdout - cmd.Stderr = stderr - return cmd.Run() -} diff --git a/cmd/hexai/task_command_test.go b/cmd/hexai/task_command_test.go index 498ac65..2cdf0bc 100644 --- a/cmd/hexai/task_command_test.go +++ b/cmd/hexai/task_command_test.go @@ -2,11 +2,6 @@ package main import ( "bytes" - "context" - "errors" - "io" - "os/exec" - "reflect" "strings" "testing" ) @@ -17,126 +12,3 @@ func TestRunTaskSubcommandIfRequested_SkipsNonTaskArgs(t *testing.T) { t.Fatalf("expected non-task args to be ignored, got handled=%v exitCode=%d err=%v", handled, exitCode, err) } } - -func TestTaskRunnerRun_InjectsProjectFilterAndAgentTag(t *testing.T) { - var gotName string - var gotArgs []string - runner := taskRunner{ - findTaskBinary: func() (string, error) { return "/usr/bin/task", nil }, - detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil }, - runCommand: func(_ context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { - gotName = name - gotArgs = append([]string(nil), args...) - return nil - }, - } - - exitCode, err := runner.run(context.Background(), []string{"list", "limit:1"}, strings.NewReader("in"), &bytes.Buffer{}, &bytes.Buffer{}) - if err != nil { - t.Fatalf("run returned error: %v", err) - } - if exitCode != 0 { - t.Fatalf("exitCode = %d, want 0", exitCode) - } - if gotName != "/usr/bin/task" { - t.Fatalf("task binary = %q, want /usr/bin/task", gotName) - } - wantArgs := []string{"project:hexai", "+agent", "list", "limit:1"} - if !reflect.DeepEqual(gotArgs, wantArgs) { - t.Fatalf("task args = %v, want %v", gotArgs, wantArgs) - } -} - -func TestTaskRunnerRun_OutsideGitRepo_IsActionable(t *testing.T) { - runner := taskRunner{ - findTaskBinary: func() (string, error) { return "/usr/bin/task", nil }, - detectRepoRoot: func(context.Context) (string, error) { return "", errors.New("git failed") }, - runCommand: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error { - t.Fatal("runCommand should not be called when repo detection fails") - return nil - }, - } - - exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) - if exitCode != 1 { - t.Fatalf("exitCode = %d, want 1", exitCode) - } - if err == nil || !strings.Contains(err.Error(), "must be run inside a git repository") { - t.Fatalf("expected actionable git-repo error, got %v", err) - } -} - -func TestTaskRunnerRun_PreservesTaskwarriorExitCode(t *testing.T) { - runner := taskRunner{ - findTaskBinary: func() (string, error) { return "/usr/bin/task", nil }, - detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil }, - runCommand: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error { - return exec.Command("sh", "-c", "exit 7").Run() - }, - } - - exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) - if err != nil { - t.Fatalf("expected nil error for subprocess exit, got %v", err) - } - if exitCode != 7 { - t.Fatalf("exitCode = %d, want 7", exitCode) - } -} - -func TestTaskRunnerRun_PreservesStdoutAndStderr(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := taskRunner{ - findTaskBinary: func() (string, error) { return "/usr/bin/task", nil }, - detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil }, - runCommand: func(_ context.Context, name string, args []string, stdin io.Reader, out, errOut io.Writer) error { - _, _ = io.WriteString(out, "task stdout") - _, _ = io.WriteString(errOut, "task stderr") - return nil - }, - } - - exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v", err) - } - if exitCode != 0 { - t.Fatalf("exitCode = %d, want 0", exitCode) - } - if stdout.String() != "task stdout" { - t.Fatalf("stdout = %q, want %q", stdout.String(), "task stdout") - } - if stderr.String() != "task stderr" { - t.Fatalf("stderr = %q, want %q", stderr.String(), "task stderr") - } -} - -func TestTaskRunnerRun_TaskLookupFailure_IsActionable(t *testing.T) { - runner := taskRunner{ - findTaskBinary: func() (string, error) { return "", errors.New("not found") }, - } - - exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) - if exitCode != 1 { - t.Fatalf("exitCode = %d, want 1", exitCode) - } - if err == nil || !strings.Contains(err.Error(), "Taskwarrior binary lookup failed") { - t.Fatalf("expected actionable task lookup error, got %v", err) - } -} - -func TestTaskRunnerRun_EmptyRepoName_IsActionable(t *testing.T) { - runner := taskRunner{ - findTaskBinary: func() (string, error) { return "/usr/bin/task", nil }, - detectRepoRoot: func(context.Context) (string, error) { return "/", nil }, - } - - exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) - if exitCode != 1 { - t.Fatalf("exitCode = %d, want 1", exitCode) - } - if err == nil || !strings.Contains(err.Error(), "could not derive project name") { - t.Fatalf("expected actionable project-name error, got %v", err) - } -} |
