Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ enforcement, sensitive file detection, and token usage tracking.
| `fork_repo` | **required** | Repository name of the fork |
| `fork_push_token` | **required** | PAT with `contents: write` on the fork repository |
| `pr_create_token` | **required** | PAT with `pull_requests: write` on the target repo (see [Token permissions](#token-permissions)) |
| `allow_fork_force_sync` | `false` | When the fork's base branch has diverged from `origin`, force-overwrite it instead of aborting. Only safe for test/throwaway forks. |
| `blocked_paths` | `""` | Comma-separated path prefixes that cannot be modified (case-sensitive). `.github/` is always blocked. |
| `git_user_name` | `autosolve[bot]` | Git author/committer name |
| `git_user_email` | `autosolve[bot]@users.noreply.github.com` | Git author/committer email |
Expand Down Expand Up @@ -265,9 +266,17 @@ must have write on the target and read on the fork.

| Token | Fine-grained | Classic |
| ------------------ | ------------------------------------------- | ------- |
| `fork_push_token` | `contents: write` on the fork repository | `repo` |
| `fork_push_token` | `contents: write` on the fork repository (plus `workflows: write` — see below) | `repo` (plus `workflow` — see below) |
| `pr_create_token` | `pull_requests: write` on the target repository | `repo` |

The `fork_push_token` additionally needs the `workflow` scope (classic) or
`workflows: write` permission (fine-grained) if the fork's base branch has
fallen behind upstream by any commit that touches `.github/workflows/`.
The bot's own commits can never include workflow-file changes (autosolve
blocks `.github/` from staging), so this requirement only comes from
upstream commits relayed during the fork base-branch sync. Without the
scope, GitHub rejects the sync push with a workflow-scope error.

Applying labels (`pr_labels`) requires `issues: write` on the target repo
(already covered by `repo` for classic tokens). If the token lacks this
permission, the action logs a warning and creates the PR without labels.
Expand Down
4 changes: 3 additions & 1 deletion autosolve/cmd/autosolve/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ func runImplement(ctx context.Context) error {
return err
}

gitClient := &git.CLIClient{}
gitClient := &git.CLIClient{
AuthEnv: git.NewAuthEnv(cfg.ForkOwner, cfg.ForkPushToken),
}
ghClient := &github.GithubClient{Token: cfg.PRCreateToken}
return implement.Run(ctx, cfg, &claude.CLIRunner{}, ghClient, gitClient, tmpDir)
}
Expand Down
9 changes: 9 additions & 0 deletions autosolve/implement/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ inputs:
description: PAT with permission to create PRs on the upstream repo.
required: false
default: ""
allow_fork_force_sync:
description: >
Whether to allow force-overwriting the fork's base branch when it has
diverged from origin. Defaults to false: a fast-forward is attempted
first, and if it fails the action aborts. Enable only for test or
throwaway forks where overwriting commits on the base branch is safe.
required: false
default: "false"
blocked_paths:
description: >
Comma-separated path prefixes that cannot be modified.
Expand Down Expand Up @@ -256,6 +264,7 @@ runs:
INPUT_FORK_REPO: ${{ inputs.fork_repo }}
INPUT_FORK_PUSH_TOKEN: ${{ inputs.fork_push_token }}
INPUT_PR_CREATE_TOKEN: ${{ inputs.pr_create_token }}
INPUT_ALLOW_FORK_FORCE_SYNC: ${{ inputs.allow_fork_force_sync }}
INPUT_BLOCKED_PATHS: ${{ inputs.blocked_paths }}
INPUT_GIT_USER_NAME: ${{ inputs.git_user_name }}
INPUT_GIT_USER_EMAIL: ${{ inputs.git_user_email }}
Expand Down
19 changes: 19 additions & 0 deletions autosolve/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ type Config struct {
BranchSuffix string
CommitSignature string

// AllowForkForceSync controls fork base-branch sync behavior. When
// false (default), the fork's base branch is fast-forwarded from
// origin and the run aborts if the FF push is rejected. When true,
// a rejected FF falls back to a force push that overwrites any
// commits on fork/<base> not in origin/<base>. Enable only for
// test/throwaway forks.
AllowForkForceSync bool

// GitHub context
GithubRepository string
PRTargetRepo string // repo where PRs are created; defaults to GithubRepository
Expand Down Expand Up @@ -117,6 +125,10 @@ func LoadImplementConfig() (*Config, error) {
if err != nil {
return nil, err
}
allowForkForceSync, err := envBool("INPUT_ALLOW_FORK_FORCE_SYNC", false)
if err != nil {
return nil, err
}
logLevel, err := parseLogLevel(os.Getenv("INPUT_LOG_LEVEL"))
if err != nil {
return nil, err
Expand Down Expand Up @@ -153,6 +165,8 @@ func LoadImplementConfig() (*Config, error) {
BranchSuffix: os.Getenv("INPUT_BRANCH_SUFFIX"),
CommitSignature: envOrDefault("INPUT_COMMIT_SIGNATURE", defaultCommitSignature),

AllowForkForceSync: allowForkForceSync,

GithubRepository: os.Getenv("GITHUB_REPOSITORY"),
PRTargetRepo: envOrDefault("INPUT_PR_TARGET_REPO", os.Getenv("GITHUB_REPOSITORY")),
}
Expand Down Expand Up @@ -260,6 +274,11 @@ func (c *Config) SecurityReviewModel() string {
return "claude-sonnet-4-6"
}

// ForkURL returns the HTTPS clone URL for the fork.
func (c *Config) ForkURL() string {
return fmt.Sprintf("https://github.com/%s/%s.git", c.ForkOwner, c.ForkRepo)
}

// sandboxInputs is the parsed/validated set of inputs that determine the
// bubblewrap bind layout. Loaded once per Config so both subcommands share
// the same validation rules.
Expand Down
58 changes: 53 additions & 5 deletions autosolve/internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
Expand All @@ -19,14 +20,40 @@ type Client interface {
Add(args ...string) error
Commit(message string) error
Push(args ...string) error
BranchExists(remoteURL, branch string) (bool, error)
RevParse(args ...string) (string, error)
ResetHead() error
}

// CLIClient implements Client by shelling out to the git binary.
// Extra env vars (e.g. for authentication) can be set via PushEnv;
// they are applied only to git push commands.
//
// AuthEnv carries env vars (typically GIT_ASKPASS plus credentials)
// applied to remote-contacting commands (Push, BranchExists). Set this
// only when every remote-contacting call should use the same credentials
// — the env is not URL-scoped, so adding a new call site that targets a
// different remote would silently send these creds there. Local-only
// commands ignore AuthEnv entirely (git only invokes the askpass when
// challenged for credentials).
type CLIClient struct {
PushEnv []string
AuthEnv []string
}

// NewAuthEnv builds the env block consumed by both CLIClient.AuthEnv
// and scripts/git-askpass.sh: GIT_ASKPASS points at the askpass script,
// GIT_USER/GIT_PASSWORD carry the credentials it returns,
// GIT_TERMINAL_PROMPT=0 prevents git from falling back to an interactive
// prompt if anything goes wrong.
//
// The askpass script lives under SCRIPTS_DIR, an env var the autosolve
// action sets before invoking the binary (and config validates is present).
func NewAuthEnv(user, token string) []string {
askpass := filepath.Join(os.Getenv("SCRIPTS_DIR"), "git-askpass.sh")
return []string{
"GIT_ASKPASS=" + askpass,
"GIT_USER=" + user,
"GIT_PASSWORD=" + token,
"GIT_TERMINAL_PROMPT=0",
}
}

func (c *CLIClient) Diff(args ...string) (string, error) {
Expand Down Expand Up @@ -64,12 +91,33 @@ func (c *CLIClient) Push(args ...string) error {
cmd := exec.Command("git", append([]string{"push"}, args...)...)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if len(c.PushEnv) > 0 {
cmd.Env = append(os.Environ(), c.PushEnv...)
if len(c.AuthEnv) > 0 {
cmd.Env = append(os.Environ(), c.AuthEnv...)
}
return cmd.Run()
}

// BranchExists reports whether the named branch exists on the given
// remote URL. Implemented via `git ls-remote <url> refs/heads/<branch>`,
// which prints "<sha>\t<refname>" for a match and nothing for a miss
// (exit 0 in both cases; nonzero only on network/auth/URL errors).
func (c *CLIClient) BranchExists(remoteURL, branch string) (bool, error) {
cmd := exec.Command("git", "ls-remote", remoteURL, "refs/heads/"+branch)
cmd.Stderr = os.Stderr
if len(c.AuthEnv) > 0 {
cmd.Env = append(os.Environ(), c.AuthEnv...)
}
out, err := cmd.Output()
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) != "", nil
}

func (c *CLIClient) RevParse(args ...string) (string, error) {
return c.output(append([]string{"rev-parse"}, args...)...)
}

func (c *CLIClient) ResetHead() error {
cmd := exec.Command("git", "reset", "HEAD")
cmd.Stdout = os.Stderr
Expand Down
28 changes: 7 additions & 21 deletions autosolve/internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
type Client interface {
CreatePR(ctx context.Context, opts PullRequestOptions) (string, error)
CreateLabel(ctx context.Context, repo string, name string) error
BranchExists(ctx context.Context, repo, branch string) (bool, error)
}

// PullRequestOptions configures PR creation.
Expand All @@ -28,7 +27,8 @@ type PullRequestOptions struct {
Draft bool
}

// GithubClient implements Client by shelling out to the gh CLI.
// GithubClient is a Client implementation that shells out to the gh CLI,
// authenticating with the given Token.
type GithubClient struct {
Token string
}
Expand Down Expand Up @@ -60,36 +60,22 @@ func (c *GithubClient) CreateLabel(ctx context.Context, repo string, name string
cmd := c.command(ctx, "label", "create", name,
"--repo", repo,
"--color", "6f42c1")
// Capture stderr so we can distinguish "already exists" from real errors.
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if strings.Contains(stderr.String(), "already exists") {
msg := stderr.String()
if strings.Contains(msg, "already exists") {
return nil
}
return fmt.Errorf("creating label %q: %s", name, strings.TrimSpace(stderr.String()))
_, _ = os.Stderr.WriteString(msg)
return fmt.Errorf("creating label %q: %w", name, err)
}
return nil
}

func (c *GithubClient) BranchExists(ctx context.Context, repo, branch string) (bool, error) {
cmd := c.command(ctx, "api", fmt.Sprintf("repos/%s/branches/%s", repo, branch),
"--silent")
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if strings.Contains(stderr.String(), "Not Found") ||
strings.Contains(stderr.String(), "404") {
return false, nil
}
return false, fmt.Errorf("checking branch %q: %s", branch, strings.TrimSpace(stderr.String()))
}
return true, nil
}

func (c *GithubClient) command(ctx context.Context, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "gh", args...)
cmd.Env = append(os.Environ(), fmt.Sprintf("GH_TOKEN=%s", c.Token))
cmd.Env = append(os.Environ(), "GH_TOKEN="+c.Token)
cmd.Stderr = os.Stderr
return cmd
}
77 changes: 55 additions & 22 deletions autosolve/internal/implement/implement.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const retryPrompt = "The previous attempt did not succeed. Please review what we
var RetryDelay = 10 * time.Second

// Run executes the implementation phase.
//
// ghClient targets the upstream repo where the PR will be created (needs
// pull_requests:write). All fork-side operations go through gitClient.
func Run(
ctx context.Context,
cfg *config.Config,
Expand Down Expand Up @@ -56,14 +59,63 @@ func Run(
branchName := cfg.BranchPrefix + suffix

forkRepo := fmt.Sprintf("%s/%s", cfg.ForkOwner, cfg.ForkRepo)
exists, err := ghClient.BranchExists(ctx, forkRepo, branchName)
exists, err := gitClient.BranchExists(cfg.ForkURL(), branchName)
if err != nil {
return fmt.Errorf("checking branch availability: %w", err)
return fmt.Errorf("checking branch availability on %s: %w", forkRepo, err)
}
if exists {
return fmt.Errorf("branch %q already exists on %s — delete it or use a different branch_suffix", branchName, forkRepo)
}

// Verify the local origin tracking ref for the PR base exists; the
// fork sync push (next) reads from it. Without this check, a missing
// ref surfaces as a confusing "src refspec ... does not match any"
// failure that our sync error message misattributes to fork divergence.
baseRef := "refs/remotes/origin/" + cfg.PRBaseBranch
if _, err := gitClient.RevParse("--verify", baseRef); err != nil {
return fmt.Errorf("%s not found locally — the workflow's actions/checkout step must include this branch (used as the sync source for fork/%s): %w", baseRef, cfg.PRBaseBranch, err)
}

// Configure git identity, add the fork remote, and create the local
// feature branch. Done upfront (before the fork sync and Claude run)
// so all fork-affecting setup is in one place and the sync push can
// use the `fork` remote name like commitAndPush does.
if err := setupForkRemote(cfg, gitClient, branchName); err != nil {
return fmt.Errorf("preparing fork remote: %w", err)
}

// Sync the fork's base branch with the local origin tracking ref so
// the eventual feature-branch push only transmits the bot's commit.
// Without this, a stale fork would force git to relay every upstream
// commit it lacks.
//
// We push refs/remotes/origin/<base> directly rather than fetching
// first — actions/checkout just brought it down from the workflow's
// repo, so it's already current and a re-fetch would only require
// origin auth that the workflow may not have configured. The sync
// source is whatever the workflow checked out as origin (typically
// github.repository), not GitHub's tracked fork-parent metadata —
// this matters when the bot's fork was created from a different repo
// than the one the PR targets.
//
// Run this before invoking Claude so a divergent or unreachable fork
// fails the run before any tokens are spent.
//
// Try a fast-forward first so a divergent fork base branch can't be
// silently overwritten. AllowForkForceSync gates the fallback for test
// forks where overwriting is acceptable.
action.LogNotice(fmt.Sprintf("Syncing fork's %s with origin", cfg.PRBaseBranch))
syncRefspec := fmt.Sprintf("refs/remotes/origin/%s:refs/heads/%s", cfg.PRBaseBranch, cfg.PRBaseBranch)
if err := gitClient.Push("fork", syncRefspec); err != nil {
if !cfg.AllowForkForceSync {
return fmt.Errorf("fast-forward sync to fork/%s rejected (likely diverged from origin/%s): %w. Set allow_fork_force_sync=true to overwrite the fork's base branch", cfg.PRBaseBranch, cfg.PRBaseBranch, err)
}
action.LogWarning(fmt.Sprintf("Fast-forward sync rejected; force-overwriting fork/%s from origin/%s (allow_fork_force_sync=true)", cfg.PRBaseBranch, cfg.PRBaseBranch))
if err := gitClient.Push("--force", "fork", syncRefspec); err != nil {
return fmt.Errorf("force-syncing fork's %s: %w", cfg.PRBaseBranch, err)
}
}

// Build prompt
promptFile, err := prompt.Build(cfg, tmpDir)
if err != nil {
Expand Down Expand Up @@ -199,12 +251,6 @@ func Run(
// Stage, validate, and submit
var prURL string
if implStatus == "SUCCESS" {
if err := setupForkRemote(cfg, gitClient, branchName); err != nil {
// Write outputs before returning the error so status/summary are
// available to subsequent workflow steps.
_ = writeOutputs("FAILED", "", "", resultText, &tracker)
return fmt.Errorf("PR creation failed: %w", err)
}
commitSubject, commitBody, err := stageChanges(cfg, gitClient, tmpDir)
if err != nil {
_ = writeOutputs("FAILED", "", "", resultText, &tracker)
Expand Down Expand Up @@ -249,20 +295,7 @@ func setupForkRemote(cfg *config.Config, gitClient git.Client, branchName string
return fmt.Errorf("setting git user.email: %w", err)
}

// Set fork credentials and GIT_ASKPASS for the git push subprocess
// only, so the token is never in the broader process environment or
// written to disk.
if cliClient, ok := gitClient.(*git.CLIClient); ok {
askpass := filepath.Join(os.Getenv("SCRIPTS_DIR"), "git-askpass.sh")
cliClient.PushEnv = []string{
"GIT_ASKPASS=" + askpass,
"GIT_FORK_USER=" + cfg.ForkOwner,
"GIT_FORK_PASSWORD=" + cfg.ForkPushToken,
"GIT_TERMINAL_PROMPT=0",
}
}

forkURL := fmt.Sprintf("https://github.com/%s/%s.git", cfg.ForkOwner, cfg.ForkRepo)
forkURL := cfg.ForkURL()

remotes, err := gitClient.Remote()
if err != nil {
Expand Down
Loading
Loading