From bd4da40e2f59e139bfffdc1235645ac3e5a69ecd Mon Sep 17 00:00:00 2001 From: Abbie Date: Fri, 13 Feb 2026 09:52:55 +0400 Subject: [PATCH 1/4] feat: migrate auth to env and secure API access --- .env.example | 15 +++ .gitignore | 4 + README.md | 29 +++--- Taskfile.yml | 3 +- cmd/app/core/middleware/basic_auth.go | 33 ++++++ cmd/app/main.go | 10 +- cmd/app/types/config.go | 56 ++++++---- cmd/app/v1/api.go | 16 ++- cmd/bauer/main.go | 25 ++--- credentials.json | 13 --- docs/ARCHITECTURE.md | 2 +- go.mod | 1 + go.sum | 2 + internal/config/cli.go | 27 ++--- internal/config/config.go | 29 ++---- internal/config/config_test.go | 145 +++++++++++--------------- internal/config/env.go | 25 +++++ internal/gdocs/credentials.go | 112 +++++++++++++------- internal/gdocs/service.go | 17 +-- internal/orchestrator/orchestrator.go | 3 +- scripts/migrate_credentials_to_env.py | 76 ++++++++++++++ 21 files changed, 390 insertions(+), 253 deletions(-) create mode 100644 .env.example create mode 100644 cmd/app/core/middleware/basic_auth.go delete mode 100644 credentials.json create mode 100644 internal/config/env.go create mode 100755 scripts/migrate_credentials_to_env.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..105875b --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Google service account credentials +GOOGLE_TYPE=service_account +GOOGLE_PROJECT_ID=your-project-id +GOOGLE_PRIVATE_KEY_ID=your-private-key-id +GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nREPLACE_ME\n-----END PRIVATE KEY-----\n" +GOOGLE_CLIENT_EMAIL=service-account@your-project-id.iam.gserviceaccount.com +GOOGLE_CLIENT_ID=your-client-id +GOOGLE_AUTH_URI=https://accounts.google.com/o/oauth2/auth +GOOGLE_TOKEN_URI=https://oauth2.googleapis.com/token +GOOGLE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs +GOOGLE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/service-account%40your-project-id.iam.gserviceaccount.com +GOOGLE_UNIVERSE_DOMAIN=googleapis.com + +# API basic auth shared secret (used as password) +API_SECRET=replace-with-a-long-random-secret diff --git a/.gitignore b/.gitignore index 7f9f318..a8102d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,14 @@ output.json bauer-doc-suggestions.json bauer-log.json *-creds.json +credentials.json *.log /.github/copilot-instructions.md .env.local-e +.env .env.local +.env.* +!.env.example # BAU output bauer-output/ .DS_Store diff --git a/README.md b/README.md index 7a46090..1f93dcc 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,16 @@ N.B. You need to install [Copilot CLI](https://docs.github.com/en/copilot/how-to ## Configuration 1. Install [Copilot CLI](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) -2. Create `credentials.json` file and copy the structure from the [default file](https://github.com/muhammadbassiony/Bauer/blob/main/credentials.json) -3. Get credentials from Google Cloud service or Bitwarden (internally) -4. Fill up `credentials.json` with Google Cloud credentials (see [Generating Google Cloud credentials](https://developers.google.com/workspace/guides/create-credentials)). -5. Share copy document with service account +2. Copy `.env.example` to `.env` +3. Fill `.env` with Google Cloud credentials (see [Generating Google Cloud credentials](https://developers.google.com/workspace/guides/create-credentials)) +4. Set `API_SECRET` in `.env` for API basic auth +5. Share copy document with the service account from `GOOGLE_CLIENT_EMAIL` + +If you already have `credentials.json`, migrate it with: + +```bash +python3 scripts/migrate_credentials_to_env.py --input credentials.json --output .env +``` ## Usage @@ -43,7 +49,7 @@ N.B. You need to install [Copilot CLI](https://docs.github.com/en/copilot/how-to 4. Run Bauer ```bash -bauer --doc-id --credentials ./credentials.json +bauer --doc-id ``` 6. Optional parameters @@ -61,14 +67,13 @@ bauer --doc-id --credentials ./credentials.json #### Basic run ```bash -bauer --doc-id --credentials ./credentials.json +bauer --doc-id ``` #### Dry run (test without executing changes) ```bash bauer --doc-id \ - --credentials ./credentials.json \ --dry-run ``` @@ -76,7 +81,6 @@ bauer --doc-id \ ```bash bauer --doc-id \ - --credentials ./credentials.json \ --chunk-size 5 \ --output-dir ./results ``` @@ -85,14 +89,12 @@ bauer --doc-id \ ```bash bauer --doc-id \ - --credentials ./credentials.json \ --model "claude-sonnet-4.5" ``` #### Run on a different repository ```bash bauer --doc-id \ - --credentials ./credentials.json \ --target-repo ../my-other-repo ``` @@ -100,7 +102,6 @@ bauer --doc-id \ ```bash bauer --doc-id \ - --credentials ./credentials.json \ --page-refresh ``` @@ -117,6 +118,9 @@ task build-api ./bauer-api --config config.json ``` +The API requires HTTP basic auth for all endpoints except `/api/v1/health`. +Use username `bauer` and password from `API_SECRET`. + ### Endpoints #### POST /api/v1/job @@ -147,6 +151,7 @@ Example: ```bash curl -X POST http://localhost:8090/api/v1/job \ + -u "bauer:${API_SECRET}" \ -H 'Content-Type: application/json' \ -d '{"doc_id":"","chunk_size":2,"page_refresh":false}' ``` @@ -171,7 +176,7 @@ curl http://localhost:8090/api/v1/health ## Steps -1. Modify the [Taskfile](./Taskfile.yml) with your document ID and credentials path for convenience +1. Modify the [Taskfile](./Taskfile.yml) with your document ID for convenience 2. Run the project with task ``` diff --git a/Taskfile.yml b/Taskfile.yml index d1ddb16..08ba3ed 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -24,12 +24,11 @@ tasks: 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}}" + - go run cmd/bauer/main.go --doc-id "{{.DOC_ID}}" --output-dir "{{.OUTPUT_DIR}}" --model "{{.MODEL}}" # requires: # vars: [DOC_ID] diff --git a/cmd/app/core/middleware/basic_auth.go b/cmd/app/core/middleware/basic_auth.go new file mode 100644 index 0000000..6397d6e --- /dev/null +++ b/cmd/app/core/middleware/basic_auth.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" +) + +const apiUser = "bauer" + +func APIBasicAuth(next http.Handler, secret string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/health" { + next.ServeHTTP(w, r) + return + } + + username, password, ok := r.BasicAuth() + if !ok || !secureEquals(username, apiUser) || !secureEquals(password, secret) { + w.Header().Set("WWW-Authenticate", `Basic realm="bauer-api"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + +func secureEquals(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/cmd/app/main.go b/cmd/app/main.go index 3f9c0f8..520fe27 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -28,13 +28,6 @@ func run() error { } 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 { @@ -57,8 +50,9 @@ func run() error { mux := http.NewServeMux() mux.HandleFunc("/api/v1/job", v1.JobPost(rc)) mux.HandleFunc("/api/v1/health", v1.GetHealth) + handler := middleware.RequestTrace(middleware.APIBasicAuth(mux, cfg.APISecret)) slog.Info("starting server", "address", ":8090") - err = http.ListenAndServe(":8090", middleware.RequestTrace(mux)) + err = http.ListenAndServe(":8090", handler) if err != nil { slog.Error("server error", "error", err.Error()) diff --git a/cmd/app/types/config.go b/cmd/app/types/config.go index bd988c7..b455cc2 100644 --- a/cmd/app/types/config.go +++ b/cmd/app/types/config.go @@ -3,13 +3,11 @@ package types import ( "bauer/internal/config" "flag" + "fmt" "os" ) type APIConfig struct { - // CredentialsPath is the path to the Google Cloud service account JSON key file. - CredentialsPath string - // OutputDir is the directory where generated prompt files will be saved. // Default is "bauer-output" if not specified. BaseOutputDir string @@ -24,10 +22,17 @@ type APIConfig struct { // TargetRepo is the path (relative or absolute) to the target repository // where tasks should be executed. If not specified, uses the current directory. - TargetRepo string `json:"target_repo"`} + TargetRepo string `json:"target_repo"` + + // APISecret is the shared secret used for API basic auth. + APISecret string +} func LoadConfig() (*APIConfig, error) { - credentialsPath := flag.String("credentials", "", "Path to service account JSON (required)") + if err := config.LoadEnvFiles(); err != nil { + return nil, err + } + baseOutputDir := flag.String("base-output-dir", "bauer-output", "Base path of directory for generated prompt files (default: bauer-output)") model := flag.String("model", "gpt-5-mini-high", "Copilot model to use for sessions (default: gpt-5-mini-high)") summaryModel := flag.String("summary-model", "gpt-5-mini-high", "Copilot model to use for summary session (default: gpt-5-mini-high)") @@ -41,26 +46,25 @@ func LoadConfig() (*APIConfig, error) { if err != nil { return nil, err } - return &APIConfig{ - CredentialsPath: cfg.CredentialsPath, - BaseOutputDir: cfg.OutputDir, - Model: cfg.Model, - SummaryModel: cfg.SummaryModel, - TargetRepo: cfg.TargetRepo, - }, nil - } - - if *credentialsPath == "" { - flag.Usage() - os.Exit(1) + apiCfg := &APIConfig{ + BaseOutputDir: cfg.OutputDir, + Model: cfg.Model, + SummaryModel: cfg.SummaryModel, + TargetRepo: cfg.TargetRepo, + APISecret: os.Getenv("API_SECRET"), + } + if err := apiCfg.Validate(); err != nil { + return nil, err + } + return apiCfg, nil } cfg := &APIConfig{ - CredentialsPath: *credentialsPath, - BaseOutputDir: *baseOutputDir, - Model: *model, - SummaryModel: *summaryModel, - TargetRepo: *targetRepo, + BaseOutputDir: *baseOutputDir, + Model: *model, + SummaryModel: *summaryModel, + TargetRepo: *targetRepo, + APISecret: os.Getenv("API_SECRET"), } if err := cfg.Validate(); err != nil { @@ -71,5 +75,11 @@ func LoadConfig() (*APIConfig, error) { } func (c *APIConfig) Validate() error { - return config.ValidateCredentialsPath(c.CredentialsPath) + if err := config.ValidateCredentialsEnv(); err != nil { + return err + } + if c.APISecret == "" { + return fmt.Errorf("missing required environment variable: API_SECRET") + } + return nil } diff --git a/cmd/app/v1/api.go b/cmd/app/v1/api.go index 40fc99f..dbf8d2b 100644 --- a/cmd/app/v1/api.go +++ b/cmd/app/v1/api.go @@ -33,13 +33,12 @@ func JobPost(rc types.RouteConfig) func(w http.ResponseWriter, r *http.Request) return } cfg := config.Config{ - DocID: payload.DocID, - ChunkSize: payload.ChunkSize, - PageRefresh: payload.PageRefresh, - CredentialsPath: rc.APIConfig.CredentialsPath, - OutputDir: fmt.Sprintf("%s/%s", rc.APIConfig.BaseOutputDir, requestID), - Model: rc.APIConfig.Model, - SummaryModel: rc.APIConfig.SummaryModel, + DocID: payload.DocID, + ChunkSize: payload.ChunkSize, + PageRefresh: payload.PageRefresh, + OutputDir: fmt.Sprintf("%s/%s", rc.APIConfig.BaseOutputDir, requestID), + Model: rc.APIConfig.Model, + SummaryModel: rc.APIConfig.SummaryModel, } go executeJob(requestID, cfg, rc) @@ -83,7 +82,6 @@ func executeJob(requestID string, cfg config.Config, rc types.RouteConfig) { ) } - func GetHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -91,4 +89,4 @@ func GetHealth(w http.ResponseWriter, r *http.Request) { if err != nil { slog.Error("error writing response", "error", err.Error()) } -} \ No newline at end of file +} diff --git a/cmd/bauer/main.go b/cmd/bauer/main.go index e32e21e..3b8b402 100644 --- a/cmd/bauer/main.go +++ b/cmd/bauer/main.go @@ -74,13 +74,6 @@ func runBauer() error { // 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 { @@ -127,18 +120,18 @@ func runBauer() error { 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") { + if strings.Contains(errMsg, "credentials") || strings.Contains(errMsg, "GOOGLE_") { 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") + fmt.Fprintf(os.Stderr, " 1. GOOGLE_* service account variables are set (for example in .env)\n") + fmt.Fprintf(os.Stderr, " 2. GOOGLE_PRIVATE_KEY contains a valid key (use \\n escapes in .env)\n") + fmt.Fprintf(os.Stderr, " 3. Required variables include:\n") + fmt.Fprintf(os.Stderr, " - GOOGLE_TYPE\n") + fmt.Fprintf(os.Stderr, " - GOOGLE_PROJECT_ID\n") + fmt.Fprintf(os.Stderr, " - GOOGLE_PRIVATE_KEY\n") + fmt.Fprintf(os.Stderr, " - GOOGLE_CLIENT_EMAIL\n") + fmt.Fprintf(os.Stderr, " - GOOGLE_TOKEN_URI\n\n") } return err } diff --git a/credentials.json b/credentials.json deleted file mode 100644 index 740ee7f..0000000 --- a/credentials.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "type": "service_account", - "project_id": "", - "private_key_id": "", - "private_key": "", - "client_email": "", - "client_id": "", - "auth_uri": "", - "token_uri": "", - "auth_provider_x509_cert_url": "", - "client_x509_cert_url": "", - "universe_domain": "" -} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0fb1b75..add825a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,7 @@ At a high level, Bauer is a CLI with three core subsystems: Google Docs extraction, prompt generation, and Copilot execution. - cmd/bauer/main.go: CLI entry point and the end-to-end orchestrator. -- internal/config: flag parsing + validation for `--doc-id`, `--credentials`, `--chunk-size`, `--output-dir`, `--dry-run`, `--model`, `--summary-model`, `--page-refresh`. +- internal/config: flag parsing + validation for `--doc-id`, `--chunk-size`, `--output-dir`, `--dry-run`, `--model`, `--summary-model`, `--page-refresh` plus required `GOOGLE_*` env vars. - internal/gdocs: Google Docs/Drive client + extraction pipeline. - service.go: auth + API clients. - extraction.go: fetch doc, walk document tree, build structure/anchors. diff --git a/go.mod b/go.mod index 613e71e..e7b61c1 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/go.sum b/go.sum index 988c8be..a16412c 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/internal/config/cli.go b/internal/config/cli.go index dc4f846..7dc9cd9 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -8,6 +8,10 @@ import ( // Load parses command-line flags and returns a validated Config. func Load() (*Config, error) { + if err := LoadEnvFiles(); err != nil { + return nil, err + } + // Define flags // Note: We use a new FlagSet to facilitate testing if needed later, // but for now relying on the default flag set is sufficient for the main entry point. @@ -15,7 +19,6 @@ func Load() (*Config, error) { // but standard `flag` usage usually assumes run once per process. docID := flag.String("doc-id", "", "Google Doc ID to extract feedback from (required)") - credentialsPath := flag.String("credentials", "", "Path to service account JSON (required)") configFile := flag.String("config", "", "Path to JSON config file") dryRun := flag.Bool("dry-run", false, "Run extraction and planning only; skip Copilot and PR creation") chunkSize := flag.Int("chunk-size", 0, "Total number of chunks to create (default: 1, or 5 if --page-refresh is set)") @@ -28,7 +31,7 @@ func Load() (*Config, error) { // Custom usage message flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage:\n\n") - fmt.Fprintf(os.Stderr, "\t%s --doc-id --credentials [flags]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\t%s --doc-id [flags]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Flags:\n\n") // Manually format flags @@ -39,7 +42,6 @@ func Load() (*Config, error) { }{ {"--config", "", "Path to JSON config file"}, {"--doc-id", "", "Google Doc ID to extract feedback from (required)"}, - {"--credentials", "", "Path to service account JSON (required)"}, {"--dry-run", "", "Run extraction and planning only; skip Copilot and PR creation"}, {"--page-refresh", "", "Use page refresh mode with page-refresh-instructions template"}, {"--chunk-size", "", "Total number of chunks to create (default: 1, or 5 if --page-refresh is set)"}, @@ -68,21 +70,20 @@ func Load() (*Config, error) { } // If no required flags are provided, show usage and exit - if *docID == "" && *credentialsPath == "" { + if *docID == "" { flag.Usage() os.Exit(1) } cfg := &Config{ - DocID: *docID, - CredentialsPath: *credentialsPath, - DryRun: *dryRun, - ChunkSize: *chunkSize, - PageRefresh: *pageRefresh, - OutputDir: *outputDir, - Model: *model, - SummaryModel: *summaryModel, - TargetRepo: *targetRepo, + DocID: *docID, + DryRun: *dryRun, + ChunkSize: *chunkSize, + PageRefresh: *pageRefresh, + OutputDir: *outputDir, + Model: *model, + SummaryModel: *summaryModel, + TargetRepo: *targetRepo, } if err := cfg.Validate(); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 715fefa..17bc0fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,8 +3,6 @@ package config import ( "bauer/internal/gdocs" "errors" - "fmt" - "os" ) // Config holds the runtime configuration for BAU. @@ -12,9 +10,6 @@ type Config struct { // DocID is the Google Doc ID to extract feedback from. DocID string `json:"doc_id"` - // CredentialsPath is the path to the Google Cloud service account JSON key file. - CredentialsPath string `json:"credentials"` - // DryRun indicates if the tool should skip side-effect operations (Copilot CLI, PR creation). DryRun bool `json:"dry_run"` @@ -78,25 +73,13 @@ func (c *Config) Validate() error { return errors.New("chunk_size must be greater than 0") } - return ValidateCredentialsPath(c.CredentialsPath) -} - -func ValidateCredentialsPath(path string) error { - // Verify credentials file exists - info, err := os.Stat(path) - if os.IsNotExist(err) { - return fmt.Errorf("credentials file not found: %s", path) - } - if err != nil { - return fmt.Errorf("error checking credentials file: %w", err) - } - if info.IsDir() { - return fmt.Errorf("credentials path is a directory, expected a file: %s", path) + if err := ValidateCredentialsEnv(); err != nil { + return err } - // Validate credentials content - if err := gdocs.ValidateCredentialsFile(path); err != nil { - return fmt.Errorf("%w", err) - } return nil } + +func ValidateCredentialsEnv() error { + return gdocs.ValidateCredentialsEnv() +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 22e95c7..d11190f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,119 +1,104 @@ package config -import ( - "os" - "path/filepath" - "testing" -) +import "testing" -func TestConfig_Validate(t *testing.T) { - // Create a temporary file to act as a valid credentials file - tmpDir := t.TempDir() - validCredsFile := filepath.Join(tmpDir, "creds.json") - if err := os.WriteFile(validCredsFile, []byte("{}"), 0644); err != nil { - t.Fatalf("Failed to create temp creds file: %v", err) - } +func setValidGoogleEnv(t *testing.T) { + t.Helper() + + t.Setenv("GOOGLE_TYPE", "service_account") + t.Setenv("GOOGLE_PROJECT_ID", "test-project") + t.Setenv("GOOGLE_PRIVATE_KEY_ID", "test-key-id") + t.Setenv("GOOGLE_PRIVATE_KEY", "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\\n") + t.Setenv("GOOGLE_CLIENT_EMAIL", "test@example.iam.gserviceaccount.com") + t.Setenv("GOOGLE_CLIENT_ID", "1234567890") + t.Setenv("GOOGLE_AUTH_URI", "https://accounts.google.com/o/oauth2/auth") + t.Setenv("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token") +} +func TestConfigValidate(t *testing.T) { tests := []struct { name string config Config + setup func(t *testing.T) wantErr bool }{ { name: "Valid config", config: Config{ - DocID: "some-doc-id", - CredentialsPath: validCredsFile, - ChunkSize: 1, - OutputDir: "bauer-output", - Model: "gpt-4", - SummaryModel: "gpt-4", + DocID: "some-doc-id", + ChunkSize: 1, + OutputDir: "bauer-output", + Model: "gpt-4", + SummaryModel: "gpt-4", }, + setup: setValidGoogleEnv, wantErr: false, }, { name: "Missing DocID", config: Config{ - DocID: "", - CredentialsPath: validCredsFile, - ChunkSize: 1, - Model: "gpt-4", - SummaryModel: "gpt-4", + DocID: "", + ChunkSize: 1, + Model: "gpt-4", + SummaryModel: "gpt-4", }, + setup: setValidGoogleEnv, wantErr: true, }, { - name: "Missing CredentialsPath", + name: "Missing required env", config: Config{ - DocID: "some-doc-id", - CredentialsPath: "", - ChunkSize: 1, - Model: "gpt-4", - SummaryModel: "gpt-4", + DocID: "some-doc-id", + ChunkSize: 1, + Model: "gpt-4", + SummaryModel: "gpt-4", }, - wantErr: true, - }, - { - name: "Credentials file does not exist", - config: Config{ - DocID: "some-doc-id", - CredentialsPath: filepath.Join(tmpDir, "non-existent.json"), - ChunkSize: 1, - Model: "gpt-4", - SummaryModel: "gpt-4", - }, - wantErr: true, - }, - { - name: "Credentials path is a directory", - config: Config{ - DocID: "some-doc-id", - CredentialsPath: tmpDir, - ChunkSize: 1, - Model: "gpt-4", - SummaryModel: "gpt-4", + setup: func(t *testing.T) { + setValidGoogleEnv(t) + t.Setenv("GOOGLE_CLIENT_EMAIL", "") }, wantErr: true, }, { name: "Invalid chunk size (negative)", config: Config{ - DocID: "some-doc-id", - CredentialsPath: validCredsFile, - ChunkSize: -1, - Model: "gpt-4", - SummaryModel: "gpt-4", + DocID: "some-doc-id", + ChunkSize: -1, + Model: "gpt-4", + SummaryModel: "gpt-4", }, + setup: setValidGoogleEnv, wantErr: true, }, { name: "Valid config with default model", config: Config{ - DocID: "some-doc-id", - CredentialsPath: validCredsFile, - ChunkSize: 1, - OutputDir: "bauer-output", - Model: "gpt-5-mini-high", - SummaryModel: "gpt-5-mini-high", + DocID: "some-doc-id", + ChunkSize: 1, + OutputDir: "bauer-output", + Model: "gpt-5-mini-high", + SummaryModel: "gpt-5-mini-high", }, + setup: setValidGoogleEnv, wantErr: false, }, { name: "Valid config with empty model (should be allowed, has default)", config: Config{ - DocID: "some-doc-id", - CredentialsPath: validCredsFile, - ChunkSize: 1, - OutputDir: "bauer-output", - Model: "", - SummaryModel: "", + DocID: "some-doc-id", + ChunkSize: 1, + OutputDir: "bauer-output", + Model: "", + SummaryModel: "", }, + setup: setValidGoogleEnv, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + tt.setup(t) err := tt.config.Validate() if (err != nil) != tt.wantErr { t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) @@ -123,13 +108,6 @@ func TestConfig_Validate(t *testing.T) { } func TestChunkSizeDefaults(t *testing.T) { - // Create a temporary file to act as a valid credentials file - tmpDir := t.TempDir() - validCredsFile := filepath.Join(tmpDir, "creds.json") - if err := os.WriteFile(validCredsFile, []byte("{}"), 0644); err != nil { - t.Fatalf("Failed to create temp creds file: %v", err) - } - tests := []struct { name string chunkSizeFlag int @@ -164,7 +142,8 @@ func TestChunkSizeDefaults(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Simulate the logic from Load() + setValidGoogleEnv(t) + effectiveChunkSize := tt.chunkSizeFlag if effectiveChunkSize == 0 { if tt.pageRefreshFlag { @@ -178,23 +157,19 @@ func TestChunkSizeDefaults(t *testing.T) { t.Errorf("Expected chunk size %d, got %d", tt.expectedChunkSize, effectiveChunkSize) } - // Create config with computed chunk size cfg := Config{ - DocID: "test-doc-id", - CredentialsPath: validCredsFile, - ChunkSize: effectiveChunkSize, - PageRefresh: tt.pageRefreshFlag, - OutputDir: "bauer-output", - Model: "gpt-5-mini-high", - SummaryModel: "gpt-5-mini-high", + DocID: "test-doc-id", + ChunkSize: effectiveChunkSize, + PageRefresh: tt.pageRefreshFlag, + OutputDir: "bauer-output", + Model: "gpt-5-mini-high", + SummaryModel: "gpt-5-mini-high", } - // Validate should pass if err := cfg.Validate(); err != nil { t.Errorf("Unexpected validation error: %v", err) } - // Verify the chunk size is what we expect if cfg.ChunkSize != tt.expectedChunkSize { t.Errorf("Config chunk size = %d, expected %d", cfg.ChunkSize, tt.expectedChunkSize) } diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..0f65bb6 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,25 @@ +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +func LoadEnvFiles() error { + for _, file := range []string{".env.local", ".env"} { + if _, err := os.Stat(file); err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("failed to stat %s: %w", file, err) + } + + if err := godotenv.Load(file); err != nil { + return fmt.Errorf("failed to load %s: %w", file, err) + } + } + + return nil +} diff --git a/internal/gdocs/credentials.go b/internal/gdocs/credentials.go index 1fa4fcc..10add9f 100644 --- a/internal/gdocs/credentials.go +++ b/internal/gdocs/credentials.go @@ -4,58 +4,92 @@ import ( "encoding/json" "fmt" "os" + "strings" ) -// ServiceAccountCredentials represents the structure of a Google service account JSON key file. +const ( + envGoogleType = "GOOGLE_TYPE" + envGoogleProjectID = "GOOGLE_PROJECT_ID" + envGooglePrivateKeyID = "GOOGLE_PRIVATE_KEY_ID" + envGooglePrivateKey = "GOOGLE_PRIVATE_KEY" + envGoogleClientEmail = "GOOGLE_CLIENT_EMAIL" + envGoogleClientID = "GOOGLE_CLIENT_ID" + envGoogleAuthURI = "GOOGLE_AUTH_URI" + envGoogleTokenURI = "GOOGLE_TOKEN_URI" + envGoogleAuthProviderX509CertURL = "GOOGLE_AUTH_PROVIDER_X509_CERT_URL" + envGoogleClientX509CertURL = "GOOGLE_CLIENT_X509_CERT_URL" + envGoogleUniverseDomain = "GOOGLE_UNIVERSE_DOMAIN" +) + +// ServiceAccountCredentials represents Google service account credentials. type ServiceAccountCredentials struct { - Type string `json:"type"` - ProjectID string `json:"project_id"` - PrivateKeyID string `json:"private_key_id"` - PrivateKey string `json:"private_key"` - ClientEmail string `json:"client_email"` - ClientID string `json:"client_id"` - AuthURI string `json:"auth_uri"` - TokenURI string `json:"token_uri"` + Type string `json:"type"` + ProjectID string `json:"project_id"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + ClientID string `json:"client_id"` + AuthURI string `json:"auth_uri"` + TokenURI string `json:"token_uri"` + AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url,omitempty"` + ClientX509CertURL string `json:"client_x509_cert_url,omitempty"` + UniverseDomain string `json:"universe_domain,omitempty"` } -// ValidateCredentialsFile checks if the credentials file exists, is readable, and contains required fields. -func ValidateCredentialsFile(path string) error { - // Read the credentials file - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read credentials file: %w", err) - } +func LoadCredentialsFromEnv() (*ServiceAccountCredentials, error) { + creds := &ServiceAccountCredentials{} - if len(data) == 0 { - return fmt.Errorf("credentials file is empty: %s", path) + var err error + if creds.Type, err = requiredEnv(envGoogleType); err != nil { + return nil, err } - - // Parse JSON - var creds ServiceAccountCredentials - if err := json.Unmarshal(data, &creds); err != nil { - return fmt.Errorf("failed to parse credentials JSON: %w", err) + if creds.ProjectID, err = requiredEnv(envGoogleProjectID); err != nil { + return nil, err } - - // Validate required fields - if creds.Type == "" { - return fmt.Errorf("missing required field: type") + if creds.PrivateKeyID, err = requiredEnv(envGooglePrivateKeyID); err != nil { + return nil, err } - - if creds.PrivateKey == "" { - return fmt.Errorf("missing required field: private_key") + if creds.PrivateKey, err = requiredEnv(envGooglePrivateKey); err != nil { + return nil, err } - - if creds.ClientEmail == "" { - return fmt.Errorf("missing required field: client_email") + if creds.ClientEmail, err = requiredEnv(envGoogleClientEmail); err != nil { + return nil, err } - - if creds.ProjectID == "" { - return fmt.Errorf("missing required field: project_id") + if creds.ClientID, err = requiredEnv(envGoogleClientID); err != nil { + return nil, err + } + if creds.AuthURI, err = requiredEnv(envGoogleAuthURI); err != nil { + return nil, err } + if creds.TokenURI, err = requiredEnv(envGoogleTokenURI); err != nil { + return nil, err + } + + creds.PrivateKey = strings.ReplaceAll(creds.PrivateKey, `\n`, "\n") + creds.AuthProviderX509CertURL = os.Getenv(envGoogleAuthProviderX509CertURL) + creds.ClientX509CertURL = os.Getenv(envGoogleClientX509CertURL) + creds.UniverseDomain = os.Getenv(envGoogleUniverseDomain) - if creds.TokenURI == "" { - return fmt.Errorf("missing required field: token_uri") + return creds, nil +} + +func (c *ServiceAccountCredentials) JSON() ([]byte, error) { + payload, err := json.Marshal(c) + if err != nil { + return nil, fmt.Errorf("failed to marshal service account credentials: %w", err) } + return payload, nil +} + +func ValidateCredentialsEnv() error { + _, err := LoadCredentialsFromEnv() + return err +} - return nil +func requiredEnv(key string) (string, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return "", fmt.Errorf("missing required environment variable: %s", key) + } + return value, nil } diff --git a/internal/gdocs/service.go b/internal/gdocs/service.go index 29979c9..0bd66f1 100644 --- a/internal/gdocs/service.go +++ b/internal/gdocs/service.go @@ -3,7 +3,6 @@ package gdocs import ( "context" "fmt" - "os" "golang.org/x/oauth2/google" "google.golang.org/api/docs/v1" @@ -17,12 +16,16 @@ type Client struct { Drive *drive.Service } -// NewClient creates a new Google Docs and Drive client using the provided credentials file. -func NewClient(ctx context.Context, credentialsPath string) (*Client, error) { - // Read service account credentials - credentials, err := os.ReadFile(credentialsPath) +// NewClient creates a new Google Docs and Drive client using service account credentials from environment variables. +func NewClient(ctx context.Context) (*Client, error) { + credentials, err := LoadCredentialsFromEnv() if err != nil { - return nil, fmt.Errorf("failed to read service account file: %w", err) + return nil, fmt.Errorf("failed to load service account credentials from environment: %w", err) + } + + credentialsJSON, err := credentials.JSON() + if err != nil { + return nil, err } // Scopes for both Docs and Drive @@ -31,7 +34,7 @@ func NewClient(ctx context.Context, credentialsPath string) (*Client, error) { "https://www.googleapis.com/auth/drive.readonly", } - config, err := google.JWTConfigFromJSON(credentials, scopes...) + config, err := google.JWTConfigFromJSON(credentialsJSON, scopes...) if err != nil { return nil, fmt.Errorf("failed to create JWT config: %w", err) } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 90d4e73..e175524 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -54,11 +54,10 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( // 1. Initialize GDocs Client and extract from doc extractionStart := time.Now() - gdocsClient, err := gdocs.NewClient(ctx, cfg.CredentialsPath) + gdocsClient, err := gdocs.NewClient(ctx) if err != nil { slog.Error("Failed to initialize Google Docs client", slog.String("error", err.Error()), - slog.String("credentials_path", cfg.CredentialsPath), ) return nil, fmt.Errorf("failed to initialize Google Docs client: %w", err) } diff --git a/scripts/migrate_credentials_to_env.py b/scripts/migrate_credentials_to_env.py new file mode 100755 index 0000000..ba7d953 --- /dev/null +++ b/scripts/migrate_credentials_to_env.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +import argparse +import json +from pathlib import Path + + +KEY_MAP = { + "type": "GOOGLE_TYPE", + "project_id": "GOOGLE_PROJECT_ID", + "private_key_id": "GOOGLE_PRIVATE_KEY_ID", + "private_key": "GOOGLE_PRIVATE_KEY", + "client_email": "GOOGLE_CLIENT_EMAIL", + "client_id": "GOOGLE_CLIENT_ID", + "auth_uri": "GOOGLE_AUTH_URI", + "token_uri": "GOOGLE_TOKEN_URI", + "auth_provider_x509_cert_url": "GOOGLE_AUTH_PROVIDER_X509_CERT_URL", + "client_x509_cert_url": "GOOGLE_CLIENT_X509_CERT_URL", + "universe_domain": "GOOGLE_UNIVERSE_DOMAIN", +} + + +def quote_env(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"') + return f'"{escaped}"' + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Convert credentials.json service-account keys to .env format." + ) + parser.add_argument( + "--input", + default="credentials.json", + help="Path to credentials JSON file (default: credentials.json)", + ) + parser.add_argument( + "--output", + default=".env", + help="Path to output env file (default: .env)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite output file if it already exists", + ) + args = parser.parse_args() + + input_path = Path(args.input) + output_path = Path(args.output) + + if not input_path.exists(): + raise SystemExit(f"input file does not exist: {input_path}") + if output_path.exists() and not args.force: + raise SystemExit( + f"output file already exists: {output_path} (use --force to overwrite)" + ) + + with input_path.open("r", encoding="utf-8") as handle: + credentials = json.load(handle) + + lines = ["# Generated from credentials.json"] + for source_key, env_key in KEY_MAP.items(): + value = str(credentials.get(source_key, "")) + lines.append(f"{env_key}={quote_env(value)}") + + if "API_SECRET" not in credentials: + lines.append("API_SECRET=replace-with-a-long-random-secret") + + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Wrote {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4fe6b0ecb599fd2152005bc7298100c1bd73735d Mon Sep 17 00:00:00 2001 From: Abbie Date: Fri, 13 Feb 2026 09:54:17 +0400 Subject: [PATCH 2/4] chore: stop tracking .env --- .env | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 3625f18..0000000 --- a/.env +++ /dev/null @@ -1,15 +0,0 @@ -# GitHub token for creating pull requests -# Generate at https://github.com/settings/tokens -GITHUB_TOKEN=ghp_xxxxxxxxxxxx - -# Service account email for domain-wide delegation (if useDelegation is enabled) -DELEGATION_EMAIL=your-service-account@your-project.iam.gserviceaccount.com - -# Optional: customize the target repository (defaults to canonical/ubuntu.com) -GITHUB_REPO_OWNER=canonical -GITHUB_REPO_NAME=ubuntu.com - -# Google Docs configuration -# Set the Google Doc URL to extract suggestions from -GOOGLE_DOC_URL=https://docs.google.com/document/d/your-document-id/edit - From a715d0592bab6228498d4732c9cc0786a3b3c0fc Mon Sep 17 00:00:00 2001 From: Abbie Date: Fri, 13 Feb 2026 09:59:29 +0400 Subject: [PATCH 3/4] add dockerfile --- .dockerignore | 23 +++++++++++++++++++++++ Dockerfile | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a6063b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.gitignore + +# Local secrets +.env +.env.local +.env.* +!.env.example +credentials.json +*-creds.json + +# Build/test artifacts +bauer +bauer-api +bauer-output/ +dist/ +*.log +bauer-doc-suggestions.json +bauer-log.json + +# Local tooling files +.vscode/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f4de7bd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +RUN apk add --no-cache ca-certificates git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd ./cmd +COPY internal ./internal + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/bauer-api ./cmd/app + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates + +WORKDIR /app +COPY --from=builder /out/bauer-api /app/bauer-api + +EXPOSE 8090 + +ENTRYPOINT ["/app/bauer-api"] From cceb9f18636ad4aa92cfc6d288feb990019f55a9 Mon Sep 17 00:00:00 2001 From: Abbie Date: Fri, 13 Feb 2026 10:03:14 +0400 Subject: [PATCH 4/4] refactor: simplify code --- cmd/app/main.go | 13 +++++----- cmd/app/types/config.go | 41 ++++++++++++++------------------ cmd/app/v1/api.go | 16 ++++++------- internal/config/cli.go | 18 +++++--------- internal/config/config.go | 18 +++++++++----- internal/config/config_test.go | 15 +----------- internal/config/env.go | 9 +++---- internal/gdocs/credentials.go | 43 +++++++++++++++------------------- 8 files changed, 73 insertions(+), 100 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index 520fe27..7a99c1c 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -20,7 +20,7 @@ func run() error { slog.Info("startup", "status", "initializing API") defer slog.Info("shutdown complete") - orchestrator := orchestrator.NewOrchestrator() + orch := orchestrator.NewOrchestrator() cfg, err := types.LoadConfig() if err != nil { slog.Error("failed to load config", "error", err.Error()) @@ -38,13 +38,16 @@ func run() error { 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() + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } slog.Info("Working directory", "path", cwd) } rc := types.RouteConfig{ APIConfig: *cfg, - Orchestrator: orchestrator, + Orchestrator: orch, } mux := http.NewServeMux() @@ -52,9 +55,7 @@ func run() error { mux.HandleFunc("/api/v1/health", v1.GetHealth) handler := middleware.RequestTrace(middleware.APIBasicAuth(mux, cfg.APISecret)) slog.Info("starting server", "address", ":8090") - err = http.ListenAndServe(":8090", handler) - - if err != nil { + if err := http.ListenAndServe(":8090", handler); err != nil { slog.Error("server error", "error", err.Error()) slog.Info("shutdown complete with errors") return err diff --git a/cmd/app/types/config.go b/cmd/app/types/config.go index b455cc2..ae8ff94 100644 --- a/cmd/app/types/config.go +++ b/cmd/app/types/config.go @@ -33,45 +33,38 @@ func LoadConfig() (*APIConfig, error) { return nil, err } - baseOutputDir := flag.String("base-output-dir", "bauer-output", "Base path of directory for generated prompt files (default: bauer-output)") - model := flag.String("model", "gpt-5-mini-high", "Copilot model to use for sessions (default: gpt-5-mini-high)") - summaryModel := flag.String("summary-model", "gpt-5-mini-high", "Copilot model to use for summary session (default: gpt-5-mini-high)") + baseOutputDir := flag.String("base-output-dir", config.DefaultOutputDir, "Base path of directory for generated prompt files (default: bauer-output)") + model := flag.String("model", config.DefaultModel, "Copilot model to use for sessions (default: gpt-5-mini-high)") + summaryModel := flag.String("summary-model", config.DefaultModel, "Copilot model to use for summary session (default: gpt-5-mini-high)") configFile := flag.String("config", "", "Path to JSON config file") targetRepo := flag.String("target-repo", "", "Path to target repository where tasks should be executed (default: current directory)") flag.Parse() + apiCfg := newAPIConfig(*baseOutputDir, *model, *summaryModel, *targetRepo) if *configFile != "" { cfg, err := config.LoadFromJSONFile(*configFile) if err != nil { return nil, err } - apiCfg := &APIConfig{ - BaseOutputDir: cfg.OutputDir, - Model: cfg.Model, - SummaryModel: cfg.SummaryModel, - TargetRepo: cfg.TargetRepo, - APISecret: os.Getenv("API_SECRET"), - } - if err := apiCfg.Validate(); err != nil { - return nil, err - } - return apiCfg, nil - } - - cfg := &APIConfig{ - BaseOutputDir: *baseOutputDir, - Model: *model, - SummaryModel: *summaryModel, - TargetRepo: *targetRepo, - APISecret: os.Getenv("API_SECRET"), + apiCfg = newAPIConfig(cfg.OutputDir, cfg.Model, cfg.SummaryModel, cfg.TargetRepo) } - if err := cfg.Validate(); err != nil { + if err := apiCfg.Validate(); err != nil { return nil, err } - return cfg, nil + return apiCfg, nil +} + +func newAPIConfig(baseOutputDir, model, summaryModel, targetRepo string) *APIConfig { + return &APIConfig{ + BaseOutputDir: baseOutputDir, + Model: model, + SummaryModel: summaryModel, + TargetRepo: targetRepo, + APISecret: os.Getenv("API_SECRET"), + } } func (c *APIConfig) Validate() error { diff --git a/cmd/app/v1/api.go b/cmd/app/v1/api.go index dbf8d2b..0ebeb8e 100644 --- a/cmd/app/v1/api.go +++ b/cmd/app/v1/api.go @@ -32,6 +32,7 @@ func JobPost(rc types.RouteConfig) func(w http.ResponseWriter, r *http.Request) if err != nil { return } + cfg := config.Config{ DocID: payload.DocID, ChunkSize: payload.ChunkSize, @@ -43,8 +44,7 @@ func JobPost(rc types.RouteConfig) func(w http.ResponseWriter, r *http.Request) go executeJob(requestID, cfg, rc) - err = types.Accepted().Render(w, r) - if err != nil { + if err := types.Accepted().Render(w, r); err != nil { slog.Error("error writing response", "error", err.Error(), "requestID", requestID) } } @@ -52,15 +52,15 @@ func JobPost(rc types.RouteConfig) func(w http.ResponseWriter, r *http.Request) func getJobFromRequest(w http.ResponseWriter, r *http.Request, requestID string) (*models.JobPost, error) { payload := models.JobPost{} - err := json.NewDecoder(r.Body).Decode(&payload) - if err != nil { + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { slog.Error("failed to decode request body", "error", err.Error(), "requestID", requestID) - err := types.BadRequest(fmt.Errorf("invalid request body: %w", err)).Render(w, r) - if err != nil { - slog.Error("error writing response", "error", err.Error(), "requestID", requestID) + renderErr := types.BadRequest(fmt.Errorf("invalid request body: %w", err)).Render(w, r) + if renderErr != nil { + slog.Error("error writing response", "error", renderErr.Error(), "requestID", requestID) } - return nil, err + return nil, renderErr } + return &payload, nil } diff --git a/internal/config/cli.go b/internal/config/cli.go index 7dc9cd9..ecc7b1f 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -12,20 +12,14 @@ func Load() (*Config, error) { return nil, err } - // Define flags - // Note: We use a new FlagSet to facilitate testing if needed later, - // but for now relying on the default flag set is sufficient for the main entry point. - // To avoid conflicts if Load is called multiple times (e.g. in tests), we reset if needed, - // but standard `flag` usage usually assumes run once per process. - docID := flag.String("doc-id", "", "Google Doc ID to extract feedback from (required)") configFile := flag.String("config", "", "Path to JSON config file") dryRun := flag.Bool("dry-run", false, "Run extraction and planning only; skip Copilot and PR creation") chunkSize := flag.Int("chunk-size", 0, "Total number of chunks to create (default: 1, or 5 if --page-refresh is set)") pageRefresh := flag.Bool("page-refresh", false, "Use page refresh mode with page-refresh-instructions template (default chunk size: 5)") - outputDir := flag.String("output-dir", "bauer-output", "Directory for generated prompt files (default: bauer-output)") - model := flag.String("model", "gpt-5-mini-high", "Copilot model to use for sessions (default: gpt-5-mini-high)") - summaryModel := flag.String("summary-model", "gpt-5-mini-high", "Copilot model to use for summary session (default: gpt-5-mini-high)") + outputDir := flag.String("output-dir", DefaultOutputDir, "Directory for generated prompt files (default: bauer-output)") + model := flag.String("model", DefaultModel, "Copilot model to use for sessions (default: gpt-5-mini-high)") + summaryModel := flag.String("summary-model", DefaultModel, "Copilot model to use for summary session (default: gpt-5-mini-high)") targetRepo := flag.String("target-repo", "", "Path to target repository where tasks should be executed (default: current directory)") // Custom usage message @@ -45,9 +39,9 @@ func Load() (*Config, error) { {"--dry-run", "", "Run extraction and planning only; skip Copilot and PR creation"}, {"--page-refresh", "", "Use page refresh mode with page-refresh-instructions template"}, {"--chunk-size", "", "Total number of chunks to create (default: 1, or 5 if --page-refresh is set)"}, - {"--output-dir", "", "Directory for generated prompt files (default: bauer-output)"}, - {"--model", "", "Copilot model to use for sessions (default: gpt-5-mini-high)"}, - {"--summary-model", "", "Copilot model to use for summary session (default: gpt-5-mini-high)"}, + {"--output-dir", "", "Directory for generated prompt files (default: " + DefaultOutputDir + ")"}, + {"--model", "", "Copilot model to use for sessions (default: " + DefaultModel + ")"}, + {"--summary-model", "", "Copilot model to use for summary session (default: " + DefaultModel + ")"}, {"--target-repo", "", "Path to target repository where tasks should be executed (default: current directory)"}, } diff --git a/internal/config/config.go b/internal/config/config.go index 17bc0fd..fe0b506 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,13 @@ import ( "errors" ) +const ( + DefaultChunkSize = 1 + DefaultPageRefreshChunkSize = 5 + DefaultOutputDir = "bauer-output" + DefaultModel = "gpt-5-mini-high" +) + // Config holds the runtime configuration for BAU. type Config struct { // DocID is the Google Doc ID to extract feedback from. @@ -41,20 +48,19 @@ type Config struct { // Apply default config values func (c *Config) ApplyDefaults() { if c.ChunkSize == 0 { + c.ChunkSize = DefaultChunkSize if c.PageRefresh { - c.ChunkSize = 5 - } else { - c.ChunkSize = 1 + c.ChunkSize = DefaultPageRefreshChunkSize } } if c.OutputDir == "" { - c.OutputDir = "bauer-output" + c.OutputDir = DefaultOutputDir } if c.Model == "" { - c.Model = "gpt-5-mini-high" + c.Model = DefaultModel } if c.SummaryModel == "" { - c.SummaryModel = "gpt-5-mini-high" + c.SummaryModel = DefaultModel } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d11190f..d9b9b15 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -144,22 +144,9 @@ func TestChunkSizeDefaults(t *testing.T) { t.Run(tt.name, func(t *testing.T) { setValidGoogleEnv(t) - effectiveChunkSize := tt.chunkSizeFlag - if effectiveChunkSize == 0 { - if tt.pageRefreshFlag { - effectiveChunkSize = 5 - } else { - effectiveChunkSize = 1 - } - } - - if effectiveChunkSize != tt.expectedChunkSize { - t.Errorf("Expected chunk size %d, got %d", tt.expectedChunkSize, effectiveChunkSize) - } - cfg := Config{ DocID: "test-doc-id", - ChunkSize: effectiveChunkSize, + ChunkSize: tt.chunkSizeFlag, PageRefresh: tt.pageRefreshFlag, OutputDir: "bauer-output", Model: "gpt-5-mini-high", diff --git a/internal/config/env.go b/internal/config/env.go index 0f65bb6..68208d2 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" @@ -9,14 +10,10 @@ import ( func LoadEnvFiles() error { for _, file := range []string{".env.local", ".env"} { - if _, err := os.Stat(file); err != nil { - if os.IsNotExist(err) { + if err := godotenv.Load(file); err != nil { + if errors.Is(err, os.ErrNotExist) { continue } - return fmt.Errorf("failed to stat %s: %w", file, err) - } - - if err := godotenv.Load(file); err != nil { return fmt.Errorf("failed to load %s: %w", file, err) } } diff --git a/internal/gdocs/credentials.go b/internal/gdocs/credentials.go index 10add9f..256a41a 100644 --- a/internal/gdocs/credentials.go +++ b/internal/gdocs/credentials.go @@ -38,31 +38,26 @@ type ServiceAccountCredentials struct { func LoadCredentialsFromEnv() (*ServiceAccountCredentials, error) { creds := &ServiceAccountCredentials{} - - var err error - if creds.Type, err = requiredEnv(envGoogleType); err != nil { - return nil, err - } - if creds.ProjectID, err = requiredEnv(envGoogleProjectID); err != nil { - return nil, err - } - if creds.PrivateKeyID, err = requiredEnv(envGooglePrivateKeyID); err != nil { - return nil, err - } - if creds.PrivateKey, err = requiredEnv(envGooglePrivateKey); err != nil { - return nil, err + requiredFields := []struct { + envKey string + target *string + }{ + {envKey: envGoogleType, target: &creds.Type}, + {envKey: envGoogleProjectID, target: &creds.ProjectID}, + {envKey: envGooglePrivateKeyID, target: &creds.PrivateKeyID}, + {envKey: envGooglePrivateKey, target: &creds.PrivateKey}, + {envKey: envGoogleClientEmail, target: &creds.ClientEmail}, + {envKey: envGoogleClientID, target: &creds.ClientID}, + {envKey: envGoogleAuthURI, target: &creds.AuthURI}, + {envKey: envGoogleTokenURI, target: &creds.TokenURI}, } - if creds.ClientEmail, err = requiredEnv(envGoogleClientEmail); err != nil { - return nil, err - } - if creds.ClientID, err = requiredEnv(envGoogleClientID); err != nil { - return nil, err - } - if creds.AuthURI, err = requiredEnv(envGoogleAuthURI); err != nil { - return nil, err - } - if creds.TokenURI, err = requiredEnv(envGoogleTokenURI); err != nil { - return nil, err + + for _, field := range requiredFields { + value, err := requiredEnv(field.envKey) + if err != nil { + return nil, err + } + *field.target = value } creds.PrivateKey = strings.ReplaceAll(creds.PrivateKey, `\n`, "\n")