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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ bauer-output/
.DS_Store
.vscode/
*.exe
bauer
/bauer
# Added by goreleaser init:
dist/
config.json
Expand Down
65 changes: 39 additions & 26 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,60 @@ version: "3"

tasks:
build:
desc: Build the Bauer and Bauer API binaries
desc: Build the Bauer CLI binary
cmds:
- go build -o bauer cmd/bauer/main.go
- go build -o bauer-api cmd/app/main.go
- go build -o bauer ./cmd/bauer/

test:
desc: Run all tests
build-api:
desc: Build the Bauer API server binary
cmds:
- go test --cover ./...
- go build -o bauer-api ./cmd/app/

lint:
desc: Format code using gofmt
run:
desc: Run Bauer CLI in standalone mode.
summary: |
Requires BAUER_DOC_ID and BAUER_CREDENTIALS_PATH (or --credentials flag).
Example: task run -- --doc-id 1abc --credentials ./creds.json
cmds:
- go run ./cmd/bauer/ {{.CLI_ARGS}}

run-api:
desc: Build and start the API server (reads config from .env / .env.local)
cmds:
- gofmt -w .
- task: build-api
- ./bauer-api

run-server:
desc: Run the API server locally
test:
desc: Run all unit tests
cmds:
- ./bauer-api --credentials ${BAUER_CREDENTIALS_PATH}
- go test ./...

lint:
desc: Run linter (requires golangci-lint)
cmds:
- golangci-lint run ./...

verify-figma:
desc: Verify Figma token is set and reachable
desc: Verify Figma token and REST API access. Pass FILE_KEY=<your-figma-file-key>.
cmds:
- |
if [ -z "$BAUER_FIGMA_TOKEN" ] && [ -z "$FIGMA_TOKEN" ]; then
echo "ERROR: BAUER_FIGMA_TOKEN (or FIGMA_TOKEN) must be set" >&2
exit 1
TOKEN="${BAUER_FIGMA_TOKEN:-$FIGMA_TOKEN}"
if [ -z "$TOKEN" ]; then
echo "ERROR: set BAUER_FIGMA_TOKEN or FIGMA_TOKEN"; exit 1
fi
- |
if [ -z "{{.FILE_KEY}}" ]; then
echo "ERROR: provide FILE_KEY=your-figma-file-key"; exit 1
fi
echo "Figma token is set."
- |
curl -sf -H "Authorization: Bearer ${TOKEN}" \
"https://api.figma.com/v1/files/{{.FILE_KEY}}/meta" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('name','?'), d.get('lastModified','?'))"
Comment on lines +45 to +56

clean:
desc: Clean up generated files
cmds:
- rm -rf bauer-output
- rm -f bauer
- rm -f bauer bauer-api
- 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
- rm -rf /tmp/gh* /tmp/tmp* /tmp/test-bauer-repo* || true
237 changes: 131 additions & 106 deletions cmd/bauer/main.go
Original file line number Diff line number Diff line change
@@ -1,152 +1,177 @@
package main

import (
"bauer/internal/agent"
"bauer/internal/artifacts"
"bauer/internal/config"
"bauer/internal/copilotcli"
"bauer/internal/github"
"bauer/internal/orchestrator"
"bauer/internal/source"
"bauer/internal/workflow"
"context"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)

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)
}
if *docID == "" {
fmt.Fprintf(os.Stderr, "ERROR: --doc-id is required\n")
os.Exit(1)
}

fmt.Println(strings.Repeat("=", 80))
fmt.Println("Bauer - A tool to automate BAU tasks")
fmt.Println(strings.Repeat("=", 80))
fmt.Println()

// Create workflow input from CLI flags/config
ghToken, err := github.GetGitHubToken()
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: Could not get GitHub token: %v\n", err)
ghToken = ""
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)

