diff --git a/Taskfile.yml b/Taskfile.yml index d1ddb16..0ddaf80 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,13 +2,9 @@ version: "3" tasks: build: - desc: Build the Bauer binary + desc: Build the Bauer and Bauer API binaries cmds: - go build -o bauer cmd/bauer/main.go - - build-api: - desc: Build the API server binary - cmds: - go build -o bauer-api cmd/app/main.go test: @@ -21,22 +17,10 @@ tasks: cmds: - gofmt -w . - run: - desc: Run the bau tool locally with specified document ID - vars: - CREDS: "bau-test-creds.json" - DOC_ID: "1WJ-N_Xkkx4r_6knxW7h200oIDyi4mVMzgh1xYt5xaU0" - OUTPUT_DIR: "bauer-output" - MODEL: "gpt-5-mini-high" - cmds: - - go run cmd/bauer/main.go --doc-id "{{.DOC_ID}}" --credentials "{{.CREDS}}" --output-dir "{{.OUTPUT_DIR}}" --model "{{.MODEL}}" - # requires: - # vars: [DOC_ID] - - test-repo: - desc: Check that test repo is created on /tmp/test-bauer-repo and has correct default branch + run-server: + desc: Run the API server locally cmds: - - cd /tmp/test-bauer-repo/ && git branch --show-current + - ./bauer-api --config config.json clean: desc: Clean up generated files @@ -45,3 +29,10 @@ tasks: - rm -f bauer - rm -f bauer.log - rm -rf /tmp/gh* /tmp/tmp* /tmp/test-bauer-repo* || true + + run: + desc: Clean up generated files, build and run Bauer server + cmds: + - task: clean + - task: build + - task: run-server \ No newline at end of file diff --git a/cmd/app/main.go b/cmd/app/main.go index 3f9c0f8..83745ae 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -5,11 +5,11 @@ import ( "bauer/cmd/app/types" v1 "bauer/cmd/app/v1" "bauer/internal/orchestrator" + "bauer/internal/workflow" "fmt" "log/slog" "net/http" "os" - "path/filepath" ) func run() error { @@ -27,28 +27,6 @@ func run() error { return err } - if cfg.TargetRepo != "" { - // Convert credentials path to absolute before changing directory - absCredsPath, err := filepath.Abs(cfg.CredentialsPath) - if err != nil { - return fmt.Errorf("failed to resolve credentials path: %w", err) - } - cfg.CredentialsPath = absCredsPath - - // Convert output directory to absolute before changing directory - absOutputDir, err := filepath.Abs(cfg.BaseOutputDir) - if err != nil { - return fmt.Errorf("failed to resolve output directory path: %w", err) - } - cfg.BaseOutputDir = absOutputDir - - if err := os.Chdir(cfg.TargetRepo); err != nil { - return fmt.Errorf("failed to change to target repository %q: %w", cfg.TargetRepo, err) - } - cwd, _ := os.Getwd() - slog.Info("Working directory", "path", cwd) - } - rc := types.RouteConfig{ APIConfig: *cfg, Orchestrator: orchestrator, @@ -57,6 +35,7 @@ func run() error { mux := http.NewServeMux() mux.HandleFunc("/api/v1/job", v1.JobPost(rc)) mux.HandleFunc("/api/v1/health", v1.GetHealth) + mux.HandleFunc("/api/v1/workflow", workflow.ExecuteWorkflowHandler(orchestrator)) slog.Info("starting server", "address", ":8090") err = http.ListenAndServe(":8090", middleware.RequestTrace(mux)) diff --git a/cmd/bauer/main.go b/cmd/bauer/main.go index e32e21e..761724b 100644 --- a/cmd/bauer/main.go +++ b/cmd/bauer/main.go @@ -1,234 +1,72 @@ package main import ( - "bauer/internal/config" "bauer/internal/github" "bauer/internal/orchestrator" + "bauer/internal/workflow" "context" + "flag" "fmt" - "log" - "log/slog" "os" - "path/filepath" "strings" - "time" ) -func runGithub() { - // 1. Parse a GitHub repo (works with all 3 formats) - // Using default repo for now - defaultRepo := "canonical/canonical.com" - repo, err := github.ParseGitHubRepo(defaultRepo) - if err != nil { - log.Fatal(err) - } - fmt.Printf("✅ Parsed: %s/%s\n", repo.Owner, repo.Name) - - // 2. Check if gh CLI is installed - if !github.IsGhCLIInstalled() { - log.Fatal("❌ gh CLI not installed.") - } - fmt.Println("✅ gh CLI detected") - - // 3. Validate GitHub authentication - if err := github.ValidateGitHubAuth(); err != nil { - log.Fatal("❌ Not authenticated. Run: gh auth login") - } - fmt.Println("✅ GitHub authenticated") - - // 4. Clone/update repo (creates temp directory) - localPath := "/tmp/test-bauer-repo" - if err := github.CloneOrUpdateRepo(repo, localPath); err != nil { - log.Fatal(err) - } - fmt.Printf("✅ Repo ready at: %s\n", localPath) - - // 5. Create feature branch - // Using default branch name for now - branchName := "bauer/test-phase1" - if err := github.CreateFeatureBranch(localPath, branchName); err != nil { - log.Fatal(err) +func main() { + // Parse CLI flags + githubRepo := flag.String("github-repo", "", "GitHub repository (owner/repo or HTTPS URL)") + docID := flag.String("doc-id", "", "Google Doc ID") + credentialsPath := flag.String("credentials", "bau-test-creds.json", "Path to service account credentials JSON") + localRepoPath := flag.String("local-repo-path", "/tmp/ubuntu.com", "Local path for cloned repository") + dryRun := flag.Bool("dry-run", false, "Perform a dry run without creating PR") + outputDir := flag.String("output-dir", "bauer-output", "Output directory for Bauer results") + branchPrefix := flag.String("branch-prefix", "bauer", "Branch naming prefix") + + flag.Parse() + + // Validate required flags + if *githubRepo == "" { + fmt.Fprintf(os.Stderr, "ERROR: --github-repo is required\n") + os.Exit(1) } - fmt.Printf("✅ Branch created: %s\n", branchName) - - // 6. Check current branch - current, err := github.GetCurrentBranch(localPath) - if err != nil { - log.Fatal(err) + if *docID == "" { + fmt.Fprintf(os.Stderr, "ERROR: --doc-id is required\n") + os.Exit(1) } - fmt.Printf("✅ Currently on: %s\n", current) -} - -func runBauer() error { fmt.Println(strings.Repeat("=", 80)) fmt.Println("Bauer - A tool to automate BAU tasks") fmt.Println(strings.Repeat("=", 80)) fmt.Println() - // 1. Load Configuration - cfg, err := config.Load() + // Create workflow input from CLI flags/config + ghToken, err := github.GetGitHubToken() if err != nil { - return fmt.Errorf("error loading configuration\n%w", err) - } - - // 1b. Change to target repository if specified - if cfg.TargetRepo != "" { - // Convert credentials path to absolute before changing directory - absCredsPath, err := filepath.Abs(cfg.CredentialsPath) - if err != nil { - return fmt.Errorf("failed to resolve credentials path: %w", err) - } - cfg.CredentialsPath = absCredsPath - - // Convert output directory to absolute before changing directory - absOutputDir, err := filepath.Abs(cfg.OutputDir) - if err != nil { - return fmt.Errorf("failed to resolve output directory path: %w", err) - } - cfg.OutputDir = absOutputDir - - if err := os.Chdir(cfg.TargetRepo); err != nil { - return fmt.Errorf("failed to change to target repository %q: %w", cfg.TargetRepo, err) - } - cwd, _ := os.Getwd() - fmt.Printf("Working directory: %s\n\n", cwd) + fmt.Fprintf(os.Stderr, "WARNING: Could not get GitHub token: %v\n", err) + ghToken = "" } - // 2. Setup Logging - // For now, mirroring POC behavior: logging to log.json in the current directory - // TODO disable with a flag or env var - logFile, err := os.OpenFile("bauer-log.json", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return fmt.Errorf("failed to open log file: %w", err) + workflowInput := workflow.WorkflowInput{ + GitHubRepo: *githubRepo, + GitHubToken: ghToken, + BranchPrefix: *branchPrefix, + DocID: *docID, + Credentials: *credentialsPath, + LocalRepoPath: *localRepoPath, + DryRun: *dryRun, + OutputDir: *outputDir, } - defer func() { - if err := logFile.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Failed to close log file: %v\n", err) - } - }() - - logger := slog.New(slog.NewJSONHandler(logFile, &slog.HandlerOptions{ - Level: slog.LevelDebug, - })) - slog.SetDefault(logger) - slog.Info("Starting BAU CLI", - slog.String("doc_id", cfg.DocID), - slog.Bool("dry_run", cfg.DryRun), - ) - - ctx := context.Background() - - // 3. Create and execute orchestrator orch := orchestrator.NewOrchestrator() - result, err := orch.Execute(ctx, cfg) - if err != nil { - slog.Error("Orchestration failed", slog.String("error", err.Error())) - // Check if the error is credentials-related and provide more context - errMsg := err.Error() - if strings.Contains(errMsg, "credentials") || strings.Contains(errMsg, "private_key") || strings.Contains(errMsg, "client_email") { - fmt.Fprintf(os.Stderr, "\n⚠️ CREDENTIALS ERROR:\n") - fmt.Fprintf(os.Stderr, " %v\n\n", err) - fmt.Fprintf(os.Stderr, "Please verify:\n") - fmt.Fprintf(os.Stderr, " 1. Credentials file exists at: %s\n", cfg.CredentialsPath) - fmt.Fprintf(os.Stderr, " 2. Credentials file is valid JSON\n") - fmt.Fprintf(os.Stderr, " 3. Credentials file contains required fields:\n") - fmt.Fprintf(os.Stderr, " - type\n") - fmt.Fprintf(os.Stderr, " - project_id\n") - fmt.Fprintf(os.Stderr, " - private_key\n") - fmt.Fprintf(os.Stderr, " - client_email\n") - fmt.Fprintf(os.Stderr, " - token_uri\n\n") - } - return err - } - - // 4. Print results - outputFile := "bauer-doc-suggestions.json" - fmt.Println("[1/4] Extracting from Google Doc...") - fmt.Printf(" ✓ Extraction completed in %s\n", result.ExtractionDuration.Round(time.Millisecond)) - fmt.Println() - - fmt.Println("[2/4] Generating technical plan...") - fmt.Printf(" ✓ Saved: %s\n", outputFile) - fmt.Printf(" ✓ Generated %d chunk file(s) in '%s/'\n", len(result.Chunks), cfg.OutputDir) - fmt.Printf(" ✓ Planning completed in %s\n", result.PlanDuration.Round(time.Millisecond)) - for _, chunk := range result.Chunks { - slog.Info("Generated chunk", - slog.Int("chunk_number", chunk.ChunkNumber), - slog.String("filename", chunk.Filename), - slog.Int("location_count", chunk.LocationCount), - ) - } - fmt.Println() - - if result.DryRun { - fmt.Println("[3/4] Copilot execution (skipped - dry run)") - fmt.Println() - fmt.Println(strings.Repeat("=", 80)) - fmt.Println("DRY RUN COMPLETE") - fmt.Println(strings.Repeat("=", 80)) - fmt.Printf(" Summary:\n") - fmt.Printf(" • Extracted: %d suggestions\n", len(result.ExtractionResult.ActionableSuggestions)) - fmt.Printf(" • Grouped into: %d location(s)\n", len(result.ExtractionResult.GroupedSuggestions)) - fmt.Printf(" • Generated: %d chunk file(s) in '%s/'\n", len(result.Chunks), cfg.OutputDir) - fmt.Printf("\n Timing:\n") - fmt.Printf(" • Extraction: %s\n", result.ExtractionDuration.Round(time.Millisecond)) - fmt.Printf(" • Planning: %s\n", result.PlanDuration.Round(time.Millisecond)) - fmt.Printf(" • Total: %s\n", result.TotalDuration.Round(time.Millisecond)) - fmt.Printf("\n Next steps:\n") - fmt.Printf(" 1. Review generated chunks in '%s/'\n", cfg.OutputDir) - fmt.Printf(" 2. Run without --dry-run to execute changes via Copilot\n") - fmt.Printf(" 3. Or manually pass chunk files to: gh copilot\n") - fmt.Println(strings.Repeat("=", 80)) - return nil - } - - // 5. Copilot execution results - fmt.Println("[3/4] Executing changes via Copilot...") - fmt.Println(strings.Repeat("=", 80)) - fmt.Printf(" ✓ All %d chunk(s) processed successfully\n", len(result.CopilotOutputs)) - fmt.Printf(" ✓ Total Copilot execution time: %s\n", result.CopilotDuration.Round(time.Millisecond)) - fmt.Println() - - // 6. Summary results if multiple chunks - if len(result.Chunks) > 1 { - fmt.Println("[4/5] Generating summary...") - fmt.Printf(" ✓ Summary completed in %s\n", result.SummaryDuration.Round(time.Millisecond)) - fmt.Println() - } - // 7. Final summary - stepLabel := "[4/4]" - if len(result.Chunks) > 1 { - stepLabel = "[5/5]" - } - - fmt.Printf("%s Complete!\n", stepLabel) - fmt.Println(strings.Repeat("=", 80)) - fmt.Println("SUCCESS: Feedback applied!") - fmt.Println(strings.Repeat("=", 80)) - fmt.Printf(" Summary:\n") - fmt.Printf(" • Extracted: %d suggestions\n", len(result.ExtractionResult.ActionableSuggestions)) - fmt.Printf(" • Processed: %d chunk(s)\n", len(result.Chunks)) - fmt.Printf("\n Timing:\n") - fmt.Printf(" • Extraction: %s\n", result.ExtractionDuration.Round(time.Millisecond)) - fmt.Printf(" • Planning: %s\n", result.PlanDuration.Round(time.Millisecond)) - fmt.Printf(" • Copilot execution: %s\n", result.CopilotDuration.Round(time.Millisecond)) - fmt.Printf(" • Total: %s\n", result.TotalDuration.Round(time.Millisecond)) - fmt.Printf("\n Next steps:\n") - fmt.Printf(" • Review the changes made by Copilot\n") - fmt.Printf(" • Create a PR with: gh pr create\n") - fmt.Println(strings.Repeat("=", 80)) - - return nil -} - -func main() { - runGithub() - - if err := runBauer(); err != nil { + // Execute the complete workflow + result, err := workflow.ExecuteWorkflow(context.Background(), workflowInput, orch) + if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) os.Exit(1) } + + // Print results + fmt.Printf("Status: %s\n", result.Status) + fmt.Printf("Branch: %s\n", result.RepositoryInfo.BranchName) + fmt.Printf("PR: %s\n", result.FinalizationInfo.PullRequest.URL) } diff --git a/config.json b/config.json new file mode 100644 index 0000000..62081ca --- /dev/null +++ b/config.json @@ -0,0 +1,11 @@ +{ + "doc_id": "1WJ-N_Xkkx4r_6knxW7h200oIDyi4mVMzgh1xYt5xaU0", + "credentials": "bau-test-creds.json", + "chunk_size": 1, + "page_refresh": false, + "output_dir": "bauer-output", + "model": "gpt-5-mini-high", + "summary_model": "gpt-5-mini-high", + "target_repo": "../canonical.com", + "dry_run": false +} \ No newline at end of file diff --git a/internal/github/pr.go b/internal/github/pr.go index 09ed62e..e5813a8 100644 --- a/internal/github/pr.go +++ b/internal/github/pr.go @@ -2,6 +2,8 @@ package github import ( "fmt" + "log/slog" + "os" "os/exec" "strings" ) @@ -63,16 +65,39 @@ func CreatePR(owner, repo string, opts CreatePROptions) (string, error) { } cmd := exec.Command("gh", args...) + + // Log token availability for debugging + logger := slog.Default() + ghToken := os.Getenv("GH_TOKEN") + if ghToken == "" { + ghToken = os.Getenv("GITHUB_TOKEN") + } + if ghToken == "" { + logger.Warn("No GH_TOKEN or GITHUB_TOKEN environment variable set for PR creation") + } else { + logger.Debug("GH_TOKEN is set for PR creation", "token_prefix", ghToken[:10]) + } + output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to create PR: %w, output: %s", err, output) } // Extract PR URL from output - // Output format: "https://github.com/owner/repo/pull/123" - prURL := strings.TrimSpace(string(output)) - if !strings.HasPrefix(prURL, "https://github.com/") { - return "", fmt.Errorf("unexpected PR creation output: %s", prURL) + // Output may contain warnings, so look for the URL pattern + outputStr := string(output) + lines := strings.Split(outputStr, "\n") + var prURL string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "https://github.com/") { + prURL = trimmed + break + } + } + + if prURL == "" { + return "", fmt.Errorf("could not extract PR URL from output: %s", outputStr) } return prURL, nil diff --git a/internal/github/repo.go b/internal/github/repo.go index b5c4059..660cb75 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -168,6 +168,18 @@ func CommitChanges(localPath, message string) error { return fmt.Errorf("failed to stage changes: %w, output: %s", err, output) } + // Exclude specific files from commit + excludeFiles := []string{ + "bauer-doc-suggestions.json", + "bauer-output/", + } + for _, file := range excludeFiles { + cmd := exec.Command("git", "reset", "HEAD", file) + cmd.Dir = localPath + // Ignore error if file doesn't exist + cmd.CombinedOutput() + } + // Check if there are changes to commit status, err := GetStatus(localPath) if err != nil { diff --git a/internal/github/setup.go b/internal/github/setup.go new file mode 100644 index 0000000..8b2e1f1 --- /dev/null +++ b/internal/github/setup.go @@ -0,0 +1,186 @@ +package github + +import ( + "fmt" + "log/slog" + "time" +) + +// GitHubSetupInput represents input for GitHub setup phase +type GitHubSetupInput struct { + GitHubRepo string + GitHubToken string + BranchPrefix string + LocalRepoPath string +} + +// GitHubSetupOutput represents the result of GitHub setup phase +type GitHubSetupOutput struct { + Repo *Repository + LocalPath string + BranchName string + DefaultBranch string + CurrentBranch string +} + +// SetupGitHubPhase performs Phase 1: GitHub Setup +// This function is reusable by both CLI (runGithub) and API (ExecuteWorkflow) +func SetupGitHubPhase(input GitHubSetupInput) (*GitHubSetupOutput, error) { + logger := slog.Default() + + // Validate GH CLI installation + if !IsGhCLIInstalled() { + return nil, fmt.Errorf("gh CLI not installed. Please install it from https://cli.github.com") + } + logger.Info("github setup: gh CLI detected") + + // Setup GitHub authentication with provided token + if err := SetupGitHubAuth(input.GitHubToken); err != nil { + return nil, fmt.Errorf("failed to setup GitHub auth: %w", err) + } + logger.Info("github setup: authentication configured") + + // Parse repository + repo, err := ParseGitHubRepo(input.GitHubRepo) + if err != nil { + return nil, fmt.Errorf("failed to parse GitHub repo: %w", err) + } + logger.Info("github setup: parsed repo", "owner", repo.Owner, "repo", repo.Name) + + // Clone/update repository + if err := CloneOrUpdateRepo(repo, input.LocalRepoPath); err != nil { + return nil, fmt.Errorf("failed to clone/update repo: %w", err) + } + logger.Info("github setup: repository ready", "local_path", input.LocalRepoPath) + + // Get default branch + defaultBranch, err := GetDefaultBranch(input.LocalRepoPath) + if err != nil { + return nil, fmt.Errorf("failed to get default branch: %w", err) + } + logger.Info("github setup: default branch detected", "branch", defaultBranch) + + // Create feature branch + branchName := fmt.Sprintf("%s/doc-suggestions-%d", input.BranchPrefix, time.Now().Unix()) + if err := CreateFeatureBranch(input.LocalRepoPath, branchName); err != nil { + return nil, fmt.Errorf("failed to create feature branch: %w", err) + } + logger.Info("github setup: feature branch created", "branch", branchName) + + // Get current branch + currentBranch, err := GetCurrentBranch(input.LocalRepoPath) + if err != nil { + return nil, fmt.Errorf("failed to get current branch: %w", err) + } + + output := &GitHubSetupOutput{ + Repo: repo, + LocalPath: input.LocalRepoPath, + BranchName: branchName, + DefaultBranch: defaultBranch, + CurrentBranch: currentBranch, + } + + logger.Info("github setup: phase complete", + "owner", repo.Owner, + "repo", repo.Name, + "branch", branchName, + "local_path", input.LocalRepoPath, + ) + + return output, nil +} + +// GitHubFinalizationInput represents input for GitHub finalization phase +type GitHubFinalizationInput struct { + LocalRepoPath string + BranchName string + DefaultBranch string + Owner string + Repo string + CommitMessage string + DryRun bool + PRTitle string + PRBody string + Labels []string +} + +// GitHubFinalizationOutput represents the result of GitHub finalization phase +type GitHubFinalizationOutput struct { + CommitMessage string + BranchPushed bool + PullRequest struct { + URL string + Number int + Title string + } + Errors []string + Warnings []string +} + +// FinalizeGitHubPhase performs Phase 3: GitHub Finalization +// This function is reusable by both CLI and API +func FinalizeGitHubPhase(input GitHubFinalizationInput) (*GitHubFinalizationOutput, error) { + logger := slog.Default() + output := &GitHubFinalizationOutput{ + Errors: []string{}, + Warnings: []string{}, + } + + // 3.1 Check for changes + status, err := GetStatus(input.LocalRepoPath) + if err != nil { + output.Warnings = append(output.Warnings, fmt.Sprintf("failed to check git status: %v", err)) + logger.Warn("github finalize: failed to check status", "error", err) + } + + // 3.2 Commit changes (if there are any) + if status != "" { + if err := CommitChanges(input.LocalRepoPath, input.CommitMessage); err != nil { + output.Errors = append(output.Errors, fmt.Sprintf("failed to commit changes: %v", err)) + logger.Warn("github finalize: failed to commit", "error", err) + } else { + output.CommitMessage = input.CommitMessage + logger.Info("github finalize: changes committed", "message", input.CommitMessage) + } + } else { + logger.Info("github finalize: no changes to commit") + } + + // 3.3 Push branch + if err := PushBranch(input.LocalRepoPath, input.BranchName); err != nil { + output.Errors = append(output.Errors, fmt.Sprintf("failed to push branch: %v", err)) + logger.Warn("github finalize: failed to push", "error", err) + return output, nil + } + output.BranchPushed = true + logger.Info("github finalize: branch pushed", "branch", input.BranchName) + + // 3.4 Create PR (only if not dry run) + if !input.DryRun && output.BranchPushed { + prOpts := CreatePROptions{ + Title: input.PRTitle, + Body: input.PRBody, + HeadBranch: input.BranchName, + BaseBranch: input.DefaultBranch, + Labels: input.Labels, + } + + prURL, err := CreatePR(input.Owner, input.Repo, prOpts) + if err != nil { + output.Errors = append(output.Errors, fmt.Sprintf("failed to create PR: %v", err)) + logger.Warn("github finalize: failed to create PR", "error", err) + } else { + output.PullRequest.URL = prURL + output.PullRequest.Title = prOpts.Title + logger.Info("github finalize: PR created", "url", prURL) + } + } + + logger.Info("github finalize: phase complete", + "branch_pushed", output.BranchPushed, + "errors", len(output.Errors), + ) + + return output, nil +} diff --git a/internal/workflow/api.go b/internal/workflow/api.go new file mode 100644 index 0000000..891eb6b --- /dev/null +++ b/internal/workflow/api.go @@ -0,0 +1,190 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "bauer/internal/orchestrator" +) + +// APIRequest represents the API request for executing a workflow +type APIRequest struct { + // GitHub configuration + GitHubRepo string `json:"github_repo" binding:"required"` // "owner/repo" or HTTPS URL + GitHubToken string `json:"github_token" binding:"required"` // Personal access token + BranchPrefix string `json:"branch_prefix" default:"bauer"` // Branch naming prefix + + // Bauer configuration + DocID string `json:"doc_id" binding:"required"` // Google Doc ID + Credentials string `json:"credentials" binding:"required"` // Path to service account JSON + ChunkSize int `json:"chunk_size" default:"1"` // Number of chunks + PageRefresh bool `json:"page_refresh" default:"false"` // Page refresh mode + OutputDir string `json:"output_dir" default:"bauer-output"` // Output directory + Model string `json:"model" default:"gpt-5-mini-high"` // Copilot model + DryRun bool `json:"dry_run" default:"false"` // Dry run mode + + // Local repository path + LocalRepoPath string `json:"local_repo_path" default:"/tmp"` // Where to clone (optional) +} + +// APIResponse represents the API response from workflow execution +type APIResponse struct { + Status string `json:"status"` // "success", "partial", "failed" + Message string `json:"message"` + Workflow *WorkflowOutput `json:"workflow"` + Error string `json:"error,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// ExecuteWorkflowHandler is an HTTP handler for executing the complete workflow +func ExecuteWorkflowHandler(orch orchestrator.Orchestrator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logger := slog.Default() + + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Parse request + var req APIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Error("failed to parse request", "error", err) + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid request: %v", err)) + return + } + + // Validate request + if req.GitHubRepo == "" { + writeError(w, http.StatusBadRequest, "github_repo is required") + return + } + if req.GitHubToken == "" { + writeError(w, http.StatusBadRequest, "github_token is required") + return + } + if req.DocID == "" { + writeError(w, http.StatusBadRequest, "doc_id is required") + return + } + if req.Credentials == "" { + writeError(w, http.StatusBadRequest, "credentials is required") + return + } + + // Set defaults + if req.BranchPrefix == "" { + req.BranchPrefix = "bauer" + } + if req.LocalRepoPath == "" { + req.LocalRepoPath = "/tmp" + } + if req.OutputDir == "" { + req.OutputDir = "bauer-output" + } + if req.Model == "" { + req.Model = "gpt-5-mini-high" + } + if req.ChunkSize == 0 { + req.ChunkSize = 1 + } + + // Create workflow input + input := WorkflowInput{ + GitHubRepo: req.GitHubRepo, + GitHubToken: req.GitHubToken, + BranchPrefix: req.BranchPrefix, + DocID: req.DocID, + Credentials: req.Credentials, + ChunkSize: req.ChunkSize, + PageRefresh: req.PageRefresh, + OutputDir: req.OutputDir, + Model: req.Model, + DryRun: req.DryRun, + LocalRepoPath: fmt.Sprintf("%s/%s-%d", req.LocalRepoPath, "bauer-workflow", time.Now().Unix()), + } + + logger.Info("workflow API request", + "github_repo", req.GitHubRepo, + "doc_id", req.DocID, + "dry_run", req.DryRun, + ) + + // Execute workflow + ctx := r.Context() + workflowOutput, err := ExecuteWorkflow(ctx, input, orch) + + // Build response + response := APIResponse{ + Timestamp: time.Now(), + } + + if workflowOutput != nil { + response.Status = workflowOutput.Status + response.Workflow = workflowOutput + + switch workflowOutput.Status { + case "success": + response.Message = fmt.Sprintf( + "Workflow completed successfully. PR: %s", + workflowOutput.FinalizationInfo.PullRequest.URL, + ) + case "partial": + response.Message = fmt.Sprintf( + "Workflow completed with errors. Branch: %s. Errors: %d", + workflowOutput.RepositoryInfo.BranchName, + len(workflowOutput.Errors), + ) + default: + response.Message = "Workflow failed" + if len(workflowOutput.Errors) > 0 { + response.Error = workflowOutput.Errors[0] + } + } + } + + if err != nil { + response.Status = "failed" + response.Message = "Workflow execution error" + response.Error = err.Error() + logger.Error("workflow execution error", "error", err) + } + + // Determine HTTP status code + statusCode := http.StatusOK + switch response.Status { + case "failed": + statusCode = http.StatusInternalServerError + case "partial": + statusCode = http.StatusAccepted + case "success": + statusCode = http.StatusCreated + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(response) + + logger.Info("workflow API response", + "status", response.Status, + "http_status", statusCode, + "duration", workflowOutput.TotalDuration, + ) + } +} + +// Helper functions + +func writeError(w http.ResponseWriter, statusCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "error", + "error": message, + "timestamp": time.Now(), + }) +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go new file mode 100644 index 0000000..c0a283e --- /dev/null +++ b/internal/workflow/workflow.go @@ -0,0 +1,261 @@ +package workflow + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "bauer/internal/config" + "bauer/internal/github" + "bauer/internal/orchestrator" +) + +// WorkflowInput represents the input for a complete workflow execution +type WorkflowInput struct { + // GitHub configuration + GitHubRepo string + GitHubToken string + BranchPrefix string + + // Bauer configuration + DocID string + Credentials string + ChunkSize int + PageRefresh bool + OutputDir string + Model string + DryRun bool + + // Local repository path + LocalRepoPath string +} + +// WorkflowOutput represents the complete workflow execution result +type WorkflowOutput struct { + // GitHub Setup + RepositoryInfo struct { + Owner string + Repo string + LocalPath string + BranchName string + DefaultBranch string + CurrentBranch string + } `json:"repository_info"` + + // Bauer Processing + BauerResult struct { + ExtractionDuration time.Duration `json:"extraction_duration"` + PlanDuration time.Duration `json:"plan_duration"` + CopilotDuration time.Duration `json:"copilot_duration"` + ChunkCount int `json:"chunk_count"` + TotalSuggestions int `json:"total_suggestions"` + } `json:"bauer_result"` + + // GitHub Finalization + FinalizationInfo struct { + CommitMessage string + BranchPushed bool + PullRequest struct { + URL string + Number int + Title string + } + } `json:"finalization_info"` + + // Overall + Status string `json:"status"` // "success", "partial", "failed" + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + TotalDuration time.Duration `json:"total_duration"` + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` +} + +// ExecuteWorkflow orchestrates the complete flow: +// 1. GitHub Setup (clone, create branch) +// 2. Bauer Processing (extract, chunk, apply changes) +// 3. GitHub Finalization (commit, push, create PR) +func ExecuteWorkflow(ctx context.Context, input WorkflowInput, orch orchestrator.Orchestrator) (*WorkflowOutput, error) { + output := &WorkflowOutput{ + Status: "pending", + StartTime: time.Now(), + Errors: []string{}, + Warnings: []string{}, + } + + logger := slog.Default() + + // GitHub setup + logger.Info("workflow: Setting up GitHub") + + githubSetupInput := github.GitHubSetupInput{ + GitHubRepo: input.GitHubRepo, + GitHubToken: input.GitHubToken, + BranchPrefix: input.BranchPrefix, + LocalRepoPath: input.LocalRepoPath, + } + + githubSetupOutput, err := github.SetupGitHubPhase(githubSetupInput) + if err != nil { + output.Status = "failed" + output.Errors = append(output.Errors, err.Error()) + output.EndTime = time.Now() + output.TotalDuration = output.EndTime.Sub(output.StartTime) + return output, err + } + // Store GH setup results + output.RepositoryInfo.Owner = githubSetupOutput.Repo.Owner + output.RepositoryInfo.Repo = githubSetupOutput.Repo.Name + output.RepositoryInfo.LocalPath = githubSetupOutput.LocalPath + output.RepositoryInfo.BranchName = githubSetupOutput.BranchName + output.RepositoryInfo.DefaultBranch = githubSetupOutput.DefaultBranch + output.RepositoryInfo.CurrentBranch = githubSetupOutput.CurrentBranch + + logger.Info("workflow success: GitHub setup successful") + + // Convert credentials path to absolute + // Do this before changing directory so relative paths work + var credentialsPath string + if input.Credentials != "" { + absPath, err := filepath.Abs(input.Credentials) + if err != nil { + output.Status = "failed" + output.Errors = append(output.Errors, fmt.Sprintf("failed to resolve credentials path: %v", err)) + output.EndTime = time.Now() + output.TotalDuration = output.EndTime.Sub(output.StartTime) + return output, err + } + credentialsPath = absPath + logger.Info("workflow: resolved credentials path", "path", credentialsPath) + } + + // Change to target repository directory + // Save original directory to restore later + originalDir, err := os.Getwd() + if err != nil { + output.Status = "failed" + output.Errors = append(output.Errors, fmt.Sprintf("failed to get current directory: %v", err)) + output.EndTime = time.Now() + output.TotalDuration = output.EndTime.Sub(output.StartTime) + return output, err + } + + if err := os.Chdir(input.LocalRepoPath); err != nil { + output.Status = "failed" + output.Errors = append(output.Errors, fmt.Sprintf("failed to change to cloned repository: %v", err)) + output.EndTime = time.Now() + output.TotalDuration = output.EndTime.Sub(output.StartTime) + return output, err + } + logger.Info("workflow: changed to cloned repository", "path", input.LocalRepoPath) + defer os.Chdir(originalDir) + + // Bauer processing + logger.Info("workflow: starting phase 2 - Bauer processing") + + bauerStartTime := time.Now() + + // Create Bauer config with target repo (now current directory) + bauerCfg := &config.Config{ + DocID: input.DocID, + CredentialsPath: credentialsPath, // Use absolute path + DryRun: input.DryRun, + ChunkSize: input.ChunkSize, + PageRefresh: input.PageRefresh, + OutputDir: input.OutputDir, + Model: input.Model, + TargetRepo: ".", // Current directory is the cloned repo + } + + logger.Info("workflow: Bauer target repository set at", "path", bauerCfg.TargetRepo) + + // Execute Bauer orchestration + bauerResult, err := orch.Execute(ctx, bauerCfg) + if err != nil { + output.Status = "partial" + output.Errors = append(output.Errors, fmt.Sprintf("Bauer processing error: %v", err)) + logger.Warn("workflow: Bauer processing returned error", "error", err) + // Continue anyway - we can still commit what we have + } + + // Store Bauer results + if bauerResult != nil { + output.BauerResult.ExtractionDuration = bauerResult.ExtractionDuration + output.BauerResult.PlanDuration = bauerResult.PlanDuration + output.BauerResult.CopilotDuration = bauerResult.CopilotDuration + if len(bauerResult.Chunks) > 0 { + output.BauerResult.ChunkCount = len(bauerResult.Chunks) + } + if bauerResult.ExtractionResult != nil { + // Count total suggestions from extraction result + output.BauerResult.TotalSuggestions = 0 // TODO: adjust based on actual field + } + } + + logger.Info("Bauer results", + "extraction_duration", output.BauerResult.ExtractionDuration, + "plan_duration", output.BauerResult.PlanDuration, + "copilot_duration", output.BauerResult.CopilotDuration, + "chunk_count", output.BauerResult.ChunkCount, + "total_suggestions", output.BauerResult.TotalSuggestions, + ) + output.BauerResult.CopilotDuration = time.Since(bauerStartTime) + logger.Info("workflow success: Bauer processing finished") + + // GitHub finalization + logger.Info("workflow: GitHub finalization") + + commitMessage := fmt.Sprintf("Apply BAU suggestions from doc %s", input.DocID) + prTitle := fmt.Sprintf("Apply BAU suggestions to %s", githubSetupOutput.Repo.Name) + prBody := fmt.Sprintf("Automated copy update changes from Bauer\n\nGDoc ID: %s", input.DocID) + + finalizationInput := github.GitHubFinalizationInput{ + LocalRepoPath: input.LocalRepoPath, + BranchName: githubSetupOutput.BranchName, + DefaultBranch: githubSetupOutput.DefaultBranch, + Owner: githubSetupOutput.Repo.Owner, + Repo: githubSetupOutput.Repo.Name, + CommitMessage: commitMessage, + DryRun: input.DryRun, + PRTitle: prTitle, + PRBody: prBody, + Labels: []string{}, + } + + finalizationOutput, _ := github.FinalizeGitHubPhase(finalizationInput) + + // Store GH PR results + output.FinalizationInfo.CommitMessage = finalizationOutput.CommitMessage + output.FinalizationInfo.BranchPushed = finalizationOutput.BranchPushed + output.FinalizationInfo.PullRequest.URL = finalizationOutput.PullRequest.URL + output.FinalizationInfo.PullRequest.Title = finalizationOutput.PullRequest.Title + + // Merge warnings and errors from finalization + output.Warnings = append(output.Warnings, finalizationOutput.Warnings...) + output.Errors = append(output.Errors, finalizationOutput.Errors...) + + logger.Info("workflow: phase 3 complete - GitHub finalization finished") + + output.EndTime = time.Now() + output.TotalDuration = output.EndTime.Sub(output.StartTime) + + if len(output.Errors) == 0 { + output.Status = "success" + } else if output.FinalizationInfo.BranchPushed { + output.Status = "partial" + } else { + output.Status = "failed" + } + + logger.Info("workflow: complete", + "status", output.Status, + "duration", output.TotalDuration, + "errors", len(output.Errors), + "warnings", len(output.Warnings), + ) + + return output, nil +}