Skip to content

Commit 3b9b49a

Browse files
Add Go rewrite of autosolve actions
Single Go binary (autosolve) replaces the bash script chain with typed config, mockable interfaces for Claude CLI and GitHub API, embedded prompt templates, and 45 unit tests. Composite action YAMLs reduced to two steps each (build + run). Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
1 parent fed481b commit 3b9b49a

24 files changed

Lines changed: 2858 additions & 0 deletions

autosolve-go/Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.PHONY: build test clean
2+
3+
build:
4+
go build -o autosolve ./cmd/autosolve
5+
6+
test:
7+
go test ./... -count=1
8+
9+
clean:
10+
rm -f autosolve

autosolve-go/assess/action.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Autosolve Assess (Go)
2+
description: Run Claude in read-only mode to assess whether a task is suitable for automated resolution.
3+
4+
inputs:
5+
prompt:
6+
description: The task to assess. Plain text instructions describing what needs to be done.
7+
required: false
8+
default: ""
9+
skill:
10+
description: Path to a skill/prompt file relative to the repo root.
11+
required: false
12+
default: ""
13+
additional_instructions:
14+
description: Extra context appended after the task prompt but before the assessment footer.
15+
required: false
16+
default: ""
17+
assessment_criteria:
18+
description: Custom criteria for the assessment. If not provided, uses default criteria.
19+
required: false
20+
default: ""
21+
model:
22+
description: Claude model ID.
23+
required: false
24+
default: "claude-opus-4-6"
25+
blocked_paths:
26+
description: Comma-separated path prefixes that cannot be modified (injected into security preamble).
27+
required: false
28+
default: ".github/workflows/"
29+
claude_cli_version:
30+
description: Claude CLI version to install.
31+
required: false
32+
default: "2.1.79"
33+
34+
outputs:
35+
assessment:
36+
description: PROCEED or SKIP
37+
value: ${{ steps.assess.outputs.assessment }}
38+
summary:
39+
description: Human-readable assessment reasoning.
40+
value: ${{ steps.assess.outputs.summary }}
41+
result:
42+
description: Full Claude result text.
43+
value: ${{ steps.assess.outputs.result }}
44+
45+
runs:
46+
using: "composite"
47+
steps:
48+
- name: Build autosolve
49+
shell: bash
50+
run: cd "${{ github.action_path }}/.." && go build -o /tmp/autosolve ./cmd/autosolve
51+
52+
- name: Run assessment
53+
id: assess
54+
shell: bash
55+
run: /tmp/autosolve assess
56+
env:
57+
INPUT_PROMPT: ${{ inputs.prompt }}
58+
INPUT_SKILL: ${{ inputs.skill }}
59+
INPUT_ADDITIONAL_INSTRUCTIONS: ${{ inputs.additional_instructions }}
60+
INPUT_ASSESSMENT_CRITERIA: ${{ inputs.assessment_criteria }}
61+
INPUT_MODEL: ${{ inputs.model }}
62+
INPUT_BLOCKED_PATHS: ${{ inputs.blocked_paths }}
63+
CLAUDE_CLI_VERSION: ${{ inputs.claude_cli_version }}

