Skip to content
Merged
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
10 changes: 6 additions & 4 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ if [ -z "$STAGED_GO" ]; then
exit 0
fi

echo "pre-commit: goimports..."
if command -v goimports &>/dev/null; then
echo "pre-commit: formatting..."
if command -v gofumpt &>/dev/null && command -v goimports &>/dev/null; then
echo "$STAGED_GO" | xargs gofumpt -w
echo "$STAGED_GO" | xargs goimports -w
echo "$STAGED_GO" | xargs git diff --exit-code -- >/dev/null 2>&1 || {
echo "pre-commit: goimports produced changes — re-staged."
echo "pre-commit: formatter produced changes — re-staged."
echo "$STAGED_GO" | xargs git add
}
else
echo "pre-commit: goimports not found, skipping format check"
echo "pre-commit: gofumpt/goimports not found, skipping format check"
echo " install: go install mvdan.cc/gofumpt@latest"
echo " install: go install golang.org/x/tools/cmd/goimports@latest"
fi

Expand Down
9 changes: 9 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Forge pre-push hook: runs full test suite so CI failures are caught locally.
# Install: git config core.hooksPath .githooks
set -euo pipefail

echo "pre-push: running tests..."
make test

echo "pre-push: OK"
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:

- name: Format check
run: |
go install mvdan.cc/gofumpt@latest
go install golang.org/x/tools/cmd/goimports@latest
make fmt
git diff --exit-code || (echo "::error::goimports produced changes; run 'make fmt' locally" && exit 1)
Expand Down
2 changes: 1 addition & 1 deletion cmd/forge/cmd_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func cmdInit() error {
return fmt.Errorf("rendering template: %w", err)
}

if err := os.WriteFile(configPath, buf.Bytes(), 0644); err != nil {
if err := os.WriteFile(configPath, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", configPath, err)
}

Expand Down
6 changes: 4 additions & 2 deletions internal/graph/topsort.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
"strings"
)

var depPattern = regexp.MustCompile(`(?i)(?:depends on|blocked by)\s+(#\d+(?:,\s*#\d+)*)`)
var issueNumPattern = regexp.MustCompile(`#(\d+)`)
var (
depPattern = regexp.MustCompile(`(?i)(?:depends on|blocked by)\s+(#\d+(?:,\s*#\d+)*)`)
issueNumPattern = regexp.MustCompile(`#(\d+)`)
)