docID := fs.String("doc-id", "", "Google Doc ID to extract feedback from (required, or set BAUER_DOC_ID)")
credentialsPath := fs.String("credentials", "", "Path to service account credentials JSON\n\t(falls back to BAUER_CREDENTIALS_PATH → GOOGLE_APPLICATION_CREDENTIALS → credentials.json)")
chunkSize := fs.Int("chunk-size", 0, "Total number of chunks (default: 1, or 5 if --page-refresh)")
pageRefresh := fs.Bool("page-refresh", false, "Use page-refresh-instructions template (default chunk-size: 5)")
model := fs.String("model", "", "Copilot model for sessions (default: gpt-5-mini-high)")
summaryModel := fs.String("summary-model", "", "Copilot model for summary session (default: gpt-5-mini-high)")
Comment on lines +20 to +24
dryRun := fs.Bool("dry-run", false, "In standalone mode: skip Copilot, write chunk files only.\n\tIn --open-pr mode: apply changes locally, skip PR creation.")
artifactsDir := fs.String("artifacts-dir", "", "Directory for run artifacts (default: ./bauer-artifacts)")
openPR := fs.Bool("open-pr", false, "Apply changes and open a pull request (mutually exclusive with --open-issue)")
openIssue := fs.Bool("open-issue", false, "Generate a plan and open a GitHub issue without applying changes (mutually exclusive with --open-pr)")
branchPrefix := fs.String("branch-prefix", "", "Prefix for created branches (default: bauer)")

fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage:\n\n")
fmt.Fprintf(os.Stderr, "\t%s --doc-id <doc-id> [--credentials <path>] [flags]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Flags:\n\n")
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nEnvironment variables:\n\n")
fmt.Fprintf(os.Stderr, "\tBAUER_DOC_ID Override for --doc-id\n")
fmt.Fprintf(os.Stderr, "\tBAUER_CREDENTIALS_PATH Override for --credentials\n")
fmt.Fprintf(os.Stderr, "\tGOOGLE_APPLICATION_CREDENTIALS Fallback credentials path\n")
fmt.Fprintf(os.Stderr, "\tBAUER_MODEL Override for --model\n")
fmt.Fprintf(os.Stderr, "\tBAUER_SUMMARY_MODEL Override for --summary-model\n")
fmt.Fprintf(os.Stderr, "\tBAUER_DRY_RUN Override for --dry-run (true/false)\n")
fmt.Fprintf(os.Stderr, "\tBAUER_ARTIFACTS_DIR Override for --artifacts-dir\n")
fmt.Fprintf(os.Stderr, "\tBAUER_BRANCH_PREFIX Override for --branch-prefix\n")
fmt.Fprintf(os.Stderr, "\tBAUER_CHUNK_SIZE Override for --chunk-size\n")
fmt.Fprintf(os.Stderr, "\tBAUER_PAGE_REFRESH Override for --page-refresh (true/false)\n")
fmt.Fprintf(os.Stderr, "\n")
Comment on lines +36 to +47
}

workflowInput := workflow.WorkflowInput{
GitHubRepo: *githubRepo,
GitHubToken: ghToken,
BranchPrefix: *branchPrefix,
DocID: *docID,
Credentials: *credentialsPath,
LocalRepoPath: *localRepoPath,
DryRun: *dryRun,
OutputDir: *outputDir,
if err := fs.Parse(os.Args[1:]); err != nil {
if err == flag.ErrHelp {
os.Exit(0)
}
os.Exit(2)
}

// Resolve credentials path to absolute so it remains valid after directory changes.
absCredentials, err := filepath.Abs(*credentialsPath)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to resolve credentials path: %v\n", err)
// Mutual exclusion check — before any network calls
if err := checkMutualExclusion(*openPR, *openIssue); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

cwd, err := os.Getwd()
// Build CLIFlags — *bool fields are only set for explicitly-provided flags
// so they don't override env vars when the user didn't pass the flag.
flags := config.CLIFlags{
DocID: *docID,
CredentialsPath: *credentialsPath,
ChunkSize: *chunkSize,
Model: *model,
SummaryModel: *summaryModel,
ArtifactsDir: *artifactsDir,
BranchPrefix: *branchPrefix,
}
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "dry-run":
flags.DryRun = config.BoolPtr(*dryRun)
case "page-refresh":
flags.PageRefresh = config.BoolPtr(*pageRefresh)
case "open-pr":
flags.OpenPR = config.BoolPtr(*openPR)
case "open-issue":
flags.OpenIssue = config.BoolPtr(*openIssue)
}
})

cfg, err := resolveCLIConfig(flags)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to get working directory: %v\n", err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

copilotAgent, err := copilotcli.NewClient(cwd)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to create Copilot client: %v\n", err)
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

sources := source.NewManager(absCredentials)
arts := artifacts.NewManager("")
orch := orchestrator.New(copilotAgent, sources, arts)
ctx := context.Background()