autosolve-go/cmd/autosolve/main.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"strconv"
9+
"strings"
10+
11+
"github.com/cockroachdb/actions/autosolve-go/internal/action"
12+
"github.com/cockroachdb/actions/autosolve-go/internal/assess"
13+
"github.com/cockroachdb/actions/autosolve-go/internal/claude"
14+
"github.com/cockroachdb/actions/autosolve-go/internal/config"
15+
"github.com/cockroachdb/actions/autosolve-go/internal/github"
16+
"github.com/cockroachdb/actions/autosolve-go/internal/implement"
17+
"github.com/cockroachdb/actions/autosolve-go/internal/prompt"
18+
"github.com/cockroachdb/actions/autosolve-go/internal/security"
19+
)
20+
21+
const usage = `Usage: autosolve <command>
22+
23+
Commands:
24+
assess Run assessment phase
25+
implement Run implementation phase
26+
security Run security check on working tree
27+
prompt build Assemble the full prompt file
28+
prompt issue Build prompt from GitHub issue context
29+
comment Post a comment on a GitHub issue
30+
label remove Remove a label from a GitHub issue`
31+
32+
func main() {
33+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
34+
defer cancel()
35+
36+
if len(os.Args) < 2 {
37+
fatalf(usage)
38+
}
39+
40+
// Join args to support two-word commands like "prompt build"
41+
cmd := os.Args[1]
42+
if len(os.Args) > 2 {
43+
cmd = os.Args[1] + " " + os.Args[2]
44+
}
45+
46+
var err error
47+
switch cmd {
48+
case "assess":
49+
err = runAssess(ctx)
50+
case "implement":
51+
err = runImplement(ctx)
52+
case "security":
53+
err = runSecurity()
54+
case "prompt build":
55+
err = runPromptBuild()
56+
case "prompt issue":
57+
err = runPromptIssue()
58+
case "comment":
59+
err = runComment(ctx)
60+
case "label remove":
61+
err = runLabelRemove(ctx)
62+
default:
63+
fatalf("unknown command: %s\n\n%s", os.Args[1], usage)
64+
}
65+
66+
if err != nil {
67+
action.LogError(err.Error())
68+
os.Exit(1)
69+
}
70+
}
71+
72+
func fatalf(format string, args ...any) {
73+
fmt.Fprintf(os.Stderr, format+"\n", args...)
74+
os.Exit(1)
75+
}
76+
77+
func runAssess(ctx context.Context) error {
78+
cfg, err := config.LoadAssessConfig()
79+
if err != nil {
80+
return err
81+
}
82+
if err := config.ValidateAuth(); err != nil {
83+
return err
84+
}
85+
if err := claude.EnsureCLI(cfg.CLIVersion); err != nil {
86+
return err
87+
}
88+
tmpDir, err := ensureTmpDir()
89+
if err != nil {
90+
return err
91+
}
92+
return assess.Run(ctx, cfg, &claude.CLIRunner{}, tmpDir)
93+
}
94+
95+
func runImplement(ctx context.Context) error {
96+
cfg, err := config.LoadImplementConfig()
97+
if err != nil {
98+
return err
99+
}
100+
if err := config.ValidateAuth(); err != nil {
101+
return err
102+
}
103+
if err := claude.EnsureCLI(cfg.CLIVersion); err != nil {
104+
return err
105+
}
106+
tmpDir, err := ensureTmpDir()
107+
if err != nil {
108+
return err
109+
}
110+
defer implement.Cleanup()
111+
112+
ghClient := &github.GHClient{Token: cfg.PRCreateToken}
113+
return implement.Run(ctx, cfg, &claude.CLIRunner{}, ghClient, tmpDir)
114+
}
115+
116+
func runSecurity() error {
117+
cfg, err := config.LoadSecurityConfig()
118+
if err != nil {
119+
return err
120+
}
121+
violations, err := security.Check(cfg.BlockedPaths)
122+
if err != nil {
123+
return err
124+
}
125+
if len(violations) > 0 {
126+
for _, v := range violations {
127+
action.LogError(v)
128+
}
129+
return fmt.Errorf("security check failed: %d violation(s) found", len(violations))
130+
}
131+
action.LogNotice("Security check passed")
132+
return nil
133+
}
134+
135+
func runPromptBuild() error {
136+
footerType := envOrDefault("INPUT_FOOTER_TYPE", "implementation")
137+
cfg := &config.Config{
138+
Prompt: os.Getenv("INPUT_PROMPT"),
139+
Skill: os.Getenv("INPUT_SKILL"),
140+
AdditionalInstructions: os.Getenv("INPUT_ADDITIONAL_INSTRUCTIONS"),
141+
AssessmentCriteria: os.Getenv("INPUT_ASSESSMENT_CRITERIA"),
142+
BlockedPaths: config.ParseBlockedPaths(os.Getenv("INPUT_BLOCKED_PATHS")),
143+
FooterType: footerType,
144+
}
145+
if cfg.Prompt == "" && cfg.Skill == "" {
146+
return fmt.Errorf("at least one of 'prompt' or 'skill' must be provided")
147+
}
148+
tmpDir, err := ensureTmpDir()
149+
if err != nil {
150+
return err
151+
}
152+
path, err := prompt.Build(cfg, tmpDir)
153+
if err != nil {
154+
return err
155+
}
156+
action.SetOutput("prompt_file", path)
157+
return nil
158+
}
159+
160+
func runPromptIssue() error {
161+
result := prompt.BuildIssuePrompt(
162+
os.Getenv("INPUT_PROMPT"),
163+
os.Getenv("ISSUE_NUMBER"),
164+
os.Getenv("ISSUE_TITLE"),
165+
os.Getenv("ISSUE_BODY"),
166+
)
167+
action.SetOutputMultiline("prompt", result)
168+
return nil
169+
}
170+
171+
func runComment(ctx context.Context) error {
172+
token := os.Getenv("GITHUB_TOKEN_INPUT")
173+
issueStr := os.Getenv("ISSUE_NUMBER")
174+
commentType := os.Getenv("COMMENT_TYPE")
175+
if token == "" || issueStr == "" || commentType == "" {
176+
return fmt.Errorf("GITHUB_TOKEN_INPUT, ISSUE_NUMBER, and COMMENT_TYPE are required")
177+
}
178+
179+
ghClient := &github.GHClient{Token: token}
180+
repo := os.Getenv("GITHUB_REPOSITORY")
181+
issue, _ := strconv.Atoi(issueStr)
182+
183+
var body string
184+
switch commentType {
185+
case "skipped":
186+
summary := os.Getenv("SUMMARY")
187+
sanitized := sanitizeForCodeBlock(summary)
188+
body = fmt.Sprintf("Auto-solver assessed this issue but determined it is not suitable for automated resolution.\n\n```\n%s\n```", sanitized)
189+
case "success":
190+
prURL := os.Getenv("PR_URL")
191+
if prURL == "" {
192+
return fmt.Errorf("PR_URL is required for success comment")
193+
}
194+
body = fmt.Sprintf("Auto-solver has created a draft PR: %s\n\nPlease review the changes carefully before approving.", prURL)
195+
case "failed":
196+
body = "Auto-solver attempted to fix this issue but was unable to complete the implementation.\n\nThis issue may require human intervention."
197+
default:
198+
return fmt.Errorf("unknown comment type: %s", commentType)
199+
}
200+
201+
return ghClient.CreateComment(ctx, repo, issue, body)
202+
}
203+
204+
func runLabelRemove(ctx context.Context) error {
205+
token := os.Getenv("GITHUB_TOKEN_INPUT")
206+
issueStr := os.Getenv("ISSUE_NUMBER")
207+
if token == "" || issueStr == "" {
208+
return fmt.Errorf("GITHUB_TOKEN_INPUT and ISSUE_NUMBER are required")
209+
}
210+
label := envOrDefault("TRIGGER_LABEL", "autosolve")
211+
repo := os.Getenv("GITHUB_REPOSITORY")
212+
213+
issue, _ := strconv.Atoi(issueStr)
214+
ghClient := &github.GHClient{Token: token}
215+
return ghClient.RemoveLabel(ctx, repo, issue, label)
216+
}
217+
218+
func ensureTmpDir() (string, error) {
219+
dir := os.Getenv("AUTOSOLVE_TMPDIR")
220+
if dir != "" {
221+
return dir, nil
222+
}
223+
dir, err := os.MkdirTemp("", "autosolve_*")
224+
if err != nil {
225+
return "", fmt.Errorf("creating temp dir: %w", err)
226+
}
227+
os.Setenv("AUTOSOLVE_TMPDIR", dir)
228+
return dir, nil
229+
}
230+
231+
func envOrDefault(key, def string) string {
232+
if v := os.Getenv(key); v != "" {
233+
return v
234+
}
235+
return def
236+
}
237+
238+
// sanitizeForCodeBlock strips HTML tags and escapes triple backticks so the
239+
// text can safely be placed inside a markdown code fence.
240+
func sanitizeForCodeBlock(s string) string {
241+
var b strings.Builder
242+
inTag := false
243+
for _, c := range s {
244+
if c == '<' {
245+
inTag = true
246+
continue
247+
}
248+
if c == '>' && inTag {
249+
inTag = false
250+
continue
251+
}
252+
if !inTag {
253+
b.WriteRune(c)
254+
}
255+
}
256+
return strings.ReplaceAll(b.String(), "```", "` ` `")
257+
}

autosolve-go/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/cockroachdb/actions/autosolve-go
2+
3+
go 1.23.8

autosolve-go/go.sum

Whitespace-only changes.

0 commit comments

Comments
 (0)