// ParseDeps extracts issue dependencies from a GitHub issue body.
// It looks for "Depends on #N" and "Blocked by #N" patterns (case-insensitive),
Expand Down
5 changes: 4 additions & 1 deletion internal/intent/classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import (
// CommandContext is the function used to create exec.Cmd. Override in tests.
var CommandContext = exec.CommandContext

// LookPath is the function used to find executables. Override in tests.
var LookPath = exec.LookPath

// MinConfidence is the minimum confidence score required to accept a classification.
const MinConfidence = 0.5

// Classify interprets a natural language query as a forge command.
func Classify(ctx context.Context, query string) (*Result, error) {
if _, err := exec.LookPath("claude"); err != nil {
if _, err := LookPath("claude"); err != nil {
return nil, ErrNoClaude
}

Expand Down
26 changes: 20 additions & 6 deletions internal/intent/classify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,17 @@ func fakeCommandContext(output string, exitErr bool) func(ctx context.Context, n
}
}

func fakeLookPath(string) (string, error) { return "/fake/claude", nil }

func TestClassify_Success(t *testing.T) {
// Envelope wrapping actual JSON result.
envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.95,\"reasoning\":\"user wants to run auth plan\"}"}`

orig := CommandContext
origLP := LookPath
CommandContext = fakeCommandContext(envelope, false)
defer func() { CommandContext = orig }()
LookPath = fakeLookPath
defer func() { CommandContext = orig; LookPath = origLP }()

r, err := Classify(context.Background(), "run the auth plan")
if err != nil {
Expand All @@ -71,8 +75,10 @@ func TestClassify_NoClaude(t *testing.T) {

func TestClassify_ExitError(t *testing.T) {
orig := CommandContext
origLP := LookPath
CommandContext = fakeCommandContext("something went wrong", true)
defer func() { CommandContext = orig }()
LookPath = fakeLookPath
defer func() { CommandContext = orig; LookPath = origLP }()

_, err := Classify(context.Background(), "do something")
if !errors.Is(err, ErrClassificationFailed) {
Expand All @@ -82,8 +88,10 @@ func TestClassify_ExitError(t *testing.T) {

func TestClassify_MalformedJSON(t *testing.T) {
orig := CommandContext
origLP := LookPath
CommandContext = fakeCommandContext(`{"result":"not json at all"}`, false)
defer func() { CommandContext = orig }()
LookPath = fakeLookPath
defer func() { CommandContext = orig; LookPath = origLP }()

_, err := Classify(context.Background(), "do something")
if !errors.Is(err, ErrClassificationFailed) {
Expand All @@ -93,8 +101,10 @@ func TestClassify_MalformedJSON(t *testing.T) {

func TestClassify_EmptyArgv(t *testing.T) {
orig := CommandContext
origLP := LookPath
CommandContext = fakeCommandContext(`{"result":"{\"argv\":[],\"confidence\":0.1,\"reasoning\":\"unclear\"}"}`, false)
defer func() { CommandContext = orig }()
LookPath = fakeLookPath
defer func() { CommandContext = orig; LookPath = origLP }()

_, err := Classify(context.Background(), "do something")
if !errors.Is(err, ErrClassificationFailed) {
Expand All @@ -107,8 +117,10 @@ func TestClassify_CodeFencedJSON(t *testing.T) {
fenced := "{\"result\":\"```json\\n{\\\"argv\\\":[\\\"runs\\\"],\\\"confidence\\\":0.9,\\\"reasoning\\\":\\\"list runs\\\"}\\n```\"}"

orig := CommandContext
origLP := LookPath
CommandContext = fakeCommandContext(fenced, false)
defer func() { CommandContext = orig }()
LookPath = fakeLookPath
defer func() { CommandContext = orig; LookPath = origLP }()

r, err := Classify(context.Background(), "show my runs")
if err != nil {
Expand All @@ -134,8 +146,10 @@ func TestClassify_LowConfidence(t *testing.T) {
envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.2,\"reasoning\":\"not sure\"}"}`

orig := CommandContext
origLP := LookPath
CommandContext = fakeCommandContext(envelope, false)
defer func() { CommandContext = orig }()
LookPath = fakeLookPath
defer func() { CommandContext = orig; LookPath = origLP }()

_, err := Classify(context.Background(), "maybe run something")
if !errors.Is(err, ErrClassificationFailed) {
Expand Down
6 changes: 4 additions & 2 deletions internal/intent/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ func TestBuildPrompt_ContainsSubcommands(t *testing.T) {
dc := DynamicContext{}
prompt := BuildPrompt("anything", dc)

subcommands := []string{"forge run", "forge push", "forge resume", "forge runs",
subcommands := []string{
"forge run", "forge push", "forge resume", "forge runs",
"forge status", "forge logs", "forge steps", "forge edit",
"forge cleanup", "forge init", "forge completion", "forge serve", "forge version"}
"forge cleanup", "forge init", "forge completion", "forge serve", "forge version",
}

for _, sub := range subcommands {
if !strings.Contains(prompt, sub) {
Expand Down
6 changes: 3 additions & 3 deletions internal/pipeline/.forge/runs/20260217-120000-test.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
id: 20260217-120000-test
plan_path: /var/folders/tx/bm28bkzx7vj48wg0z_ls65gw0000gn/T/TestRun_LocalCR_SeparateReviewAgent3223185930/001/auth.md
plan_path: /var/folders/tx/bm28bkzx7vj48wg0z_ls65gw0000gn/T/TestRun_LocalCR_SeparateReviewAgent1795440872/001/auth.md
status: completed
created_at: 2026-02-28T17:24:37.746616+02:00
updated_at: 2026-02-28T17:24:37.749882+02:00
created_at: 2026-02-28T20:04:38.362657+02:00
updated_at: 2026-02-28T20:04:38.366259+02:00
branch: forge/auth
worktree_path: /tmp/wt
pr_url: https://github.com/owner/repo/pull/1
Expand Down
4 changes: 2 additions & 2 deletions internal/pipeline/.forge/runs/20260219-120000-push-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ id: 20260219-120000-push-test
plan_path: ""
mode: push
status: completed
created_at: 2026-02-28T17:24:37.49641+02:00
updated_at: 2026-02-28T17:24:37.500963+02:00
created_at: 2026-02-28T20:04:38.105526+02:00
updated_at: 2026-02-28T20:04:38.110421+02:00
branch: forge/my-feature
worktree_path: /tmp/repo
pr_url: url
Expand Down
11 changes: 6 additions & 5 deletions internal/pipeline/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import (
// sorts them by dependency, and executes each in order. Sequential within each
// level for V1.
func RunBatch(ctx context.Context, cfg *config.Config, providers Providers,
label string, dryRun bool, logger *slog.Logger) error {

label string, dryRun bool, logger *slog.Logger,
) error {
issues, err := providers.VCS.ListIssues(ctx, "open", label)
if err != nil {
return fmt.Errorf("listing issues: %w", err)
Expand Down Expand Up @@ -154,8 +154,8 @@ func reportFailure(ctx context.Context, providers Providers, num int, err error,

// runSingleIssue executes a single GitHub issue through the forge pipeline.
func runSingleIssue(ctx context.Context, cfg *config.Config, providers Providers,
number int, title, body string, logger *slog.Logger) error {

number int, title, body string, logger *slog.Logger,
) error {
slug := SlugFromTitle(title)
runID := time.Now().Format("20060102-150405") + "-" + slug

Expand Down Expand Up @@ -184,7 +184,8 @@ func runSingleIssue(ctx context.Context, cfg *config.Config, providers Providers
// Handles transitive deps. Fetch errors are logged and treated as external deps (skipped).
func expandDeps(ctx context.Context, vcs interface {
GetIssue(ctx context.Context, number int) (*provider.GitHubIssue, error)
}, issueSet map[int]bool, titleMap map[int]string, bodyMap map[int]string, logger *slog.Logger) error {
}, issueSet map[int]bool, titleMap map[int]string, bodyMap map[int]string, logger *slog.Logger,
) error {
external := make(map[int]bool) // deps we tried to fetch and failed — treat as external
for {
var missing []int
Expand Down
3 changes: 1 addition & 2 deletions internal/pipeline/pool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ package pipeline
import (
"context"
"errors"
"log/slog"
"os"
"testing"

"log/slog"

"github.com/shahar-caura/forge/internal/provider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down
12 changes: 8 additions & 4 deletions internal/pipeline/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,8 +518,10 @@ func agentResultText(output string) string {
return parsed.Result
}

var crReviewMarker = "---CRREVIEW---"
var crSummaryMarker = "---CRSUMMARY---"
var (
crReviewMarker = "---CRREVIEW---"
crSummaryMarker = "---CRSUMMARY---"
)

// extractCRSummary extracts the text between ---CRSUMMARY--- markers from agent output.
// Returns empty string if markers are missing or content is empty.
Expand Down Expand Up @@ -724,8 +726,10 @@ func openAgentLog(runID string, step int, a provider.Agent, logger *slog.Logger)
}
}

var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9-]+`)
var validBranch = regexp.MustCompile(`^[A-Z]+-[0-9]+(-[a-z0-9]+)+$`)
var (
nonAlphanumeric = regexp.MustCompile(`[^a-z0-9-]+`)
validBranch = regexp.MustCompile(`^[A-Z]+-[0-9]+(-[a-z0-9]+)+$`)
)

// SlugFromTitle converts a title string to a kebab-case slug.
func SlugFromTitle(title string) string {
Expand Down
3 changes: 2 additions & 1 deletion internal/provider/agent/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func (c *Claude) Run(ctx context.Context, dir, prompt string) (string, error) {

c.Logger.Info("running agent", "dir", dir, "timeout", c.Timeout)

args := []string{"-p", prompt,
args := []string{
"-p", prompt,
"--allowedTools", "Edit,Read,Write,Bash",
"--output-format", "json",
}
Expand Down
3 changes: 2 additions & 1 deletion internal/provider/agent/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ func (c *Codex) Run(ctx context.Context, dir, prompt string) (string, error) {

c.Logger.Info("running codex agent", "dir", dir, "timeout", c.Timeout)

args := []string{"exec",
args := []string{
"exec",
"--full-auto",
"--json",
"--cd", dir,
Expand Down
3 changes: 2 additions & 1 deletion internal/provider/vcs/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ func (g *GitHub) GetIssue(ctx context.Context, number int) (*provider.GitHubIssu
func (g *GitHub) ListIssues(ctx context.Context, state string, label string) ([]provider.GitHubIssue, error) {
g.Logger.Info("listing issues", "state", state, "label", label)

args := []string{"issue", "list",
args := []string{
"issue", "list",
"--repo", g.Repo,
"--state", state,
"--json", "number,title,body,url",
Expand Down
Loading