• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 22118232837

17 Feb 2026 10:28PM UTC coverage: 81.917% (+0.2%) from 81.708%
22118232837

Pull #124

github

umputun
fix: process partial line before error check in ParseProgressHeader

ReadString may return data alongside an error; process the line first
before returning the error, matching loadProgressFileIntoSession pattern.
Pull Request #124: Replace bufio.Scanner with unbounded line reader

102 of 108 new or added lines in 5 files covered. (94.44%)

1 existing line in 1 file now uncovered.

5350 of 6531 relevant lines covered (81.92%)

188.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.87
/pkg/executor/custom.go
1
package executor
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "io"
8
        "os"
9
        "os/exec"
10
)
11

12
// CustomRunner abstracts command execution for custom review scripts.
13
// Returns stdout reader and a wait function for completion.
14
type CustomRunner interface {
15
        Run(ctx context.Context, script, promptFile string) (stdout io.Reader, wait func() error, err error)
16
}
17

18
// execCustomRunner is the default command runner using os/exec.
19
type execCustomRunner struct{}
20

21
func (r *execCustomRunner) Run(ctx context.Context, script, promptFile string) (io.Reader, func() error, error) {
3✔
22
        // check context before starting to avoid spawning a process that will be immediately killed
3✔
23
        if err := ctx.Err(); err != nil {
4✔
24
                return nil, nil, fmt.Errorf("context already canceled: %w", err)
1✔
25
        }
1✔
26

27
        // use exec.Command (not CommandContext) because we handle cancellation ourselves
28
        // to ensure the entire process group is killed, not just the direct child
29
        cmd := exec.Command(script, promptFile) //nolint:noctx // intentional: we handle context cancellation via process group kill
2✔
30

2✔
31
        // create new process group so we can kill all descendants on cleanup
2✔
32
        setupProcessGroup(cmd)
2✔
33

2✔
34
        stdout, err := cmd.StdoutPipe()
2✔
35
        if err != nil {
2✔
36
                return nil, nil, fmt.Errorf("stdout pipe: %w", err)
×
37
        }
×
38
        // merge stderr into stdout
39
        cmd.Stderr = cmd.Stdout
2✔
40

2✔
41
        if err := cmd.Start(); err != nil {
3✔
42
                return nil, nil, fmt.Errorf("start command: %w", err)
1✔
43
        }
1✔
44

45
        // setup process group cleanup with graceful shutdown on context cancellation
46
        cleanup := newProcessGroupCleanup(cmd, ctx.Done())
1✔
47

1✔
48
        return stdout, cleanup.Wait, nil
1✔
49
}
50

51
// CustomExecutor runs custom review scripts and streams output.
52
type CustomExecutor struct {
53
        Script        string            // path to the custom review script
54
        OutputHandler func(text string) // called for each output line, can be nil
55
        ErrorPatterns []string          // patterns to detect in output (e.g., rate limit messages)
56
        runner        CustomRunner      // for testing, nil uses default
57
}
58

59
// SetRunner sets the custom runner for testing purposes.
60
func (e *CustomExecutor) SetRunner(r CustomRunner) {
×
61
        e.runner = r
×
62
}
×
63

64
// Run executes the custom review script with the prompt content written to a temp file.
65
// The script receives the path to the prompt file as its single argument.
66
// Output is streamed line-by-line to OutputHandler.
67
func (e *CustomExecutor) Run(ctx context.Context, promptContent string) Result {
20✔
68
        if e.Script == "" {
21✔
69
                return Result{Error: errors.New("custom review script not configured")}
1✔
70
        }
1✔
71

72
        // write prompt to temp file
73
        promptFile, err := os.CreateTemp("", "ralphex-custom-prompt-*.txt")
19✔
74
        if err != nil {
19✔
75
                return Result{Error: fmt.Errorf("create prompt file: %w", err)}
×
76
        }
×
77
        promptPath := promptFile.Name()
19✔
78
        defer os.Remove(promptPath) //nolint:errcheck // cleanup temp file
19✔
79

19✔
80
        if _, writeErr := promptFile.WriteString(promptContent); writeErr != nil {
19✔
81
                promptFile.Close()
×
82
                return Result{Error: fmt.Errorf("write prompt file: %w", writeErr)}
×
83
        }
×
84
        if closeErr := promptFile.Close(); closeErr != nil {
19✔
85
                return Result{Error: fmt.Errorf("close prompt file: %w", closeErr)}
×
86
        }
×
87

88
        runner := e.runner
19✔
89
        if runner == nil {
19✔
90
                runner = &execCustomRunner{}
×
91
        }
×
92

93
        stdout, wait, err := runner.Run(ctx, e.Script, promptPath)
19✔
94
        if err != nil {
20✔
95
                return Result{Error: fmt.Errorf("start custom script: %w", err)}
1✔
96
        }
1✔
97

98
        // process stdout for output and signal detection
99
        output, signal, streamErr := e.processOutput(ctx, stdout)
18✔
100

18✔
101
        // wait for command completion
18✔
102
        waitErr := wait()
18✔
103

18✔
104
        // determine final error
18✔
105
        var finalErr error
18✔
106
        switch {
18✔
107
        case streamErr != nil:
1✔
108
                finalErr = streamErr
1✔
109
        case waitErr != nil:
1✔
110
                if ctx.Err() != nil {
1✔
UNCOV
111
                        finalErr = fmt.Errorf("context error: %w", ctx.Err())
×
112
                } else {
1✔
113
                        finalErr = fmt.Errorf("custom script exited with error: %w", waitErr)
1✔
114
                }
1✔
115
        }
116

117
        // check for error patterns in output
118
        if pattern := checkErrorPatterns(output, e.ErrorPatterns); pattern != "" {
20✔
119
                return Result{
2✔
120
                        Output: output,
2✔
121
                        Signal: signal,
2✔
122
                        Error:  &PatternMatchError{Pattern: pattern, HelpCmd: e.Script + " --help"},
2✔
123
                }
2✔
124
        }
2✔
125

126
        return Result{Output: output, Signal: signal, Error: finalErr}
16✔
127
}
128

129
// processOutput reads stdout line-by-line, streams to OutputHandler, and detects signals.
130
func (e *CustomExecutor) processOutput(ctx context.Context, r io.Reader) (output, signal string, err error) {
20✔
131
        var outputBuf []byte
20✔
132
        var sig string
20✔
133

20✔
134
        readErr := readLines(ctx, r, func(line string) {
51✔
135
                outputBuf = append(outputBuf, line...)
31✔
136
                outputBuf = append(outputBuf, '\n')
31✔
137

31✔
138
                if e.OutputHandler != nil {
38✔
139
                        e.OutputHandler(line + "\n")
7✔
140
                }
7✔
141

142
                // check for signals in each line
143
                if s := detectSignal(line); s != "" {
40✔
144
                        sig = s
9✔
145
                }
9✔
146
        })
147

148
        if readErr != nil {
23✔
149
                return string(outputBuf), sig, fmt.Errorf("read output: %w", readErr)
3✔
150
        }
3✔
151
        return string(outputBuf), sig, nil
17✔
152
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc