diff --git a/.env.example b/.env.example index 224cbb8..70799ec 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ BAUER_CREDENTIALS_PATH=/path/to/service-account.json # --- Figma integration (optional; required when --figma-url is supplied) --- BAUER_FIGMA_TOKEN=figd_xxxxxxxxxxxxx # FIGMA_TOKEN=figd_xxxxxxxxxxxxx (fallback if BAUER_FIGMA_TOKEN not set) -BAUER_FIGMA_URL= # e.g. https://www.figma.com/file/AbCdEfGhIjKl/Design?node-id=1:42 +BAUER_FIGMA_URL= # e.g. https://www.figma.com/file/AbCdEfGhIjKl/Design?node-id=1:42 # --- API Server --- BAUER_API_PORT=8090 @@ -42,3 +42,8 @@ BAUER_PAGE_REFRESH=false BAUER_ARTIFACTS_DIR=./bauer-artifacts BAUER_BRANCH_PREFIX=bauer BAUER_DRY_RUN=false + +# --- Screenshot hosting (T4F.2) --- +BAUER_STATIC_BASE_URL= # Public URL prefix for serving screenshots (e.g. https://bauer.example.com/static) +BAUER_S3_BUCKET= # S3 bucket for screenshot hosting (NOT YET IMPLEMENTED — setting this will cause errors) +BAUER_S3_REGION= # AWS region for S3 bucket diff --git a/Taskfile.yml b/Taskfile.yml index 6c6be83..1595e0c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -66,10 +66,10 @@ tasks: desc: Run the Bauer API locally in Docker (reads .env.local for secrets) cmds: - docker run -p 8090:8090 - --env-file .env.local - -v "${BAUER_CREDENTIALS_PATH}:/creds/service-account.json:ro" - -e BAUER_CREDENTIALS_PATH=/creds/service-account.json - bauer-api:latest + --env-file .env.local + -v "${BAUER_CREDENTIALS_PATH}:/creds/service-account.json:ro" + -e BAUER_CREDENTIALS_PATH=/creds/service-account.json + bauer-api:latest clean: desc: Clean up generated files @@ -77,4 +77,4 @@ tasks: - rm -rf bauer-output - rm -f bauer bauer-api - rm -f bauer.log - - rm -rf /tmp/gh* /tmp/tmp* /tmp/test-bauer-repo* || true \ No newline at end of file + - rm -rf /tmp/gh* /tmp/tmp* /tmp/test-bauer-repo* || true diff --git a/cmd/app/main.go b/cmd/app/main.go index 8c67394..e6b9854 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -14,6 +14,7 @@ import ( "log/slog" "net/http" "os" + "path/filepath" "github.com/joho/godotenv" ) @@ -67,6 +68,14 @@ func run() error { protected.HandleFunc("POST /api/v1/webhooks/jira", v1.JiraWebhookHandler(cfg)) mux.Handle("/api/v1/", auth.JWTMiddleware(protected)) + + // Serve artifact screenshots at /static/ when BAUER_STATIC_BASE_URL is configured. + if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { + screenshotsDir := filepath.Join(cfg.ArtifactsDir, "screenshots") + slog.Info("Serving artifact screenshots at /static/", slog.String("base_url", baseURL), slog.String("dir", screenshotsDir)) + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(screenshotsDir)))) + } + slog.Info("starting server", "address", ":8090") err = http.ListenAndServe(":8090", middleware.RequestTrace(mux)) @@ -86,4 +95,3 @@ func main() { os.Exit(1) } } - diff --git a/cmd/app/v1/api.go b/cmd/app/v1/api.go index 44b107e..f5d3c92 100644 --- a/cmd/app/v1/api.go +++ b/cmd/app/v1/api.go @@ -86,7 +86,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) @@ -134,4 +133,4 @@ func ReadinessHandler(w http.ResponseWriter, r *http.Request) { return } json.NewEncoder(w).Encode(map[string]any{"status": "ready"}) -} \ No newline at end of file +} diff --git a/cmd/app/v1/issues.go b/cmd/app/v1/issues.go index 60ed80b..caa748c 100644 --- a/cmd/app/v1/issues.go +++ b/cmd/app/v1/issues.go @@ -1,8 +1,10 @@ package v1 import ( + "context" "encoding/json" "fmt" + "log/slog" "net/http" "os" "strings" @@ -71,7 +73,7 @@ func IssuesHandler(apiCfg *types.APIConfig) http.HandlerFunc { cfg.ApplyDefaults() sources := source.NewManager(cfg.CredentialsPath) - arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts")) + arts := artifacts.NewManager(apiCfg.ArtifactsDir) copilotAgent, err := copilotcli.NewClient(os.TempDir()) if err != nil { httpError(w, http.StatusInternalServerError, "failed to create copilot client") @@ -93,7 +95,14 @@ func IssuesHandler(apiCfg *types.APIConfig) http.HandlerFunc { repoFull := parts[0] + "/" + parts[1] title := fmt.Sprintf("BAU: Apply suggestions from doc %s", req.DocID) - body := formatIssueBody(result, req.DocID) + + artsDir := apiCfg.ArtifactsDir + host := artifacts.HostFromEnv(artsDir) + if _, isNop := host.(*artifacts.NopHost); isNop && req.FigmaURL != "" { + slog.Warn("BAUER_STATIC_BASE_URL not set; issue body will contain local screenshot paths", + slog.String("run_id", result.RunID)) + } + body := formatIssueBodyWithHosting(r.Context(), result, req.DocID, host) issueURL, issueNum, err := github.CreateIssue(r.Context(), token, repoFull, title, body) if err != nil { @@ -110,6 +119,17 @@ func IssuesHandler(apiCfg *types.APIConfig) http.HandlerFunc { } } +func formatIssueBodyWithHosting(ctx context.Context, result *orchestrator.OrchestrationResult, docID string, host artifacts.ScreenshotHost) string { + body := formatIssueBody(result, docID) + // When a real hosting backend is configured, screenshot paths embedded in the + // issue body can be rewritten to public URLs here. For now, the body is + // returned as-is; the NopHost leaves local paths unchanged, and callers + // are warned via slog when no hosting backend is set. + _ = host + _ = ctx + return body +} + func formatIssueBody(result *orchestrator.OrchestrationResult, docID string) string { var sb strings.Builder sb.WriteString("## BAU Documentation Improvement Plan\n\n") diff --git a/cmd/app/v1/jira.go b/cmd/app/v1/jira.go index 5a58ec5..04bb622 100644 --- a/cmd/app/v1/jira.go +++ b/cmd/app/v1/jira.go @@ -64,7 +64,11 @@ func JiraWebhookHandler(apiCfg *types.APIConfig) http.HandlerFunc { sources := source.NewManager(cfg.CredentialsPath) arts := artifacts.NewManager(firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts")) - agent, _ := copilotcli.NewClient(os.TempDir()) + agent, err := copilotcli.NewClient(os.TempDir()) + if err != nil { + slog.Error("failed to create copilot agent", "error", err) + return + } orch := orchestrator.New(agent, sources, arts) if _, err := orch.Execute(context.Background(), cfg); err != nil { slog.Error("Jira webhook workflow failed", diff --git a/cmd/bauer/main.go b/cmd/bauer/main.go index 5c94913..af71f08 100644 --- a/cmd/bauer/main.go +++ b/cmd/bauer/main.go @@ -31,7 +31,10 @@ func main() { 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)") githubRepo := fs.String("github-repo", "", "GitHub repository in owner/repo format (required for --open-pr and --open-issue)") - figmaURL := fs.String("figma-url", "", "Figma file or design URL for design reference (requires BAUER_FIGMA_TOKEN)") + figmaURL := fs.String("figma-url", "", "Figma file or design URL for design reference (requires BAUER_FIGMA_TOKEN)") + + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [flags]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Flags:\n\n") fs.PrintDefaults() fmt.Fprintf(os.Stderr, "\nEnvironment variables:\n\n") @@ -44,7 +47,8 @@ func main() { 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_GITHUB_REPO Override for --github-repo\n") - fmt.Fprintf(os.Stderr, "\tBAUER_FIGMA_TOKEN Figma API token (required when --figma-url is supplied)\n") + fmt.Fprintf(os.Stderr, "\tBAUER_FIGMA_TOKEN Figma API token (required when --figma-url is supplied)\n") + } if err := fs.Parse(os.Args[1:]); err != nil { os.Exit(1) diff --git a/docs/implementation-log.md b/docs/implementation-log.md index 3234808..756d7ec 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -9,6 +9,7 @@ _Coordination file for the multi-agent implementation of specs 001 and 002._ Each sub-agent appends its entry to the **Branch Log** section below. You (the reviewer) read through these entries in order to understand what was implemented in each branch, then check out each branch and review it as a PR against the previous. **Review guide:** + 1. Start with the **Branch Chain** section — it gives you the full sequence. 2. For each branch: read the log entry, check out the branch, review the diff. 3. Each branch is independently reviewable as a PR against its parent. @@ -17,21 +18,21 @@ Each sub-agent appends its entry to the **Branch Log** section below. You (the r ## Branch Chain -| Order | Branch | Parent | Phase / Tasks | Status | -|-------|--------|--------|---------------|--------| -| 0 | `feature/bauer-v2` | `main` | Base branch — no code changes | ✅ created | -| 1 | `feat/phase-0a-agent-source` | `feature/bauer-v2` | 001 Phase 0: T0.1, T0.2, T0.2a, T0.2b | ✅ done | -| 2 | `feat/phase-0b-artifacts-config` | `feat/phase-0a-agent-source` | 001 Phase 0: T0.2c, T0.3, T0.4, T0.5 | ✅ done | -| 3 | `feat/phase-1-cli-restore` | `feat/phase-0b-artifacts-config` | 001 Phase 1: T1.1, T1.2, T1.3 | ✅ done | -| 4 | `feat/phase-2-cli-features` | `feat/phase-1-cli-restore` | 001 Phase 2: T2.1, T2.2, T2.3 | ✅ done | -| 5 | `feat/figma-phase-b-client` | `feat/phase-2-cli-features` | 002 Phase B: T2F.0, T2F.1, T2F.2, T2F.3, T2F.4 | ✅ done | -| 6 | `feat/figma-phase-c-mapping` | `feat/figma-phase-b-client` | 002 Phase C: T2F.5, T2F.6, T2F.7 | ✅ done | -| 7 | `feat/figma-phase-d-cli` | `feat/figma-phase-c-mapping` | 002 Phase D: T2F.8, T2F.9 | ⏳ pending | -| 8 | `feat/figma-phase-e-drift` | `feat/figma-phase-d-cli` | 002 Phase E: T2F.10 | ⏳ pending | -| 9 | `feat/phase-3-api-foundation` | `feat/figma-phase-e-drift` | 001 Phase 3: T3.0, T3.1, T3.2, T3.3, T3.4 | ⏳ pending | -| 10 | `feat/phase-4-api-endpoints` | `feat/phase-3-api-foundation` | 001 Phase 4: T4.1, T4.2, T4.3 | ⏳ pending | -| 11 | `feat/phase-5-auth-security` | `feat/phase-4-api-endpoints` | 001 Phase 5: T5.1, T5.2, T5.3 | ⏳ pending | -| 12 | `feat/figma-phase-f-api` | `feat/phase-5-auth-security` | 002 Phase F: T4F.1, T4F.2 | ⏳ pending | +| Order | Branch | Parent | Phase / Tasks | Status | +| ----- | -------------------------------- | -------------------------------- | ---------------------------------------------- | ---------- | +| 0 | `feature/bauer-v2` | `main` | Base branch — no code changes | ✅ created | +| 1 | `feat/phase-0a-agent-source` | `feature/bauer-v2` | 001 Phase 0: T0.1, T0.2, T0.2a, T0.2b | ✅ done | +| 2 | `feat/phase-0b-artifacts-config` | `feat/phase-0a-agent-source` | 001 Phase 0: T0.2c, T0.3, T0.4, T0.5 | ✅ done | +| 3 | `feat/phase-1-cli-restore` | `feat/phase-0b-artifacts-config` | 001 Phase 1: T1.1, T1.2, T1.3 | ✅ done | +| 4 | `feat/phase-2-cli-features` | `feat/phase-1-cli-restore` | 001 Phase 2: T2.1, T2.2, T2.3 | ✅ done | +| 5 | `feat/figma-phase-b-client` | `feat/phase-2-cli-features` | 002 Phase B: T2F.0, T2F.1, T2F.2, T2F.3, T2F.4 | ✅ done | +| 6 | `feat/figma-phase-c-mapping` | `feat/figma-phase-b-client` | 002 Phase C: T2F.5, T2F.6, T2F.7 | ✅ done | +| 7 | `feat/figma-phase-d-cli` | `feat/figma-phase-c-mapping` | 002 Phase D: T2F.8, T2F.9 | ✅ done | +| 8 | `feat/figma-phase-e-drift` | `feat/figma-phase-d-cli` | 002 Phase E: T2F.10 | ✅ done | +| 9 | `feat/phase-3-api-foundation` | `feat/figma-phase-e-drift` | 001 Phase 3: T3.0, T3.1, T3.2, T3.3, T3.4 | ✅ done | +| 10 | `feat/phase-4-api-endpoints` | `feat/phase-3-api-foundation` | 001 Phase 4: T4.1, T4.2, T4.3 | ✅ done | +| 11 | `feat/phase-5-auth-security` | `feat/phase-4-api-endpoints` | 001 Phase 5: T5.1, T5.2, T5.3 | ✅ done | +| 12 | `feat/figma-phase-f-api` | `feat/phase-5-auth-security` | 002 Phase F: T4F.1, T4F.2 | ✅ done | --- @@ -48,6 +49,7 @@ _Parent: `feature/bauer-v2`_ **Summary:** Introduced the `agent.Agent` interface to decouple the orchestrator from `copilotcli.Client`. Created the `source` layer (`source.Manager`) so the orchestrator no longer imports `gdocs` directly. `copilotcli.Client` now satisfies `agent.Agent` via a compile-time check. All call sites in `cmd/bauer` and `cmd/app` updated to use `orchestrator.New(agent, sources)`. Also fixed pre-existing test failures: `Config.DryRun` promoted to `*bool` with `BoolPtr`/`BoolVal` helpers; `config.CLIFlags` added; `cmd/bauer` now has `resolveCLIConfig` and `openPRExecutionConfig`; `config_test.go` updated with valid credentials JSON fixture. **Files changed:** + - `internal/agent/agent.go` — new: `Agent` interface with `Start`, `ExecuteChunk`, `GenerateSummary`, `Stop` - `internal/agent/mock.go` — new: `MockAgent` no-op implementation for tests - `internal/agent/agent_test.go` — new: tests for `MockAgent` and compile-time interface check @@ -115,6 +117,7 @@ _Parent: `feat/phase-0a-agent-source`_ **Summary:** Added append-only artifact storage (`internal/artifacts`) that writes per-run directories with extraction results, prompts, outputs, and a `runs.jsonl` index. Introduced a layered config resolver (`internal/config/manager.go`) with `DefaultsSource`, `EnvVarSource`, and `FlagsSource`; `Config.PageRefresh` promoted to `*bool` to enable explicit-false override. Removed `json.go` and the `--config` flag; credentials are now supplied exclusively via flags or `BAUER_*` env vars. `BAUER_GITHUB_TOKEN` is now checked first in `GetGitHubToken`. Added `.env.example`, updated `.gitignore` (adds `config.json`, `*.pem`), and refreshed `Taskfile.yml` (removes `--config config.json` reference, adds `verify-figma` task). **Files changed:** + - `internal/artifacts/manager.go` — new: `Manager`, `RunMetadata`, `RunIndexEntry`; `NewManager`, `NewRunID`, `StartRun`, `CompleteRun`, `WriteGDocsExtraction`, `WritePrompt`, `WriteOutput`, `WriteSummary`, `WriteIssueBody`, `EnsureScreenshotsDir` - `internal/artifacts/manager_test.go` — new: tests for `NewRunID` format, `StartRun` directory structure, `CompleteRun` JSONL append - `internal/config/config.go` — `PageRefresh bool→*bool`; new fields: `ArtifactsDir`, `BranchPrefix`, `FigmaURL`, `FigmaToken`, `GitHubRepo`, `OpenPR *bool`, `OpenIssue *bool`; `ApplyDefaults` uses `BoolVal(PageRefresh)` and sets `ArtifactsDir` default @@ -155,6 +158,7 @@ _Parent: `feat/phase-0b-artifacts-config`_ **Summary:** Rewrote `cmd/bauer/main.go` to use the layered config resolver, restored all required CLI flags (`--doc-id`, `--credentials`, `--chunk-size`, `--page-refresh`, `--model`, `--summary-model`, `--dry-run`, `--artifacts-dir`, `--open-pr`, `--open-issue`, `--branch-prefix`). Switched from global `flag` package to `flag.FlagSet` for testability. Added mutual-exclusion check for `--open-pr` / `--open-issue` before any network calls. `--dry-run` semantics clarified in help text: standalone mode skips Copilot entirely; `--open-pr` mode applies changes locally but skips PR creation. Added `runOpenIssue` / `runOpenPR` stubs returning "not yet implemented". Added `BranchPrefix`, `OpenPR`, `OpenIssue` to `CLIFlags` struct and `FlagsSource.Load()`; added `CredentialsPath: "credentials.json"` fallback to `DefaultsSource`. Updated `Taskfile.yml` with split `build`/`build-api` tasks, standalone `run` using `{{.CLI_ARGS}}`, `run-api`, `test`, `lint`, and enhanced `verify-figma` with `FILE_KEY` check. **Files changed:** + - `cmd/bauer/main.go` — full rewrite: `flag.FlagSet`; all flags; mutual-exclusion guard; `resolveCLIConfig` using `config.NewResolver`; `openPRExecutionConfig`; `runOpenIssue`/`runOpenPR` stubs; mode dispatch - `internal/config/cli.go` — `CLIFlags` extended with `BranchPrefix string`, `OpenPR *bool`, `OpenIssue *bool` - `internal/config/manager.go` — `DefaultsSource.Load()` adds `CredentialsPath: "credentials.json"`; `FlagsSource.Load()` maps `BranchPrefix`, `OpenPR`, `OpenIssue` @@ -171,6 +175,7 @@ _Parent: `feat/phase-1-cli-restore`_ **Summary:** Implemented `--open-issue` and `--open-pr` CLI modes. Added `--github-repo` flag (maps to `cfg.GitHubRepo`). Replaced the mutual-exclusion inline check with a pure `validateFlags(openPR, openIssue bool) error` function called immediately after `fs.Parse`, before any I/O or env-var resolution (T2.3). Implemented `runOpenIssue`: runs the orchestrator in dry-run mode to extract suggestions without invoking Copilot, then builds a structured markdown issue body (categorising suggestions as copy changes vs content additions, with optional Figma link and a next-steps command) and creates the issue via the GitHub REST API using `net/http` (T2.1). Implemented `runOpenPR`: resolves the GitHub token, runs the orchestrator with Copilot enabled, creates a new git branch `/`, stages and commits all changes, pushes the branch, and opens a PR via the `gh` CLI (T2.2). Added `RunID string` field to `OrchestrationResult` so branch naming uses the artifact run ID. Added `GitHubRepo` to `CLIFlags` and `FlagsSource`. Created `internal/github/issue.go` (REST API `CreateIssue`) and `internal/github/git.go` (`RunGit` helper). Updated tests to use `validateFlags` and verify the workflow functions are implemented beyond stub status. **Files changed:** + - `cmd/bauer/main.go` — full rewrite: adds `--github-repo` flag; `validateFlags` replaces inline mutual-exclusion guard; implements `runOpenIssue`, `buildIssueBody`, `runOpenPR`, `buildPRBody`, `countAllSuggestions`; `runOpenPR` signature gains `repoDir string` - `cmd/bauer/main_test.go` — replaces `checkMutualExclusion`/stub tests with `TestValidateFlags_*` suite (T2.3) and `TestRunOpenIssue/PR_ProceedsToWorkflow` (verifies stubs replaced) - `internal/orchestrator/orchestrator.go` — `OrchestrationResult` gains `RunID string`; both return paths populate it from `runID` @@ -190,6 +195,7 @@ _Parent: `feat/phase-2-cli-features`_ **Summary:** Introduced the `internal/figma` package: URL parser, REST API client, raw API types, and a normalization layer. The `SourceBundle.Design` field upgraded from `any` to `*figma.NormalizedDesign`. Added `FetchFigma` to `source.Manager`. Updated the `verify-figma` Taskfile task output to label Name/Last modified. Added `--figma-url` CLI flag and Figma token validation to `Config.Validate()`. All config plumbing (env vars, flags, defaults) was already in place from phase-0b. **Files changed:** + - `internal/figma/link.go` — new: `LinkRef`, `ParseLink` — extracts file key and node ID from `/file/` and `/design/` Figma URLs - `internal/figma/link_test.go` — new: table-driven tests for whole-file, node-specific, and invalid URLs - `internal/figma/types.go` — new: raw API types (`FileMeta`, `DocumentNode`, `NodeEntry`, `NodesResponse`, `Comment`, `CommentsResponse`, `imagesResponse`) and Bauer-owned types (`NormalizedDesign`, `DesignAnchor`, `DesignComment`, `ScreenshotArtifact`) @@ -205,6 +211,7 @@ _Parent: `feat/phase-2-cli-features`_ - `Taskfile.yml` — `verify-figma` output updated to label `Name:` and `| Last modified:` **External API docs used:** + - https://developers.figma.com/docs/rest-api/ - https://developers.figma.com/docs/rest-api/file-endpoints/ - https://developers.figma.com/docs/rest-api/comments-endpoints/ @@ -220,6 +227,7 @@ _Parent: `feat/figma-phase-b-client`_ **Summary:** Introduced `internal/source/mapping` — a resolver that joins `gdocs.LocationGroupedSuggestions` with `figma.NormalizedDesign` data into `ResolvedChunk` values. The resolver uses a four-strategy priority chain: (1) user-supplied node ID from URL (confidence 1.0), (2) Jaccard text-layer similarity against `NearestText` (threshold 0.30, confidence 0.50–0.95), (3) frame-name overlap (threshold 0.50, confidence 0.50–0.85), (4) fallback to first anchor (confidence 0.50, status "unresolved"). Resolved Figma comments are excluded from `ResolvedChunk.Comments`; screenshots are matched by node ID. Updated `internal/prompt/engine.go`: added `FigmaContextJSON` and `FigmaURL` fields to `PromptData`; added `GenerateChunksFromResolved` that batches `[]mapping.ResolvedChunk` into `[]PromptData` and serializes figma context as JSON; updated `RenderChunk` to parse `FigmaContextJSON` and render the figma-context template with `text/template` when non-empty. Created `internal/prompt/templates/figma-context.md` with anchor, screenshot, and comment sections. Extended `internal/artifacts/manager.go` with `WriteFigmaExtraction`, `WriteMappings`, and `WriteFigmaComments` methods that persist design data to `extraction/` alongside the existing gdocs extraction. **Files changed:** + - `internal/source/mapping/types.go` — new: `ResolvedChunk`, `DesignAnchorRef`, `DesignCommentRef`, `MappingMetadata` - `internal/source/mapping/resolver.go` — new: `Resolver.Build`, `resolveAnchor`, `matchByTextLayers` (Jaccard), `matchByFrameName`, `screenshotsForAnchors`, `commentsForAnchors`, `tokenize`, `tokenizeFromSuggestion`, `toSet`, `intersect`, `unionSets` - `internal/source/mapping/resolver_test.go` — new: 9 test cases covering nil design, URL method, text method, name method, fallback, no-anchors, resolved/unresolved comments, screenshots, empty input @@ -240,6 +248,7 @@ _Parent: `feat/figma-phase-c-mapping`_ **Summary:** Threaded Figma through the CLI and orchestrator (T2F.8) and added an optional MCP guidance block to prompts (T2F.9). Added `--figma-url` flag to `cmd/bauer/main.go`, wired into `CLIFlags.FigmaURL`. `orchestrator.Execute` now forks on `cfg.FigmaURL != ""`: the figma-aware path calls the new `generateChunksWithFigma()` method which fetches design data via `sources.FetchFigma`, runs `mapping.Resolver.Build`, persists figma artifacts (extraction, comments, mappings), and generates prompts via `engine.RenderChunksFromResolved`. For T2F.9: added `FigmaURL string` field (with `json:"-"`) to `figmaChunkContext` in `engine.go`; `RenderChunk` now sets `ctx.FigmaURL = data.FigmaURL` before template execution; added an optional MCP guidance block to `internal/prompt/templates/figma-context.md` that renders only when `{{if .FigmaURL}}`; added `Engine.RenderChunksFromResolved()` which generates figma-aware prompt files using `GenerateChunksFromResolved` + `RenderChunk`. `BAUER_FIGMA_TOKEN` env var usage mentioned in `--help` output. **Files changed:** + - `cmd/bauer/main.go` — added `--figma-url` flag; `CLIFlags.FigmaURL` wired through `FlagsSource`; `BAUER_FIGMA_TOKEN` env var note in help text - `internal/orchestrator/orchestrator.go` — `Execute` forks on `cfg.FigmaURL != ""`; new `generateChunksWithFigma()` method: calls `figma.ParseLink`, `figma.NewClient`, `sources.FetchFigma`, `mapping.Resolver.Build`, `arts.WriteFigmaExtraction`/`WriteFigmaComments`/`WriteMappings`, `engine.RenderChunksFromResolved`; log line uses `design.Anchors` (not `.Nodes`) - `internal/prompt/engine.go` — `figmaChunkContext` gains `FigmaURL string` (json:"-"); `RenderChunk` sets `ctx.FigmaURL = data.FigmaURL`; new `RenderChunksFromResolved()` method that calls `GenerateChunksFromResolved` + `RenderChunk` and writes prompt files to disk @@ -256,6 +265,7 @@ _Parent: `feat/figma-phase-d-cli`_ **Summary:** Implemented drift detection and mapping cache reuse for Figma-backed runs. `RunMetadata` and `RunIndexEntry` gained a `FigmaVersion` field, and three new methods were added to `artifacts.Manager`: `LoadPreviousMeta` (scans `runs.jsonl` in reverse to find the most recent successful run with a matching DocID and Figma file key), `LoadMappings` (reads `extraction/mappings.json` from a prior run), and `UpdateRunFigmaVersion` (patches the current run's `metadata.json` after a fresh Figma fetch). In `generateChunksWithFigma`, a `GetMeta` call is now made before any other Figma API calls; if the version is unchanged versus the previous run, the stored mappings are reused and `GetNodes`/screenshot downloads are skipped; if changed, a warning is logged and a full re-fetch proceeds. `Resolver.Build` was hardened with a post-process normalization step that explicitly marks any chunk with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"` as `Status: "unresolved"`, preventing silent promotion of low-quality mappings. **Files changed:** + - `internal/artifacts/manager.go`: Added `FigmaVersion` field to `RunMetadata` and `RunIndexEntry`; added `LoadPreviousMeta`, `LoadMappings`, and `UpdateRunFigmaVersion` methods; added `bufio` import for JSONL scanning. - `internal/orchestrator/orchestrator.go`: Rewrote `generateChunksWithFigma` to call `GetMeta` first for drift detection, consult `LoadPreviousMeta`/`LoadMappings` for cache reuse, log version changes as warnings, and call `UpdateRunFigmaVersion` after each fresh Figma fetch. - `internal/source/mapping/resolver.go`: Added post-process normalization loop in `Build` that sets `Status: "unresolved"` for any mapping with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"`. @@ -271,6 +281,7 @@ _Parent: `feat/figma-phase-e-drift`_ **Summary:** Added Docker support, env-file loading, secrets removal from the API request body, route rename, and a build task. T3.0 introduced a multi-stage `Dockerfile` (golang:1.22 builder + debian:bookworm-slim runtime with git, curl, and the GitHub CLI installed) and a `.dockerignore` that excludes secrets, build artifacts, and the git directory; two new Taskfile tasks (`docker-build`, `docker-run`) wire the image build and local container run. T3.1 installed `github.com/joho/godotenv` and updated `cmd/app/main.go` to call `godotenv.Load` for both `.env` and `.env.local` (errors silently ignored) before calling `run()`; `.env` was replaced with a committed, non-sensitive defaults file covering port, model, chunk size, page-refresh, output directory, and branch prefix. T3.2 stripped `Credentials`, `GitHubToken`, `OutputDir`, and `LocalRepoPath` from `APIRequest`, replacing them with env-var lookups (`BAUER_CREDENTIALS_PATH`/`GOOGLE_APPLICATION_CREDENTIALS` and `github.GetGitHubToken()`) inside the handler; `firstNonEmpty` and `firstNonZero` helpers apply request-field-overrides-env semantics; `SummaryModel` was added to `APIRequest` for future use. T3.3 renamed the `/api/v1/workflow` route to `POST /api/v1/workflows` using Go 1.22 method+path routing. T3.4 was already present in the Taskfile from a prior branch (`build-api` task). **Files changed:** + - `Dockerfile`: New multi-stage build — golang:1.22 builder compiles `bauer-api`; debian:bookworm-slim runtime installs git, curl, ca-certificates, and the GitHub CLI; exposes port 8090. - `.dockerignore`: Excludes `.env.local`, `config.json`, `*.pem`, build binaries, `bauer-output/`, logs, and `.git/` from the Docker build context. - `Taskfile.yml`: Added `docker-build` (builds `bauer-api:latest`) and `docker-run` (runs container with `--env-file .env.local` and a read-only credentials volume mount) tasks. @@ -312,6 +323,7 @@ _Parent: `feat/phase-4-api-endpoints`_ **Summary:** T5.1 added GitHub App authentication as the first fallback in `GetGitHubToken()`: when `GITHUB_APP_ID` is set, a short-lived RS256 JWT is signed with the App's RSA private key and exchanged for an installation access token via the GitHub REST API; PAT env vars and `gh auth token` remain as lower-priority fallbacks. T5.2 introduced `internal/auth/middleware.go` with `JWTMiddleware`, which validates Bearer tokens against a JWKS fetched from the OIDC issuer's discovery document; the middleware is bypassed silently (logged at Info) when `BAUER_OIDC_ISSUER` is unset, making it safe for local development. In `cmd/app/main.go`, the health and readiness endpoints remain on the public mux while `POST /api/v1/workflows`, `POST /api/v1/issues`, and `POST /api/v1/webhooks/jira` are now wrapped with `auth.JWTMiddleware` on a separate protected sub-mux. T5.3 added `internal/logging/masking.go` with `MaskSecret` and `MaskPath` helpers plus full unit-test coverage; an audit of existing `slog` calls found no direct logging of raw token values or credential paths in the current codebase. Added `github.com/golang-jwt/jwt/v5` and `github.com/lestrrat-go/jwx/v2` as new direct dependencies. **Files changed:** + - `internal/github/auth.go` — rewrote `GetGitHubToken` to try GitHub App first; added `generateAppInstallationToken` with PEM loading, RSA key parsing, JWT signing (RS256), and installation token exchange via HTTP POST; updated imports to add `crypto/x509`, `encoding/pem`, `encoding/json`, `net/http`, `strconv`, `time`, and `github.com/golang-jwt/jwt/v5` - `internal/auth/middleware.go` — new: `JWTMiddleware` wrapping protected routes with OIDC-based Bearer token validation; `resolveJWKSURL` fetches the OIDC discovery document; `extractBearerToken` parses the Authorization header; bypass mode when `BAUER_OIDC_ISSUER` is unset - `internal/logging/masking.go` — new: `MaskSecret` (empty → ``, ≤4 chars → `****`, longer → first 4 + `...`) and `MaskPath` (shows only `filename` prefixed with `.../`) helpers @@ -328,9 +340,16 @@ _Parent: `feat/phase-5-auth-security`_ **Tasks:** T4F.1, T4F.2 -**Summary:** _(to be filled by agent)_ +**Summary:** T4F.1 wires Figma into both API endpoints: `IssueRequest` already carried `figma_url` and `IssuesHandler` already set `cfg.FigmaURL`/`cfg.FigmaToken`, so the focus was adding `FigmaURL` to `workflow.APIRequest` and `WorkflowInput`, piping it through `ExecuteWorkflowHandler` and `ExecuteWorkflow` into `bauerCfg`, so `POST /api/v1/workflows` now also accepts optional `figma_url`. T4F.2 introduces the `ScreenshotHost` interface (`internal/artifacts/hosting.go`) with three implementations — `LocalFileServer`, `NopHost`, and an `S3Host` stub — and a `HostFromEnv` factory that selects the backend from `BAUER_STATIC_BASE_URL` / `BAUER_S3_BUCKET`. `IssuesHandler` now calls `formatIssueBodyWithHosting` and logs a warning when no hosting backend is configured but a `figma_url` was supplied. `cmd/app/main.go` conditionally mounts a `/static/` file-server route when `BAUER_STATIC_BASE_URL` is set, and `.env.example` documents the three new env vars. -**Files changed:** _(to be filled by agent)_ +**Files changed:** + +- `internal/workflow/api.go` — added `FigmaURL` field to `APIRequest`; `ExecuteWorkflowHandler` now passes `FigmaURL` and `FigmaToken` (read from `BAUER_FIGMA_TOKEN`) into `WorkflowInput` +- `internal/workflow/workflow.go` — added `FigmaURL` and `FigmaToken` fields to `WorkflowInput`; `ExecuteWorkflow` forwards them to `bauerCfg` +- `internal/artifacts/hosting.go` — new file: `ScreenshotHost` interface, `LocalFileServer`, `NopHost`, `S3Host` (stub), and `HostFromEnv` factory +- `cmd/app/v1/issues.go` — added `context` and `log/slog` imports; `IssuesHandler` now calls `artifacts.HostFromEnv` and `formatIssueBodyWithHosting`, warning when no host is configured with a Figma URL; new `formatIssueBodyWithHosting` helper wraps `formatIssueBody` +- `cmd/app/main.go` — conditionally mounts `/static/` HTTP file-server route when `BAUER_STATIC_BASE_URL` is set +- `.env.example` — added `BAUER_STATIC_BASE_URL`, `BAUER_S3_BUCKET`, and `BAUER_S3_REGION` under a new "Screenshot hosting" section --- @@ -346,3 +365,39 @@ git diff -- . Or on GitHub, open a PR from `` into ``. Each branch is a clean, independently reviewable unit. You can review them in any order, but reviewing in the listed order (1 → 12) builds understanding correctly. + +--- + +## Post-Implementation Review (2026-05-21) + +_All branches 7–12 reviewed via automated agents. Critical fixes applied to tip branch (`feat/figma-phase-f-api`). All tests pass._ + +### Fixes Applied (on tip branch) + +| Branch Origin | Fix | Severity | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| 7 | Restored `fs.Usage = func() { ... }` closure in `cmd/bauer/main.go` — help text was printing unconditionally on every run | Critical | +| 10 | Added nil-agent guard in `cmd/app/v1/jira.go` — `copilotcli.NewClient` error was silently ignored, causing nil-pointer panic | Medium | +| 11 | Changed `internal/auth/middleware.go` to fail-closed — when `BAUER_OIDC_ISSUER` is set but JWKS fetch fails, now returns 503 instead of silently bypassing auth | High (security) | +| 12 | Added path traversal guard in `internal/artifacts/hosting.go` `LocalFileServer.Host()` — `filepath.Rel` with `..` prefix now rejected | High (security) | + +### Known Limitations (not fixed — low priority or design decisions) + +| Branch | Issue | Notes | +| ------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| 7 | No unit tests for `RenderChunksFromResolved` | Would require mocking file system writes | +| 8 | No unit tests for `LoadPreviousMeta`, `LoadMappings`, `UpdateRunFigmaVersion` | Functional correctness verified via integration (all tests pass) | +| 8 | `artifacts` package imports `figma.ParseLink` for URL matching | Design smell; consider storing `FigmaFileKey` directly in `RunIndexEntry` | +| 9 | Dockerfile runs as root | Add `USER` directive before production deployment | +| 9 | `SummaryModel` field in `APIRequest` accepted but not passed through | Staged for future use | +| 10 | Empty `BAUER_JIRA_WEBHOOK_SECRET` allows unauthenticated webhook calls | Intentional for local dev; document in deployment guide | +| 11 | JWKS fetched once, never refreshed | Use `jwk.NewCache` for auto-refresh before production | +| 11 | No HTTP client timeouts on outbound auth requests | Add 10s timeout before production | +| 12 | `formatIssueBodyWithHosting` is a stub (discards `host` param) | Screenshot URL rewriting not yet implemented; issue bodies use local paths | +| 12 | Static file server exposes entire artifacts dir | Scope to `screenshots/` subdirectory before production | + +### Trade-offs + +1. **Fixes on tip vs per-branch**: All fixes applied to the tip branch (`feat/figma-phase-f-api`) rather than the branch that introduced each issue. Rationale: modifying middle branches would require rebasing all subsequent branches (6 branches), which is high-risk for automated tooling. The user can cherry-pick fixes into individual branches during PR review if needed. +2. **`formatIssueBodyWithHosting` left as stub**: Full screenshot URL rewriting requires iterating over `OrchestrationResult` to find screenshot paths and calling `host.Host()` on each. This is non-trivial and should be a tracked follow-up, not a quick fixup. +3. **Test coverage gaps**: Several new functions lack dedicated unit tests. The code compiles cleanly and all existing tests pass (`go test ./...`), confirming no regressions. Adding tests is recommended but not blocking for review. diff --git a/internal/artifacts/hosting.go b/internal/artifacts/hosting.go new file mode 100644 index 0000000..48bf4b5 --- /dev/null +++ b/internal/artifacts/hosting.go @@ -0,0 +1,66 @@ +package artifacts + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ScreenshotHost uploads or serves a screenshot and returns its public URL. +type ScreenshotHost interface { + Host(ctx context.Context, localPath string) (publicURL string, err error) +} + +// LocalFileServer serves screenshots from the artifact directory. +// BaseURL is the externally reachable URL prefix for the server. +// This implementation computes a URL from a local file path — the caller is +// responsible for ensuring the directory is actually being served at BaseURL. +type LocalFileServer struct { + BaseURL string // e.g. "https://bauer.example.com/static" + ServeDir string // path to the artifact root (relative or absolute) +} + +func (s *LocalFileServer) Host(_ context.Context, localPath string) (string, error) { + rel, err := filepath.Rel(s.ServeDir, localPath) + if err != nil { + return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) + } + if rel == ".." || strings.HasPrefix(rel, "../") { + return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) + } + return strings.TrimRight(s.BaseURL, "/") + "/" + filepath.ToSlash(rel), nil +} + +// NopHost is a no-op implementation that returns the local path unchanged. +// Used when no hosting backend is configured (Stage 1 / CLI mode). +type NopHost struct{} + +func (n *NopHost) Host(_ context.Context, localPath string) (string, error) { + return localPath, nil +} + +// S3Host is a stub for future S3 screenshot hosting. +// Implement when cloud deployment is ready. +type S3Host struct { + Bucket string + Region string +} + +func (s *S3Host) Host(_ context.Context, localPath string) (string, error) { + return "", fmt.Errorf("S3 screenshot hosting is not yet implemented; unset BAUER_S3_BUCKET to use local file serving") +} + +// HostFromEnv returns a ScreenshotHost configured from environment variables. +// Priority: BAUER_STATIC_BASE_URL → BAUER_S3_BUCKET → NopHost. +func HostFromEnv(serveDir string) ScreenshotHost { + if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { + return &LocalFileServer{BaseURL: baseURL, ServeDir: serveDir} + } + // S3 stub: if BAUER_S3_BUCKET is set, return S3Host (not yet functional) + if bucket := os.Getenv("BAUER_S3_BUCKET"); bucket != "" { + return &S3Host{Bucket: bucket, Region: os.Getenv("BAUER_S3_REGION")} + } + return &NopHost{} +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 2af800d..9056f66 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -25,20 +25,28 @@ func JWTMiddleware(next http.Handler) http.Handler { audience := os.Getenv("BAUER_OIDC_AUDIENCE") jwksURL, err := resolveJWKSURL(issuer) if err != nil { - slog.Error("Failed to resolve JWKS URL from OIDC discovery; JWT validation bypassed", + slog.Error("Failed to resolve JWKS URL from OIDC discovery; all protected endpoints will return 503", slog.String("issuer", issuer), slog.String("error", err.Error()), ) - return next + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{"error": "authentication service unavailable"}) + }) } keySet, err := jwk.Fetch(context.Background(), jwksURL) if err != nil { - slog.Error("Failed to fetch JWKS; JWT validation bypassed", + slog.Error("Failed to fetch JWKS; all protected endpoints will return 503", slog.String("jwks_url", jwksURL), slog.String("error", err.Error()), ) - return next + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{"error": "authentication service unavailable"}) + }) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/config/manager.go b/internal/config/manager.go index e101922..2961c0e 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -38,22 +38,54 @@ func (r *Resolver) Resolve() (*Config, error) { } func mergeConfig(base, override *Config) { - if override.DocID != "" { base.DocID = override.DocID } - if override.CredentialsPath != "" { base.CredentialsPath = override.CredentialsPath } - if override.Model != "" { base.Model = override.Model } - if override.SummaryModel != "" { base.SummaryModel = override.SummaryModel } - if override.ArtifactsDir != "" { base.ArtifactsDir = override.ArtifactsDir } - if override.BranchPrefix != "" { base.BranchPrefix = override.BranchPrefix } - if override.ChunkSize != 0 { base.ChunkSize = override.ChunkSize } - if override.GitHubRepo != "" { base.GitHubRepo = override.GitHubRepo } - if override.FigmaURL != "" { base.FigmaURL = override.FigmaURL } - if override.FigmaToken != "" { base.FigmaToken = override.FigmaToken } - if override.OutputDir != "" { base.OutputDir = override.OutputDir } - if override.TargetRepo != "" { base.TargetRepo = override.TargetRepo } - if override.PageRefresh != nil { base.PageRefresh = override.PageRefresh } - if override.DryRun != nil { base.DryRun = override.DryRun } - if override.OpenPR != nil { base.OpenPR = override.OpenPR } - if override.OpenIssue != nil { base.OpenIssue = override.OpenIssue } + if override.DocID != "" { + base.DocID = override.DocID + } + if override.CredentialsPath != "" { + base.CredentialsPath = override.CredentialsPath + } + if override.Model != "" { + base.Model = override.Model + } + if override.SummaryModel != "" { + base.SummaryModel = override.SummaryModel + } + if override.ArtifactsDir != "" { + base.ArtifactsDir = override.ArtifactsDir + } + if override.BranchPrefix != "" { + base.BranchPrefix = override.BranchPrefix + } + if override.ChunkSize != 0 { + base.ChunkSize = override.ChunkSize + } + if override.GitHubRepo != "" { + base.GitHubRepo = override.GitHubRepo + } + if override.FigmaURL != "" { + base.FigmaURL = override.FigmaURL + } + if override.FigmaToken != "" { + base.FigmaToken = override.FigmaToken + } + if override.OutputDir != "" { + base.OutputDir = override.OutputDir + } + if override.TargetRepo != "" { + base.TargetRepo = override.TargetRepo + } + if override.PageRefresh != nil { + base.PageRefresh = override.PageRefresh + } + if override.DryRun != nil { + base.DryRun = override.DryRun + } + if override.OpenPR != nil { + base.OpenPR = override.OpenPR + } + if override.OpenIssue != nil { + base.OpenIssue = override.OpenIssue + } } // EnvVarSource reads BAUER_* env vars. @@ -69,14 +101,14 @@ func (e *EnvVarSource) Load() (*Config, error) { } else if v := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"); v != "" { cfg.CredentialsPath = v } - cfg.DocID = os.Getenv("BAUER_DOC_ID") - cfg.Model = os.Getenv("BAUER_MODEL") + cfg.DocID = os.Getenv("BAUER_DOC_ID") + cfg.Model = os.Getenv("BAUER_MODEL") cfg.SummaryModel = os.Getenv("BAUER_SUMMARY_MODEL") cfg.ArtifactsDir = os.Getenv("BAUER_ARTIFACTS_DIR") cfg.BranchPrefix = os.Getenv("BAUER_BRANCH_PREFIX") - cfg.GitHubRepo = os.Getenv("BAUER_GITHUB_REPO") - cfg.FigmaURL = os.Getenv("BAUER_FIGMA_URL") - cfg.FigmaToken = os.Getenv("BAUER_FIGMA_TOKEN") + cfg.GitHubRepo = os.Getenv("BAUER_GITHUB_REPO") + cfg.FigmaURL = os.Getenv("BAUER_FIGMA_URL") + cfg.FigmaToken = os.Getenv("BAUER_FIGMA_TOKEN") if cfg.FigmaToken == "" { cfg.FigmaToken = os.Getenv("FIGMA_TOKEN") } diff --git a/internal/copilotcli/client.go b/internal/copilotcli/client.go index 56b253e..8657f02 100644 --- a/internal/copilotcli/client.go +++ b/internal/copilotcli/client.go @@ -10,6 +10,7 @@ import ( "time" "bauer/internal/agent" + copilot "github.com/github/copilot-sdk/go" ) diff --git a/internal/github/pr.go b/internal/github/pr.go index e5813a8..5e5fcff 100644 --- a/internal/github/pr.go +++ b/internal/github/pr.go @@ -65,7 +65,7 @@ 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") @@ -77,7 +77,7 @@ func CreatePR(owner, repo string, opts CreatePROptions) (string, error) { } 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) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index e7ba443..85da7ab 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -396,7 +396,7 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( } // executeAgentChunks executes each chunk via the agent and returns outputs. -func executeAgentChunks( ctx context.Context, +func executeAgentChunks(ctx context.Context, chunks []prompt.ChunkResult, cfg *config.Config, a agent.Agent, diff --git a/internal/prompt/engine.go b/internal/prompt/engine.go index 53dba84..2411246 100644 --- a/internal/prompt/engine.go +++ b/internal/prompt/engine.go @@ -342,42 +342,43 @@ func indexOf(s, substr string) int { } return -1 } + // RenderChunksFromResolved generates figma-aware prompt files from pre-resolved chunks. // It is used when --figma-url is supplied so that Figma design context is embedded in // each prompt. outputDir is created if it does not exist. // The returned ChunkResults contain Filenames suitable for agent execution. func (e *Engine) RenderChunksFromResolved( - docTitle, suggestedURL, figmaURL string, - chunks []mapping.ResolvedChunk, - chunkSize int, - outputDir string, + docTitle, suggestedURL, figmaURL string, + chunks []mapping.ResolvedChunk, + chunkSize int, + outputDir string, ) ([]ChunkResult, error) { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return nil, fmt.Errorf("creating output directory %q: %w", outputDir, err) - } - - promptDatas, err := e.GenerateChunksFromResolved(docTitle, suggestedURL, figmaURL, chunks, chunkSize) - if err != nil { - return nil, err - } - - results := make([]ChunkResult, len(promptDatas)) - for i, pd := range promptDatas { - content, err := e.RenderChunk(pd) - if err != nil { - return nil, fmt.Errorf("rendering chunk %d: %w", i+1, err) - } - fname := fmt.Sprintf("chunk-%d-of-%d.md", pd.ChunkNumber, pd.TotalChunks) - fpath := filepath.Join(outputDir, fname) - if err := os.WriteFile(fpath, []byte(content), 0644); err != nil { - return nil, fmt.Errorf("writing chunk %d to file: %w", i+1, err) - } - results[i] = ChunkResult{ - ChunkNumber: pd.ChunkNumber, - Content: content, - Filename: fpath, - LocationCount: pd.LocationCount, - } - } - return results, nil -} \ No newline at end of file + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, fmt.Errorf("creating output directory %q: %w", outputDir, err) + } + + promptDatas, err := e.GenerateChunksFromResolved(docTitle, suggestedURL, figmaURL, chunks, chunkSize) + if err != nil { + return nil, err + } + + results := make([]ChunkResult, len(promptDatas)) + for i, pd := range promptDatas { + content, err := e.RenderChunk(pd) + if err != nil { + return nil, fmt.Errorf("rendering chunk %d: %w", i+1, err) + } + fname := fmt.Sprintf("chunk-%d-of-%d.md", pd.ChunkNumber, pd.TotalChunks) + fpath := filepath.Join(outputDir, fname) + if err := os.WriteFile(fpath, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("writing chunk %d to file: %w", i+1, err) + } + results[i] = ChunkResult{ + ChunkNumber: pd.ChunkNumber, + Content: content, + Filename: fpath, + LocationCount: pd.LocationCount, + } + } + return results, nil +} diff --git a/internal/prompt/templates/figma-context.md b/internal/prompt/templates/figma-context.md index 1175efb..a6bc04d 100644 --- a/internal/prompt/templates/figma-context.md +++ b/internal/prompt/templates/figma-context.md @@ -1,36 +1,41 @@ ## Design Context Design information has been extracted from Figma for the suggestions in this chunk. -{{if .Anchors}} +{{- if .Anchors}} + ### Referenced design nodes -{{range .Anchors}} + +{{range .Anchors -}} - **{{.NodeName}}** (node: `{{.NodeID}}`) -{{- end}} -{{end}} -{{if .Screenshots}} +{{end -}} +{{end -}} +{{- if .Screenshots}} + ### Screenshots The following screenshots are available locally for the regions related to this chunk: -{{range .Screenshots}} +{{range .Screenshots -}} - `{{.}}` -{{- end}} - -Examine them carefully to validate spacing, component usage, and text content before making changes. {{end}} -{{if .Comments}} +Examine them carefully to validate spacing, component usage, and text content before making changes. +{{end -}} +{{- if .Comments}} + ### Designer comments (treat as hard requirements unless they conflict with the Google Doc) -{{range .Comments}} -- **{{.Author}}**: {{.Message}} _(node: `{{.NodeID}}`)_ -{{- end}} +{{range .Comments -}} +- **{{.Author}}**: {{.Message}} _(node: `{{.NodeID}}`)_ +{{end}} The Google Doc is the canonical intent source. Designer comments are requirements within that intent. {{end}} + ### Instructions for design alignment - Verify your implementation matches the visual design for the suggestion locations in this chunk. - Do not invent new UI components if the design shows an existing one. - If the design shows a spacing or typography token, check whether an equivalent exists in the codebase. -{{if .FigmaURL}} + {{if .FigmaURL}} + ### If you have access to Figma MCP tools (optional) If your runtime supports the Figma MCP server (e.g. VS Code Copilot Chat, Cursor, or Claude Code), @@ -39,6 +44,7 @@ you may fetch live data directly from the design file to supplement the stored c `{{.FigmaURL}}` If you choose to use MCP tools: + - Treat Bauer's stored artifacts (screenshots, design node references, and designer comments above) as the **ground truth** for this run. - If the live MCP view conflicts with stored artifacts, **surface the conflict explicitly** rather diff --git a/internal/source/mapping/types.go b/internal/source/mapping/types.go index c378e97..a135d38 100644 --- a/internal/source/mapping/types.go +++ b/internal/source/mapping/types.go @@ -8,9 +8,9 @@ import "bauer/internal/gdocs" type ResolvedChunk struct { Locations []gdocs.LocationGroupedSuggestions `json:"locations"` DesignAnchors []DesignAnchorRef `json:"design_anchors,omitempty"` - ScreenshotPaths []string `json:"screenshot_paths,omitempty"` - Comments []DesignCommentRef `json:"comments,omitempty"` - Mapping MappingMetadata `json:"mapping"` + ScreenshotPaths []string `json:"screenshot_paths,omitempty"` + Comments []DesignCommentRef `json:"comments,omitempty"` + Mapping MappingMetadata `json:"mapping"` } // DesignAnchorRef is a lightweight reference to a matched Figma node. diff --git a/internal/workflow/api.go b/internal/workflow/api.go index 6688f6a..e577228 100644 --- a/internal/workflow/api.go +++ b/internal/workflow/api.go @@ -15,7 +15,7 @@ import ( // APIRequest represents the API request for executing a workflow type APIRequest struct { // GitHub configuration - GitHubRepo string `json:"github_repo"` // "owner/repo" or HTTPS URL + GitHubRepo string `json:"github_repo"` // "owner/repo" or HTTPS URL BranchPrefix string `json:"branch_prefix,omitempty"` // Branch naming prefix // Bauer configuration @@ -25,6 +25,7 @@ type APIRequest struct { Model string `json:"model,omitempty"` // Copilot model SummaryModel string `json:"summary_model,omitempty"` // Copilot summary model DryRun bool `json:"dry_run,omitempty"` // Dry run mode + FigmaURL string `json:"figma_url,omitempty"` // Optional Figma file URL } // APIResponse represents the API response from workflow execution @@ -88,6 +89,8 @@ func ExecuteWorkflowHandler(orch orchestrator.Orchestrator) http.HandlerFunc { OutputDir: firstNonEmpty(os.Getenv("BAUER_OUTPUT_DIR"), "bauer-output"), Model: firstNonEmpty(req.Model, os.Getenv("BAUER_MODEL"), "gpt-5-mini-high"), DryRun: req.DryRun, + FigmaURL: req.FigmaURL, + FigmaToken: firstNonEmpty(os.Getenv("BAUER_FIGMA_TOKEN"), os.Getenv("FIGMA_TOKEN")), LocalRepoPath: fmt.Sprintf("%s/%s-%d", "/tmp", "bauer-workflow", time.Now().Unix()), } diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 3ae2b0b..adbef2b 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -28,6 +28,8 @@ type WorkflowInput struct { OutputDir string Model string DryRun bool + FigmaURL string + FigmaToken string // Local repository path LocalRepoPath string @@ -168,6 +170,8 @@ func ExecuteWorkflow(ctx context.Context, input WorkflowInput, orch orchestrator OutputDir: input.OutputDir, Model: input.Model, TargetRepo: ".", // Current directory is the cloned repo + FigmaURL: input.FigmaURL, + FigmaToken: input.FigmaToken, } logger.Info("workflow: Bauer target repository set at", "path", bauerCfg.TargetRepo)