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
9 changes: 8 additions & 1 deletion internal/api/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 22 additions & 2 deletions internal/application/tickets/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,9 @@ func (o *Orchestrator) DiscoverTickets(ctx context.Context) ([]DiscoveredTicket,
func (o *Orchestrator) ensureWorktreeAndContext(ctx context.Context, state *workflowstate.State) error {
if state.WorktreePath == "" {
branchName := "auto-pr/" + state.TicketNumber
baseBranch := effectiveBaseBranch(state.BaseBranch, o.Cfg.BaseBranch)
slog.Info("creating worktree", "ticket", state.TicketNumber, "branch", branchName)
wtPath, err := gitutil.EnsureWorktree(ctx, o.RepoRoot, o.Cfg.StateDirName, state.TicketNumber, branchName, o.Cfg.BaseBranch)
wtPath, err := gitutil.EnsureWorktree(ctx, o.RepoRoot, o.Cfg.StateDirName, state.TicketNumber, branchName, baseBranch)
if err != nil {
return fmt.Errorf("create worktree: %w", err)
}
Expand All @@ -350,7 +351,14 @@ func (o *Orchestrator) ensureWorktreeAndContext(ctx context.Context, state *work
_, statErr := os.Stat(contextPath)
if os.IsNotExist(statErr) {
guidelinesPath := config.ResolveGuidelinesPath(o.RepoRoot, o.Cfg)
content := fmt.Sprintf("Ticket: %s\nWorktree: %s\nRepo: %s\nGuidelines: %s\n", state.TicketNumber, state.WorktreePath, o.RepoRoot, guidelinesPath)
content := fmt.Sprintf(
"Ticket: %s\nWorktree: %s\nRepo: %s\nBase Branch: %s\nGuidelines: %s\n",
state.TicketNumber,
state.WorktreePath,
o.RepoRoot,
effectiveBaseBranch(state.BaseBranch, o.Cfg.BaseBranch),
guidelinesPath,
)
err = os.WriteFile(contextPath, []byte(content), 0o644)
if err != nil {
return fmt.Errorf("write context file: %w", err)
Expand Down Expand Up @@ -726,6 +734,10 @@ func (o *Orchestrator) prepareRunContext(
rawProviderLog := filepath.ToSlash(filepath.Join(".auto-pr", "runs", run.ID, "raw-provider.log"))
fmt.Fprintf(&buf, "Current Raw Provider Log: %s\n", rawProviderLog)
fmt.Fprintf(&buf, "Shared Context File: %s\n", filepath.ToSlash(filepath.Join(".auto-pr", "context.md")))
baseBranch := effectiveBaseBranch(state.BaseBranch, o.Cfg.BaseBranch)
if baseBranch != "" {
fmt.Fprintf(&buf, "Base Branch: %s\n", baseBranch)
}
guidelinesPath := config.ResolveGuidelinesPath(o.RepoRoot, o.Cfg)
if guidelinesPath != "" {
fmt.Fprintf(&buf, "Guidelines File: %s\n", guidelinesPath)
Expand Down Expand Up @@ -758,6 +770,14 @@ func (o *Orchestrator) prepareRunContext(
return nil
}

func effectiveBaseBranch(ticketBaseBranch, configBaseBranch string) string {
if branch := strings.TrimSpace(ticketBaseBranch); branch != "" {
return branch
}

return strings.TrimSpace(configBaseBranch)
}

// extractArtifactField reads field from the metadata section of a markdown artifact.
// It stops scanning at the first ## heading so body content cannot produce false matches.
func extractArtifactField(path, field string) string {
Expand Down
114 changes: 114 additions & 0 deletions internal/application/tickets/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -133,6 +134,41 @@ func newOrchestrator(repoRoot string, store *memStore, prov *mockProvider) *tick
)
}

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
// Test helper runs trusted local git commands against temp repositories.
//nolint:gosec
cmd := exec.CommandContext(context.Background(), "git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(out))
}
}

func setupGitRepoWithBaseBranch(t *testing.T) string {
t.Helper()
root := setupRepo(t, minimalWorkflow, "Investigate this ticket.")
runGit(t, root, "init", "-b", "main")
runGit(t, root, "config", "user.name", "Test User")
runGit(t, root, "config", "user.email", "test@example.com")
writeErr := os.WriteFile(filepath.Join(root, "base.txt"), []byte("main\n"), 0o644)
if writeErr != nil {
t.Fatal(writeErr)
}
runGit(t, root, "add", ".")
runGit(t, root, "commit", "-m", "initial main")
runGit(t, root, "checkout", "-b", "release/1.2")
writeErr = os.WriteFile(filepath.Join(root, "base.txt"), []byte("release\n"), 0o644)
if writeErr != nil {
t.Fatal(writeErr)
}
runGit(t, root, "commit", "-am", "release base")
runGit(t, root, "checkout", "main")

return root
}

// ── StartFlow ─────────────────────────────────────────────────────────────

func TestStartFlow_newTicket_endsWaiting(t *testing.T) {
Expand Down Expand Up @@ -213,6 +249,84 @@ func TestStartFlow_providerError_setsFailedStatus(t *testing.T) {
}
}

func TestStartFlow_createsWorktreeFromTicketBaseBranch(t *testing.T) {
t.Parallel()
root := setupGitRepoWithBaseBranch(t)
store := newMemStore()
prov := &mockProvider{result: providers.ExecuteResult{RawOutput: "analysis done"}}
orch := tickets.NewWithStore(
config.Config{StateDirName: ".auto-pr-state", BaseBranch: "main"},
root,
store,
prov,
)

st := workflowstate.New("42")
st.BaseBranch = "release/1.2"
err := store.SaveState("42", st)
if err != nil {
t.Fatal(err)
}

err = orch.StartFlow(context.Background(), "42")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result, _ := store.LoadState("42")
data, err := os.ReadFile(filepath.Join(result.WorktreePath, "base.txt"))
if err != nil {
t.Fatalf("read base.txt from worktree: %v", err)
}
if strings.TrimSpace(string(data)) != "release" {
t.Fatalf("expected worktree to use release base branch, got %q", string(data))
}
}

func TestStartFlow_writesBaseBranchIntoContextFiles(t *testing.T) {
t.Parallel()
root := setupRepo(t, minimalWorkflow, "Investigate this ticket.")
store := newMemStore()
prov := &mockProvider{result: providers.ExecuteResult{RawOutput: "analysis done"}}
worktreeDir := t.TempDir()
err := os.MkdirAll(filepath.Join(worktreeDir, ".auto-pr"), 0o755)
if err != nil {
t.Fatal(err)
}
st := workflowstate.New("77")
st.WorktreePath = worktreeDir
st.BranchName = "auto-pr/77"
st.BaseBranch = "release/1.2"
err = store.SaveState("77", st)
if err != nil {
t.Fatal(err)
}
orch := tickets.NewWithStore(
config.Config{StateDirName: ".auto-pr-state", BaseBranch: "main"},
root,
store,
prov,
)

err = orch.StartFlow(context.Background(), "77")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
contextData, err := os.ReadFile(filepath.Join(worktreeDir, ".auto-pr", "context.md"))
if err != nil {
t.Fatalf("read context.md: %v", err)
}
if !strings.Contains(string(contextData), "Base Branch: release/1.2") {
t.Fatalf("expected context.md to contain base branch, got:\n%s", string(contextData))
}
runContextData, err := os.ReadFile(filepath.Join(worktreeDir, ".auto-pr", "run-context.md"))
if err != nil {
t.Fatalf("read run-context.md: %v", err)
}
if !strings.Contains(string(runContextData), "Base Branch: release/1.2") {
t.Fatalf("expected run-context.md to contain base branch, got:\n%s", string(runContextData))
}
}

func TestDiscoverTickets_persistsLogsUnderUserHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
Expand Down
1 change: 1 addition & 0 deletions internal/domain/workflowstate/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type State struct {
CurrentRunID string `json:"current_run_id,omitempty"`
FlowStatus FlowStatus `json:"flow_status"`
BranchName string `json:"branch_name"`
BaseBranch string `json:"base_branch,omitempty"`
WorktreePath string `json:"worktree_path"`
LastError string `json:"last_error,omitempty"`
PRURL string `json:"pr_url,omitempty"`
Expand Down
8 changes: 8 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,11 @@ func resolveArtifactRef(ticketState workflowstate.State, name string) (string, b

return ticketState.ResolveRef(filepath.ToSlash(clean)), true
}

func effectiveBaseBranch(ticketBaseBranch, configBaseBranch string) string {
if branch := strings.TrimSpace(ticketBaseBranch); branch != "" {
return branch
}

return strings.TrimSpace(configBaseBranch)
}
18 changes: 16 additions & 2 deletions internal/server/strict_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func (s *server) GetTicket(ctx context.Context, request api.GetTicketRequestObje
return api.GetTicket404JSONResponse{Error: errMsgTicketNotFound}, nil
}
nextSteps, _ := repoRt.svc.NextSteps(request.Id)
githubBlobBase, _ := gitutil.GitHubBlobBase(ctx, repoRoot, s.cfg.BaseBranch)
githubBlobBase, _ := gitutil.GitHubBlobBase(ctx, repoRoot, effectiveBaseBranch(ticketState.BaseBranch, s.cfg.BaseBranch))

var availableActions []actionInfo
var workflowStates []workflowStateInfo
Expand Down Expand Up @@ -377,10 +377,24 @@ func (s *server) RunTicket(ctx context.Context, request api.RunTicketRequestObje
if strings.TrimSpace(req.RepoPath) == "" {
return badRunTicket("repo_path is required"), nil
}
repoRoot, repoID, _, err := s.runtimeForRepoPath(ctx, req.RepoPath)
repoRoot, repoID, repoRt, err := s.runtimeForRepoPath(ctx, req.RepoPath)
if err != nil {
return badRunTicket(err.Error()), nil
}
baseBranch := strings.TrimSpace(derefString(req.BaseBranch))
if baseBranch != "" {
ticketState, loadErr := repoRt.store.LoadState(request.Id)
if errors.Is(loadErr, os.ErrNotExist) {
ticketState = workflowstate.New(request.Id)
} else if loadErr != nil {
return api.RunTicket500JSONResponse{Error: fmt.Errorf("load ticket state: %w", loadErr).Error()}, nil
}
ticketState.BaseBranch = baseBranch
saveErr := repoRt.store.SaveState(request.Id, ticketState)
if saveErr != nil {
return api.RunTicket500JSONResponse{Error: fmt.Errorf("save ticket state: %w", saveErr).Error()}, nil
}
}

return acceptedRunTicket(s.enqueueJob(jobRun, repoID, repoRoot, request.Id, enqueueOptions{}))
}
Expand Down
1 change: 1 addition & 0 deletions internal/server/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func toTicketStateResponse(state workflowstate.State) api.TicketStateResponse {
CurrentRunId: stringPtr(state.CurrentRunID),
FlowStatus: api.FlowStatus(state.FlowStatus),
BranchName: state.BranchName,
BaseBranch: stringPtr(state.BaseBranch),
WorktreePath: state.WorktreePath,
LastError: stringPtr(state.LastError),
PrUrl: stringPtr(state.PRURL),
Expand Down
1 change: 1 addition & 0 deletions internal/workflow/prompts/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Implement all changes described in the proposal. Then:
3. If a command fails, fix the code and re-run until it passes or clearly report blockers.

Create a pull request for the changes using `gh pr create`.
If `.auto-pr/run-context.md` or `.auto-pr/context.md` includes a `Base Branch:` value, pass it with `gh pr create --base <branch>`.

Write a summary to the `Current Primary Artifact` path listed in `.auto-pr/run-context.md` with the following sections:
- Changes Made
Expand Down
9 changes: 8 additions & 1 deletion openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RepoRequest"
$ref: "#/components/schemas/RunTicketRequest"
responses:
"202":
description: Job accepted
Expand Down Expand Up @@ -323,6 +323,12 @@ components:
required: [repo_path]
properties:
repo_path: { type: string }
RunTicketRequest:
type: object
required: [repo_path]
properties:
repo_path: { type: string }
base_branch: { type: string }
CleanupScopeRequest:
type: object
required: [repo_path, scope]
Expand Down Expand Up @@ -405,6 +411,7 @@ components:
current_run_id: { type: string }
flow_status: { $ref: "#/components/schemas/FlowStatus" }
branch_name: { type: string }
base_branch: { type: string }
worktree_path: { type: string }
last_error: { type: string }
pr_url: { type: string }
Expand Down
14 changes: 14 additions & 0 deletions web/src/AddTicketDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ type AddTicketDialogProps = {
knownRepoPaths: string[];
repoPath: string;
ticketNumber: string;
baseBranch: string;
error: string;
onRepoPathChange: (value: string) => void;
onTicketNumberChange: (value: string) => void;
onBaseBranchChange: (value: string) => void;
onSubmit: () => void;
onClose: () => void;
};
Expand All @@ -13,9 +15,11 @@ export function AddTicketDialog({
knownRepoPaths,
repoPath,
ticketNumber,
baseBranch,
error,
onRepoPathChange,
onTicketNumberChange,
onBaseBranchChange,
onSubmit,
onClose
}: AddTicketDialogProps) {
Expand Down Expand Up @@ -58,6 +62,16 @@ export function AddTicketDialog({
}}
/>

<label className="field-label" htmlFor="base-branch-input">
Base Branch
</label>
<input
id="base-branch-input"
value={baseBranch}
onChange={(event) => onBaseBranchChange(event.target.value)}
placeholder="Optional, e.g. release/1.2"
/>

<div className="button-row modal-actions">
<button type="button" className="secondary" onClick={onClose}>
Cancel
Expand Down
Loading
Loading