diff --git a/internal/api/generated.go b/internal/api/generated.go index 00e7f5a..5921bd9 100644 --- a/internal/api/generated.go +++ b/internal/api/generated.go @@ -207,6 +207,12 @@ type RepositoryListResponse struct { Repositories []string `json:"repositories"` } +// RunTicketRequest defines model for RunTicketRequest. +type RunTicketRequest struct { + BaseBranch *string `json:"base_branch,omitempty"` + RepoPath string `json:"repo_path"` +} + // ServerEvent defines model for ServerEvent. type ServerEvent struct { Action *string `json:"action,omitempty"` @@ -278,6 +284,7 @@ type TicketPayload struct { // TicketStateResponse defines model for TicketStateResponse. type TicketStateResponse struct { + BaseBranch *string `json:"base_branch,omitempty"` BranchName string `json:"branch_name"` CreatedAt time.Time `json:"created_at"` CurrentRunId *string `json:"current_run_id,omitempty"` @@ -362,7 +369,7 @@ type CleanupTicketJSONRequestBody = RepoRequest type MoveTicketToStateJSONRequestBody = MoveToStateRequest // RunTicketJSONRequestBody defines body for RunTicket for application/json ContentType. -type RunTicketJSONRequestBody = RepoRequest +type RunTicketJSONRequestBody = RunTicketRequest // Getter for additional properties for TicketPayload. Returns the specified // element and whether it was found diff --git a/internal/application/tickets/orchestrator.go b/internal/application/tickets/orchestrator.go index 0e5df2f..f9a4dd6 100644 --- a/internal/application/tickets/orchestrator.go +++ b/internal/application/tickets/orchestrator.go @@ -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) } @@ -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) @@ -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) @@ -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 { diff --git a/internal/application/tickets/orchestrator_test.go b/internal/application/tickets/orchestrator_test.go index 646832f..0891c71 100644 --- a/internal/application/tickets/orchestrator_test.go +++ b/internal/application/tickets/orchestrator_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -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) { @@ -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) diff --git a/internal/domain/workflowstate/types.go b/internal/domain/workflowstate/types.go index cc6bac9..47b4f28 100644 --- a/internal/domain/workflowstate/types.go +++ b/internal/domain/workflowstate/types.go @@ -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"` diff --git a/internal/server/server.go b/internal/server/server.go index 3e107bf..c7229a3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) +} diff --git a/internal/server/strict_api.go b/internal/server/strict_api.go index c86dc9e..c9ef4c3 100644 --- a/internal/server/strict_api.go +++ b/internal/server/strict_api.go @@ -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 @@ -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{})) } diff --git a/internal/server/transport.go b/internal/server/transport.go index f0fd470..d310466 100644 --- a/internal/server/transport.go +++ b/internal/server/transport.go @@ -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), diff --git a/internal/workflow/prompts/implement.md b/internal/workflow/prompts/implement.md index 8696039..6ebfe6f 100644 --- a/internal/workflow/prompts/implement.md +++ b/internal/workflow/prompts/implement.md @@ -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 `. Write a summary to the `Current Primary Artifact` path listed in `.auto-pr/run-context.md` with the following sections: - Changes Made diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 00cfbc2..1dc7220 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -147,7 +147,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RepoRequest" + $ref: "#/components/schemas/RunTicketRequest" responses: "202": description: Job accepted @@ -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] @@ -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 } diff --git a/web/src/AddTicketDialog.tsx b/web/src/AddTicketDialog.tsx index f1c28c5..0a595f9 100644 --- a/web/src/AddTicketDialog.tsx +++ b/web/src/AddTicketDialog.tsx @@ -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; }; @@ -13,9 +15,11 @@ export function AddTicketDialog({ knownRepoPaths, repoPath, ticketNumber, + baseBranch, error, onRepoPathChange, onTicketNumberChange, + onBaseBranchChange, onSubmit, onClose }: AddTicketDialogProps) { @@ -58,6 +62,16 @@ export function AddTicketDialog({ }} /> + + onBaseBranchChange(event.target.value)} + placeholder="Optional, e.g. release/1.2" + /> +