Skip to content

Commit 7f74273

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 7f74273

24 files changed

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

autosolve-go/go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module github.com/cockroachdb/actions/autosolve-go
2+
3+
go 1.23.8
4+
5+
require github.com/spf13/cobra v1.10.2
6+
7+
require (
8+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
9+
github.com/spf13/pflag v1.0.9 // indirect
10+
)

autosolve-go/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
6+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
7+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
8+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

0 commit comments

Comments
 (0)