summaryrefslogtreecommitdiff
path: root/internal/taskproxy/run.go
blob: 7654b7b661b693df7f3a3282e793616465171f1e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package taskproxy

import (
	"context"
	"errors"
	"fmt"
	"io"
	"os/exec"
	"path/filepath"
	"strings"
)

type binaryFinder func() (string, error)

type repoTopLevelDetector func(context.Context) (string, error)

type commandRunner func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error

type Runner struct {
	CommandName    string
	findTaskBinary binaryFinder
	detectRepoRoot repoTopLevelDetector
	runCommand     commandRunner
}

func NewRunner(commandName string) Runner {
	return Runner{
		CommandName:    strings.TrimSpace(commandName),
		findTaskBinary: findTaskBinary,
		detectRepoRoot: detectRepoRoot,
		runCommand:     runTaskCommand,
	}
}

func (r Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
	runner := normalizeRunner(r)
	taskPath, err := runner.findTaskBinary()
	if err != nil {
		return 1, fmt.Errorf("%s: Taskwarrior binary lookup failed: %w", runner.commandLabel(), err)
	}
	repoRoot, err := runner.detectRepoRoot(ctx)
	if err != nil {
		return 1, fmt.Errorf("%s: must be run inside a git repository so project:<repo> can be derived: %w", runner.commandLabel(), err)
	}
	taskArgs, err := runner.taskArgs(repoRoot, args)
	if err != nil {
		return 1, fmt.Errorf("%s: %w", runner.commandLabel(), err)
	}
	if err := runner.runCommand(ctx, taskPath, taskArgs, stdin, stdout, stderr); err != nil {
		return runner.exitCodeFor(err)
	}
	return 0, nil
}

func normalizeRunner(r Runner) Runner {
	if r.CommandName == "" {
		r.CommandName = "task"
	}
	if r.findTaskBinary == nil {
		r.findTaskBinary = findTaskBinary
	}
	if r.detectRepoRoot == nil {
		r.detectRepoRoot = detectRepoRoot
	}
	if r.runCommand == nil {
		r.runCommand = runTaskCommand
	}
	return r
}

func (r Runner) commandLabel() string {
	label := strings.TrimSpace(r.CommandName)
	if label == "" {
		return "task"
	}
	return label
}

func (r Runner) taskArgs(repoRoot string, args []string) ([]string, error) {
	projectName, err := projectNameFromRoot(repoRoot)
	if err != nil {
		return nil, err
	}
	return append([]string{"project:" + projectName, "+agent"}, args...), nil
}

func (r Runner) exitCodeFor(err error) (int, error) {
	var exitErr *exec.ExitError
	if errors.As(err, &exitErr) {
		return exitErr.ExitCode(), nil
	}
	return 1, fmt.Errorf("%s: failed to run Taskwarrior: %w", r.commandLabel(), err)
}

func projectNameFromRoot(repoRoot string) (string, error) {
	projectName := filepath.Base(strings.TrimSpace(repoRoot))
	if projectName == "" || projectName == "." || projectName == string(filepath.Separator) {
		return "", fmt.Errorf("could not derive project name from git root %q", repoRoot)
	}
	return projectName, nil
}

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