// Execute the complete workflow
result, err := workflow.ExecuteWorkflow(context.Background(), workflowInput, orch)
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
fmt.Fprintln(os.Stderr, "ERROR: failed to get working directory:", 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)
}

// resolveCLIConfig builds a Config from CLI flags, falling back to environment variables
// when flag values are empty. It does NOT call Validate — callers must do that separately.
func resolveCLIConfig(flags config.CLIFlags) (*config.Config, error) {
docID := flags.DocID
if docID == "" {
docID = os.Getenv("BAUER_DOC_ID")
// In standalone dry-run mode, skip Copilot client initialization
// so the CLI works on machines without the Copilot CLI installed.
var copilotAgent agent.Agent
if !(*dryRun && !*openPR && !*openIssue) {
var err2 error
copilotAgent, err2 = copilotcli.NewClient(cwd)
if err2 != nil {
fmt.Fprintln(os.Stderr, "ERROR: failed to create Copilot client:", err2)
os.Exit(1)
}
}

credentialsPath := flags.CredentialsPath
if credentialsPath == "" {
credentialsPath = os.Getenv("BAUER_CREDENTIALS")
}
sources := source.NewManager(cfg.CredentialsPath)
arts := artifacts.NewManager(cfg.ArtifactsDir)
orch := orchestrator.New(copilotAgent, sources, arts)
Comment on lines +98 to 120

outputDir := flags.OutputDir
if outputDir == "" {
outputDir = os.Getenv("BAUER_OUTPUT_DIR")
switch {
case *openIssue:
if err := runOpenIssue(ctx, cfg, orch); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case *openPR:
if err := runOpenPR(ctx, cfg, orch); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
if _, err := orch.Execute(ctx, cfg); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
}

model := flags.Model
if model == "" {
model = os.Getenv("BAUER_MODEL")
// checkMutualExclusion returns an error if --open-pr and --open-issue are both set.
// Extracted for testability — main() calls os.Exit on error.
func checkMutualExclusion(openPR, openIssue bool) error {
if openPR && openIssue {
return fmt.Errorf("Error: --open-pr and --open-issue are mutually exclusive.\n Use --open-pr to apply changes and open a PR.\n Use --open-issue to generate a plan and open an issue without applying changes.")
}
return nil
}

summaryModel := flags.SummaryModel
if summaryModel == "" {
summaryModel = os.Getenv("BAUER_SUMMARY_MODEL")
}
// resolveCLIConfig builds a Config from CLI flags, falling back to environment variables
// and then hardcoded defaults. FlagsSource has highest priority.
func resolveCLIConfig(flags config.CLIFlags) (*config.Config, error) {
return config.NewResolver(
config.NewFlagsSource(flags),
config.NewEnvVarSource(),
config.NewDefaultsSource(),
).Resolve()
}

targetRepo := flags.TargetRepo
if targetRepo == "" {
targetRepo = os.Getenv("BAUER_TARGET_REPO")
}
// openPRExecutionConfig returns a copy of cfg with DryRun disabled.
// In --open-pr mode, Copilot runs to apply changes locally; only PR creation
// is skipped when the original cfg has DryRun=true.
func openPRExecutionConfig(original *config.Config) *config.Config {
copy := *original
copy.DryRun = config.BoolPtr(false)
return &copy
Comment on lines +164 to +166
}

return &config.Config{
DocID: docID,
CredentialsPath: credentialsPath,
DryRun: flags.DryRun,
ChunkSize: flags.ChunkSize,
OutputDir: outputDir,
Model: model,
SummaryModel: summaryModel,
TargetRepo: targetRepo,
}, nil
// runOpenIssue is a stub — to be fully implemented in Phase 2.
func runOpenIssue(_ context.Context, _ *config.Config, _ orchestrator.Orchestrator) error {
return fmt.Errorf("--open-issue not yet implemented")
}

// openPRExecutionConfig returns a copy of cfg with DryRun forced to false,
// so that the Copilot agent runs even when the overall --dry-run flag was set.
func openPRExecutionConfig(cfg *config.Config) *config.Config {
copy := *cfg
f := false
copy.DryRun = &f
return &copy
// runOpenPR is a stub — to be fully implemented in Phase 2.
func runOpenPR(_ context.Context, _ *config.Config, _ orchestrator.Orchestrator) error {
return fmt.Errorf("--open-pr not yet implemented")
}
Loading