From 021988a9459acd777c9cc0b664eaffe21cbf5a5c Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Wed, 20 May 2026 13:10:48 +0300 Subject: [PATCH 1/3] feat(figma-phase-b): Figma REST client, URL parser, normalization layer - T2F.0: verify-figma Taskfile task, .env.example Figma vars - T2F.1: internal/figma/link.go - ParseLink for /file/ and /design/ URLs - T2F.2: FigmaToken/FigmaURL config wiring, FIGMA_TOKEN fallback - T2F.3: internal/figma/client.go - GetMeta, GetNodes, GetComments, GetImages, DownloadImage - T2F.4: internal/figma/normalize.go - Normalize() into NormalizedDesign --- Taskfile.yml | 10 +- docs/implementation-log.md | 19 ++- internal/config/cli.go | 4 + internal/config/config.go | 4 + internal/config/manager.go | 1 + internal/figma/client.go | 145 ++++++++++++++++++++ internal/figma/client_test.go | 162 ++++++++++++++++++++++ internal/figma/link.go | 34 +++++ internal/figma/link_test.go | 58 ++++++++ internal/figma/normalize.go | 97 +++++++++++++ internal/figma/normalize_test.go | 226 +++++++++++++++++++++++++++++++ internal/figma/types.go | 102 ++++++++++++++ internal/source/manager.go | 50 +++++++ internal/source/types.go | 10 +- 14 files changed, 911 insertions(+), 11 deletions(-) create mode 100644 internal/figma/client.go create mode 100644 internal/figma/client_test.go create mode 100644 internal/figma/link.go create mode 100644 internal/figma/link_test.go create mode 100644 internal/figma/normalize.go create mode 100644 internal/figma/normalize_test.go create mode 100644 internal/figma/types.go diff --git a/Taskfile.yml b/Taskfile.yml index eea659f..358b4dd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,21 +39,23 @@ tasks: - golangci-lint run ./... verify-figma: - desc: Verify Figma token and REST API access. Pass FILE_KEY=. + desc: "Verify Figma token and REST API access. Pass FILE_KEY=." cmds: - | TOKEN="${BAUER_FIGMA_TOKEN:-$FIGMA_TOKEN}" if [ -z "$TOKEN" ]; then - echo "ERROR: set BAUER_FIGMA_TOKEN or FIGMA_TOKEN"; exit 1 + echo "ERROR: set BAUER_FIGMA_TOKEN or FIGMA_TOKEN" + exit 1 fi - | if [ -z "{{.FILE_KEY}}" ]; then - echo "ERROR: provide FILE_KEY=your-figma-file-key"; exit 1 + echo "ERROR: provide FILE_KEY=your-figma-file-key" + exit 1 fi - | curl -sf -H "Authorization: Bearer ${BAUER_FIGMA_TOKEN:-$FIGMA_TOKEN}" \ "https://api.figma.com/v1/files/{{.FILE_KEY}}/meta" \ - | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('name','?'), d.get('lastModified','?'))" + | python3 -c "import sys,json; d=json.load(sys.stdin); print('Name:', d.get('name','?'), '| Last modified:', d.get('lastModified','?'))" clean: desc: Clean up generated files diff --git a/docs/implementation-log.md b/docs/implementation-log.md index 6d9132c..539253b 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -24,7 +24,7 @@ Each sub-agent appends its entry to the **Branch Log** section below. You (the r | 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 | ⏳ pending | +| 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 | ⏳ pending | | 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 | @@ -187,9 +187,22 @@ _Parent: `feat/phase-2-cli-features`_ **Tasks:** T2F.0, T2F.1, T2F.2, T2F.3, T2F.4 -**Summary:** _(to be filled by agent)_ +**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:** _(to be filled by agent)_ +**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`) +- `internal/figma/client.go` — new: `Client` with `NewClient`, `NewClientWithHTTP` (for tests), `GetMeta`, `GetNodes`, `GetComments`, `GetImages`, `DownloadImage`; generic `doGet[T]` helper; structured error messages for 401/403/429/404 +- `internal/figma/client_test.go` — new: mock HTTP server tests via `httptest.NewServer` and `prefixTransport`; covers success path, auth failure, 404, rate limit, empty node/image ID short-circuits +- `internal/figma/normalize.go` — new: `Normalize()` converts raw API payloads into `NormalizedDesign`; `extractAnchors` walks the node tree collecting TEXT/INSTANCE children +- `internal/figma/normalize_test.go` — new: covers empty children, no comments/screenshots, resolved/unresolved comments, whole-file vs node-specific fetch, text extraction, component ID extraction, screenshots, meta fields +- `internal/source/types.go` — `Design any` → `Design *figma.NormalizedDesign` +- `internal/source/manager.go` — added `FetchFigma(ctx, client, ref, screenshotDir)` method; added `figma`, `path/filepath`, `strings` imports +- `internal/config/cli.go` — `CLIFlags.FigmaURL` field added; `--figma-url` flag registered; `FigmaURL` mapped in `Config` construction +- `internal/config/manager.go` — `FlagsSource.Load()` maps `FigmaURL` +- `internal/config/config.go` — `Validate()` returns error when `FigmaURL != ""` and `FigmaToken == ""` +- `Taskfile.yml` — `verify-figma` output updated to label `Name:` and `| Last modified:` **External API docs used:** - https://developers.figma.com/docs/rest-api/ diff --git a/internal/config/cli.go b/internal/config/cli.go index 72fc604..a8c9313 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -19,6 +19,7 @@ type CLIFlags struct { TargetRepo string ArtifactsDir string BranchPrefix string + FigmaURL string GitHubRepo string OpenPR *bool OpenIssue *bool @@ -42,6 +43,7 @@ func Load() (*Config, error) { summaryModel := flag.String("summary-model", "gpt-5-mini-high", "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)") artifactsDir := flag.String("artifacts-dir", "", "Directory for run artifacts (default: ./bauer-artifacts)") + figmaURL := flag.String("figma-url", "", "Figma file or design URL for design reference (optional)") // Custom usage message flag.Usage = func() { @@ -62,6 +64,7 @@ func Load() (*Config, error) { {"--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)"}, {"--artifacts-dir", "", "Directory for run artifacts (default: ./bauer-artifacts)"}, + {"--figma-url", "", "Figma file or design URL for design reference (optional)"}, {"--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)"}, {"--target-repo", "", "Path to target repository where tasks should be executed (default: current directory)"}, @@ -97,6 +100,7 @@ func Load() (*Config, error) { SummaryModel: *summaryModel, TargetRepo: *targetRepo, ArtifactsDir: *artifactsDir, + FigmaURL: *figmaURL, } if err := cfg.Validate(); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 9c8ec4d..85942d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,6 +105,10 @@ func (c *Config) Validate() error { return errors.New("chunk_size must be greater than 0") } + if c.FigmaURL != "" && c.FigmaToken == "" { + return errors.New("BAUER_FIGMA_TOKEN or FIGMA_TOKEN must be set when --figma-url is supplied") + } + return ValidateCredentialsPath(c.CredentialsPath) } diff --git a/internal/config/manager.go b/internal/config/manager.go index 5798d0c..e101922 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -138,6 +138,7 @@ func (f *FlagsSource) Load() (*Config, error) { TargetRepo: f.flags.TargetRepo, ArtifactsDir: f.flags.ArtifactsDir, BranchPrefix: f.flags.BranchPrefix, + FigmaURL: f.flags.FigmaURL, GitHubRepo: f.flags.GitHubRepo, OpenPR: f.flags.OpenPR, OpenIssue: f.flags.OpenIssue, diff --git a/internal/figma/client.go b/internal/figma/client.go new file mode 100644 index 0000000..360fbd0 --- /dev/null +++ b/internal/figma/client.go @@ -0,0 +1,145 @@ +package figma + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const baseURL = "https://api.figma.com/v1" + +// Client is the Figma REST API client. +// It never logs the token. +type Client struct { + token string + http *http.Client +} + +// NewClient creates a new Figma client with the given personal access token. +func NewClient(token string) *Client { + return &Client{ + token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// NewClientWithHTTP creates a Client with a custom HTTP client. Use for testing only. +func NewClientWithHTTP(token string, httpClient *http.Client) *Client { + return &Client{token: token, http: httpClient} +} + +// GetMeta fetches file name, last-modified date, and version. +// Docs: https://developers.figma.com/docs/rest-api/file-endpoints/ +func (c *Client) GetMeta(ctx context.Context, fileKey string) (*FileMeta, error) { + return doGet[FileMeta](ctx, c, fmt.Sprintf("%s/files/%s/meta", baseURL, fileKey)) +} + +// GetNodes fetches specific nodes (frames, layers) and their children. +// If nodeIDs is empty, returns nothing useful — callers should always provide at least one ID. +// Docs: https://developers.figma.com/docs/rest-api/file-endpoints/ +func (c *Client) GetNodes(ctx context.Context, fileKey string, nodeIDs []string) (*NodesResponse, error) { + if len(nodeIDs) == 0 { + return &NodesResponse{}, nil + } + ids := url.QueryEscape(strings.Join(nodeIDs, ",")) + return doGet[NodesResponse](ctx, c, + fmt.Sprintf("%s/files/%s/nodes?ids=%s", baseURL, fileKey, ids)) +} + +// GetComments fetches all comments from the file, with text in markdown format. +// Docs: https://developers.figma.com/docs/rest-api/comments-endpoints/ +func (c *Client) GetComments(ctx context.Context, fileKey string) (*CommentsResponse, error) { + return doGet[CommentsResponse](ctx, c, + fmt.Sprintf("%s/files/%s/comments?as_md=true", baseURL, fileKey)) +} + +// GetImages requests rendered screenshot URLs for the given node IDs at 2x scale. +// Returns a map of nodeID → pre-signed URL. URLs expire quickly; download immediately. +// Docs: https://developers.figma.com/docs/rest-api/file-endpoints/#get-images-endpoint +func (c *Client) GetImages(ctx context.Context, fileKey string, nodeIDs []string) (map[string]string, error) { + if len(nodeIDs) == 0 { + return map[string]string{}, nil + } + ids := url.QueryEscape(strings.Join(nodeIDs, ",")) + resp, err := doGet[imagesResponse](ctx, c, + fmt.Sprintf("%s/images/%s?ids=%s&format=png&scale=2", baseURL, fileKey, ids)) + if err != nil { + return nil, err + } + if resp.Images == nil { + return map[string]string{}, nil + } + return resp.Images, nil +} + +// DownloadImage downloads a pre-signed image URL (no auth needed) to destPath. +// The file is created with 0o644 permissions. +func (c *Client) DownloadImage(ctx context.Context, presignedURL, destPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignedURL, nil) + if err != nil { + return fmt.Errorf("creating download request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("downloading image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("image download failed: status %d", resp.StatusCode) + } + + f, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("creating image file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + return fmt.Errorf("writing image: %w", err) + } + return nil +} + +// doGet performs a GET request to the Figma API endpoint and decodes the JSON response. +// It returns a clear error for non-200 responses. The token is never logged. +func doGet[T any](ctx context.Context, c *Client, endpoint string) (*T, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("figma API request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // success path; continue + case http.StatusUnauthorized, http.StatusForbidden: + return nil, fmt.Errorf("figma API authentication failed (status %d): check BAUER_FIGMA_TOKEN", resp.StatusCode) + case http.StatusTooManyRequests: + return nil, fmt.Errorf("figma API rate limit exceeded (status 429): retry after a delay") + case http.StatusNotFound: + return nil, fmt.Errorf("figma resource not found (status 404): check the file key and node ID") + default: + return nil, fmt.Errorf("figma API error: status %d for %s", resp.StatusCode, endpoint) + } + + var result T + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding figma API response: %w", err) + } + return &result, nil +} diff --git a/internal/figma/client_test.go b/internal/figma/client_test.go new file mode 100644 index 0000000..fe4afce --- /dev/null +++ b/internal/figma/client_test.go @@ -0,0 +1,162 @@ +package figma_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "bauer/internal/figma" +) + +func TestGetMeta_Success(t *testing.T) { + meta := figma.FileMeta{ + Name: "My Design", + LastModified: "2024-01-15T10:00:00Z", + Version: "42", + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(meta) + })) + defer srv.Close() + + // Override baseURL by using a client pointed at the test server via transport. + transport := &prefixTransport{base: srv.URL} + client := figma.NewClientWithHTTP("test-token", &http.Client{Transport: transport}) + + got, err := client.GetMeta(context.Background(), "fileKey123") + if err != nil { + t.Fatalf("GetMeta error: %v", err) + } + if got.Name != meta.Name { + t.Errorf("Name = %q, want %q", got.Name, meta.Name) + } + if got.Version != meta.Version { + t.Errorf("Version = %q, want %q", got.Version, meta.Version) + } +} + +func TestGetMeta_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + transport := &prefixTransport{base: srv.URL} + client := figma.NewClientWithHTTP("bad-token", &http.Client{Transport: transport}) + + _, err := client.GetMeta(context.Background(), "fileKey123") + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} + +func TestGetMeta_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + transport := &prefixTransport{base: srv.URL} + client := figma.NewClientWithHTTP("test-token", &http.Client{Transport: transport}) + + _, err := client.GetMeta(context.Background(), "missingKey") + if err == nil { + t.Fatal("expected error for 404, got nil") + } +} + +func TestGetMeta_RateLimited(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + transport := &prefixTransport{base: srv.URL} + client := figma.NewClientWithHTTP("test-token", &http.Client{Transport: transport}) + + _, err := client.GetMeta(context.Background(), "fileKey") + if err == nil { + t.Fatal("expected error for 429, got nil") + } +} + +func TestGetComments_Success(t *testing.T) { + resolved := "2024-01-16T12:00:00Z" + resp := figma.CommentsResponse{ + Comments: []figma.Comment{ + { + ID: "c1", + Message: "Look at this node", + ClientMeta: figma.CommentClientMeta{NodeID: "1:42"}, + CreatedAt: "2024-01-15T10:00:00Z", + User: figma.CommentUser{Handle: "alice"}, + }, + { + ID: "c2", + Message: "Resolved comment", + CreatedAt: "2024-01-15T11:00:00Z", + User: figma.CommentUser{Handle: "bob"}, + ResolvedAt: &resolved, + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + transport := &prefixTransport{base: srv.URL} + client := figma.NewClientWithHTTP("test-token", &http.Client{Transport: transport}) + + got, err := client.GetComments(context.Background(), "fileKey123") + if err != nil { + t.Fatalf("GetComments error: %v", err) + } + if len(got.Comments) != 2 { + t.Errorf("got %d comments, want 2", len(got.Comments)) + } +} + +func TestGetNodes_EmptyIDs(t *testing.T) { + // When nodeIDs is empty, should return empty response without making an HTTP call. + client := figma.NewClientWithHTTP("test-token", &http.Client{}) + got, err := client.GetNodes(context.Background(), "fileKey", []string{}) + if err != nil { + t.Fatalf("GetNodes error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil response") + } +} + +func TestGetImages_EmptyIDs(t *testing.T) { + client := figma.NewClientWithHTTP("test-token", &http.Client{}) + got, err := client.GetImages(context.Background(), "fileKey", []string{}) + if err != nil { + t.Fatalf("GetImages error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty map, got %v", got) + } +} + +// prefixTransport rewrites requests so they go to the test server instead of api.figma.com. +type prefixTransport struct { + base string +} + +func (t *prefixTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Rewrite the host to point to the test server. + req2 := req.Clone(req.Context()) + req2.URL.Scheme = "http" + req2.URL.Host = t.base[len("http://"):] + return http.DefaultTransport.RoundTrip(req2) +} diff --git a/internal/figma/link.go b/internal/figma/link.go new file mode 100644 index 0000000..d5c15fc --- /dev/null +++ b/internal/figma/link.go @@ -0,0 +1,34 @@ +package figma + +import ( + "fmt" + "net/url" + "regexp" +) + +var figmaFilePattern = regexp.MustCompile(`figma\.com/(?:file|design)/([A-Za-z0-9_-]+)`) + +// LinkRef holds the parsed result of a Figma URL. +type LinkRef struct { + FileKey string // opaque file key from the URL path + NodeID string // URL-decoded node ID, e.g. "1:42". Empty for whole-file links. + RawURL string +} + +// ParseLink extracts the file key and optional node ID from a Figma link. +// Accepts /file/ and /design/ URL patterns. +// Returns a clear error for non-Figma URLs. +func ParseLink(rawURL string) (*LinkRef, error) { + matches := figmaFilePattern.FindStringSubmatch(rawURL) + if len(matches) < 2 { + return nil, fmt.Errorf("not a valid Figma link: %q (expected figma.com/file/... or figma.com/design/...)", rawURL) + } + ref := &LinkRef{FileKey: matches[1], RawURL: rawURL} + + u, err := url.Parse(rawURL) + if err == nil { + // url.Query().Get() URL-decodes automatically: "1%3A42" → "1:42" + ref.NodeID = u.Query().Get("node-id") + } + return ref, nil +} diff --git a/internal/figma/link_test.go b/internal/figma/link_test.go new file mode 100644 index 0000000..e5ff274 --- /dev/null +++ b/internal/figma/link_test.go @@ -0,0 +1,58 @@ +package figma_test + +import ( + "testing" + + "bauer/internal/figma" +) + +func TestParseLink(t *testing.T) { + tests := []struct { + name string + input string + fileKey string + nodeID string + wantErr bool + }{ + { + name: "file URL without node", + input: "https://www.figma.com/file/bwqWjuxIJiwDetRL2fYwNN/Product-Name", + fileKey: "bwqWjuxIJiwDetRL2fYwNN", + nodeID: "", + }, + { + name: "file URL with node", + input: "https://www.figma.com/file/bwqWjuxIJiwDetRL2fYwNN/Product-Name?node-id=1%3A42", + fileKey: "bwqWjuxIJiwDetRL2fYwNN", + nodeID: "1:42", + }, + { + name: "design URL with node", + input: "https://www.figma.com/design/bwqWjuxIJiwDetRL2fYwNN/Product?node-id=6039-4970", + fileKey: "bwqWjuxIJiwDetRL2fYwNN", + nodeID: "6039-4970", + }, + { + name: "not a figma URL", + input: "https://www.example.com/something", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref, err := figma.ParseLink(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseLink(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if err != nil { + return + } + if ref.FileKey != tt.fileKey { + t.Errorf("FileKey = %q, want %q", ref.FileKey, tt.fileKey) + } + if ref.NodeID != tt.nodeID { + t.Errorf("NodeID = %q, want %q", ref.NodeID, tt.nodeID) + } + }) + } +} diff --git a/internal/figma/normalize.go b/internal/figma/normalize.go new file mode 100644 index 0000000..73326c2 --- /dev/null +++ b/internal/figma/normalize.go @@ -0,0 +1,97 @@ +package figma + +import "time" + +// Normalize converts raw Figma API responses into a NormalizedDesign. +// +// requestedNodeID is the node ID from the user's LinkRef (empty for whole-file). +// screenshotPaths maps nodeID → local file path after download. +// +// All comments are included — including resolved ones. Resolved status is indicated +// by the Resolved field. Filtering resolved comments from prompt context happens +// in the mapping layer, not here. +func Normalize( + fileKey string, + requestedNodeID string, + meta *FileMeta, + nodes *NodesResponse, + comments *CommentsResponse, + screenshotPaths map[string]string, +) *NormalizedDesign { + design := &NormalizedDesign{ + FileKey: fileKey, + RootNodeID: requestedNodeID, + Version: meta.Version, + LastModified: meta.LastModified, + } + + // Normalize nodes into anchors. + // IMPORTANT: if a specific node was requested, look it up by ID. + // Do NOT range over the map for the primary lookup — Go map iteration is + // randomized, which would cause nondeterministic results when multiple nodes + // are returned. + if requestedNodeID != "" { + if entry, ok := nodes.Nodes[requestedNodeID]; ok { + design.Anchors = extractAnchors(requestedNodeID, &entry.Document, nil) + } + } else { + // Whole-file fetch: collect anchors from every node returned. + // Use a sorted iteration order if stability matters for tests. + for nodeID, entry := range nodes.Nodes { + design.Anchors = append(design.Anchors, extractAnchors(nodeID, &entry.Document, nil)...) + } + } + + // Normalize comments — include all (resolved and unresolved). + if comments != nil { + for _, c := range comments.Comments { + design.Comments = append(design.Comments, DesignComment{ + ID: c.ID, + NodeID: c.ClientMeta.NodeID, + Message: c.Message, + Author: c.User.Handle, + CreatedAt: c.CreatedAt, + Resolved: c.ResolvedAt != nil, + }) + } + } + + // Map screenshot paths to ScreenshotArtifact records. + now := time.Now().UTC().Format(time.RFC3339) + for nodeID, localPath := range screenshotPaths { + design.Screenshots = append(design.Screenshots, ScreenshotArtifact{ + NodeID: nodeID, + LocalPath: localPath, + Scale: 2, + FetchedAt: now, + }) + } + + return design +} + +// extractAnchors recursively extracts DesignAnchor values from a document node subtree. +// path is the breadcrumb from the root to the current node (used for NodePath). +func extractAnchors(nodeID string, doc *DocumentNode, path []string) []DesignAnchor { + currentPath := append(append([]string{}, path...), doc.Name) + anchor := DesignAnchor{ + NodeID: nodeID, + NodeName: doc.Name, + NodePath: currentPath, + } + + for _, child := range doc.Children { + switch child.Type { + case "TEXT": + if child.Characters != "" { + anchor.NearestText = append(anchor.NearestText, child.Characters) + } + case "INSTANCE": + if child.ComponentID != "" { + anchor.ComponentIDs = append(anchor.ComponentIDs, child.ComponentID) + } + } + } + + return []DesignAnchor{anchor} +} diff --git a/internal/figma/normalize_test.go b/internal/figma/normalize_test.go new file mode 100644 index 0000000..c0ad1a8 --- /dev/null +++ b/internal/figma/normalize_test.go @@ -0,0 +1,226 @@ +package figma_test + +import ( + "testing" + + "bauer/internal/figma" +) + +func ptr(s string) *string { return &s } + +func makeMeta() *figma.FileMeta { + return &figma.FileMeta{ + Name: "Test File", + LastModified: "2024-01-15T10:00:00Z", + Version: "99", + } +} + +func TestNormalize_EmptyChildren(t *testing.T) { + nodes := &figma.NodesResponse{ + Nodes: map[string]figma.NodeEntry{ + "1:1": { + Document: figma.DocumentNode{ + ID: "1:1", + Name: "Frame", + Type: "FRAME", + }, + }, + }, + } + design := figma.Normalize("fileKey", "1:1", makeMeta(), nodes, nil, nil) + + if len(design.Anchors) != 1 { + t.Fatalf("expected 1 anchor, got %d", len(design.Anchors)) + } + if design.Anchors[0].NearestText != nil { + t.Errorf("expected nil NearestText, got %v", design.Anchors[0].NearestText) + } + if design.Anchors[0].ComponentIDs != nil { + t.Errorf("expected nil ComponentIDs, got %v", design.Anchors[0].ComponentIDs) + } +} + +func TestNormalize_NoCommentsNoScreenshots(t *testing.T) { + nodes := &figma.NodesResponse{Nodes: map[string]figma.NodeEntry{}} + design := figma.Normalize("fileKey", "", makeMeta(), nodes, nil, nil) + + if len(design.Comments) != 0 { + t.Errorf("expected 0 comments, got %d", len(design.Comments)) + } + if len(design.Screenshots) != 0 { + t.Errorf("expected 0 screenshots, got %d", len(design.Screenshots)) + } +} + +func TestNormalize_ResolvedComments(t *testing.T) { + resolvedAt := "2024-01-16T12:00:00Z" + comments := &figma.CommentsResponse{ + Comments: []figma.Comment{ + { + ID: "c1", + Message: "unresolved", + User: figma.CommentUser{Handle: "alice"}, + CreatedAt: "2024-01-15T10:00:00Z", + ResolvedAt: nil, + }, + { + ID: "c2", + Message: "resolved", + User: figma.CommentUser{Handle: "bob"}, + CreatedAt: "2024-01-15T11:00:00Z", + ResolvedAt: &resolvedAt, + }, + }, + } + nodes := &figma.NodesResponse{Nodes: map[string]figma.NodeEntry{}} + design := figma.Normalize("fileKey", "", makeMeta(), nodes, comments, nil) + + if len(design.Comments) != 2 { + t.Fatalf("expected 2 comments, got %d", len(design.Comments)) + } + + // Find by ID + byID := map[string]figma.DesignComment{} + for _, c := range design.Comments { + byID[c.ID] = c + } + + if byID["c1"].Resolved { + t.Errorf("c1 should not be resolved") + } + if !byID["c2"].Resolved { + t.Errorf("c2 should be resolved") + } +} + +func TestNormalize_WholeFileFetch(t *testing.T) { + nodes := &figma.NodesResponse{ + Nodes: map[string]figma.NodeEntry{ + "1:1": {Document: figma.DocumentNode{ID: "1:1", Name: "Frame A", Type: "FRAME"}}, + "2:2": {Document: figma.DocumentNode{ID: "2:2", Name: "Frame B", Type: "FRAME"}}, + }, + } + // Whole-file fetch: requestedNodeID == "" + design := figma.Normalize("fileKey", "", makeMeta(), nodes, nil, nil) + + if len(design.Anchors) != 2 { + t.Errorf("expected 2 anchors for whole-file fetch, got %d", len(design.Anchors)) + } + if design.RootNodeID != "" { + t.Errorf("RootNodeID should be empty for whole-file fetch, got %q", design.RootNodeID) + } +} + +func TestNormalize_NodeSpecificFetch(t *testing.T) { + nodes := &figma.NodesResponse{ + Nodes: map[string]figma.NodeEntry{ + "1:42": {Document: figma.DocumentNode{ID: "1:42", Name: "My Frame", Type: "FRAME"}}, + "2:99": {Document: figma.DocumentNode{ID: "2:99", Name: "Other Frame", Type: "FRAME"}}, + }, + } + design := figma.Normalize("fileKey", "1:42", makeMeta(), nodes, nil, nil) + + if len(design.Anchors) != 1 { + t.Fatalf("expected 1 anchor for node-specific fetch, got %d", len(design.Anchors)) + } + if design.Anchors[0].NodeID != "1:42" { + t.Errorf("NodeID = %q, want %q", design.Anchors[0].NodeID, "1:42") + } + if design.RootNodeID != "1:42" { + t.Errorf("RootNodeID = %q, want %q", design.RootNodeID, "1:42") + } +} + +func TestNormalize_TextExtraction(t *testing.T) { + nodes := &figma.NodesResponse{ + Nodes: map[string]figma.NodeEntry{ + "1:1": { + Document: figma.DocumentNode{ + ID: "1:1", + Name: "Frame", + Type: "FRAME", + Children: []figma.DocumentNode{ + {ID: "1:2", Name: "Title", Type: "TEXT", Characters: "Hello World"}, + {ID: "1:3", Name: "Subtitle", Type: "TEXT", Characters: "Sub text"}, + {ID: "1:4", Name: "Empty", Type: "TEXT", Characters: ""}, + }, + }, + }, + }, + } + design := figma.Normalize("fileKey", "1:1", makeMeta(), nodes, nil, nil) + + if len(design.Anchors) != 1 { + t.Fatalf("expected 1 anchor, got %d", len(design.Anchors)) + } + anchor := design.Anchors[0] + if len(anchor.NearestText) != 2 { + t.Errorf("expected 2 NearestText entries (empty excluded), got %d: %v", len(anchor.NearestText), anchor.NearestText) + } + if anchor.NearestText[0] != "Hello World" { + t.Errorf("NearestText[0] = %q, want %q", anchor.NearestText[0], "Hello World") + } +} + +func TestNormalize_ComponentIDExtraction(t *testing.T) { + nodes := &figma.NodesResponse{ + Nodes: map[string]figma.NodeEntry{ + "1:1": { + Document: figma.DocumentNode{ + ID: "1:1", + Name: "Frame", + Type: "FRAME", + Children: []figma.DocumentNode{ + {ID: "1:2", Name: "Button", Type: "INSTANCE", ComponentID: "comp-abc"}, + {ID: "1:3", Name: "Icon", Type: "INSTANCE", ComponentID: "comp-xyz"}, + {ID: "1:4", Name: "NoComp", Type: "INSTANCE", ComponentID: ""}, + }, + }, + }, + }, + } + design := figma.Normalize("fileKey", "1:1", makeMeta(), nodes, nil, nil) + + anchor := design.Anchors[0] + if len(anchor.ComponentIDs) != 2 { + t.Errorf("expected 2 ComponentIDs (empty excluded), got %d: %v", len(anchor.ComponentIDs), anchor.ComponentIDs) + } +} + +func TestNormalize_Screenshots(t *testing.T) { + nodes := &figma.NodesResponse{Nodes: map[string]figma.NodeEntry{}} + screenshotPaths := map[string]string{ + "1:42": "/tmp/shot-node-1-42.png", + } + design := figma.Normalize("fileKey", "", makeMeta(), nodes, nil, screenshotPaths) + + if len(design.Screenshots) != 1 { + t.Fatalf("expected 1 screenshot, got %d", len(design.Screenshots)) + } + shot := design.Screenshots[0] + if shot.NodeID != "1:42" { + t.Errorf("NodeID = %q, want %q", shot.NodeID, "1:42") + } + if shot.LocalPath != "/tmp/shot-node-1-42.png" { + t.Errorf("LocalPath = %q, want %q", shot.LocalPath, "/tmp/shot-node-1-42.png") + } + if shot.Scale != 2 { + t.Errorf("Scale = %d, want 2", shot.Scale) + } +} + +func TestNormalize_MetaFields(t *testing.T) { + nodes := &figma.NodesResponse{Nodes: map[string]figma.NodeEntry{}} + design := figma.Normalize("myFileKey", "3:7", makeMeta(), nodes, nil, nil) + + if design.FileKey != "myFileKey" { + t.Errorf("FileKey = %q, want %q", design.FileKey, "myFileKey") + } + if design.Version != "99" { + t.Errorf("Version = %q, want %q", design.Version, "99") + } + if design.LastModified != "2024-01-15T10:00:00Z" { + t.Errorf("LastModified = %q, want %q", design.LastModified, "2024-01-15T10:00:00Z") + } +} diff --git a/internal/figma/types.go b/internal/figma/types.go new file mode 100644 index 0000000..b7bdf24 --- /dev/null +++ b/internal/figma/types.go @@ -0,0 +1,102 @@ +package figma + +// FileMeta is the raw response from GET /v1/files/:key/meta +type FileMeta struct { + Name string `json:"name"` + LastModified string `json:"lastModified"` + Version string `json:"version"` +} + +// DocumentNode represents a Figma document node (frame, layer, text, etc.) +type DocumentNode struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Characters string `json:"characters,omitempty"` // TEXT nodes only + ComponentID string `json:"componentId,omitempty"` // INSTANCE nodes only + Children []DocumentNode `json:"children,omitempty"` +} + +// NodeEntry is a single node entry in NodesResponse.Nodes map +type NodeEntry struct { + Document DocumentNode `json:"document"` +} + +// NodesResponse is the raw response from GET /v1/files/:key/nodes +type NodesResponse struct { + Name string `json:"name"` + LastModified string `json:"lastModified"` + Nodes map[string]NodeEntry `json:"nodes"` +} + +// CommentUser is the author of a comment +type CommentUser struct { + Handle string `json:"handle"` + Name string `json:"name"` +} + +// CommentClientMeta holds positional metadata for a comment +type CommentClientMeta struct { + NodeID string `json:"node_id,omitempty"` +} + +// Comment is a single Figma comment +type Comment struct { + ID string `json:"id"` + Message string `json:"message"` + ClientMeta CommentClientMeta `json:"client_meta"` + CreatedAt string `json:"created_at"` + User CommentUser `json:"user"` + ParentID string `json:"parent_id,omitempty"` + ResolvedAt *string `json:"resolved_at,omitempty"` // nil if not resolved +} + +// CommentsResponse is the raw response from GET /v1/files/:key/comments +type CommentsResponse struct { + Comments []Comment `json:"comments"` +} + +// imagesResponse is the raw response from GET /v1/images/:key +type imagesResponse struct { + Err interface{} `json:"err"` + Images map[string]string `json:"images"` +} + +// NormalizedDesign is the Bauer-owned representation of a fetched Figma design. +// This is the canonical type all downstream code uses — never the raw API types. +type NormalizedDesign struct { + FileKey string `json:"file_key"` + RootNodeID string `json:"root_node_id"` // from the LinkRef.NodeID + Version string `json:"version"` + LastModified string `json:"last_modified"` + Anchors []DesignAnchor `json:"anchors"` + Comments []DesignComment `json:"comments"` + Screenshots []ScreenshotArtifact `json:"screenshots"` +} + +// DesignAnchor represents a Figma node used as a design reference point. +type DesignAnchor struct { + NodeID string `json:"node_id"` + NodeName string `json:"node_name"` + NodePath []string `json:"node_path,omitempty"` + NearestText []string `json:"nearest_text,omitempty"` + ComponentIDs []string `json:"component_ids,omitempty"` +} + +// DesignComment is a normalized Figma comment. +type DesignComment struct { + ID string `json:"id"` + NodeID string `json:"node_id,omitempty"` + Message string `json:"message"` + Author string `json:"author"` + CreatedAt string `json:"created_at"` + Resolved bool `json:"resolved"` +} + +// ScreenshotArtifact records a downloaded screenshot. +type ScreenshotArtifact struct { + NodeID string `json:"node_id"` + LocalPath string `json:"local_path"` + Scale int `json:"scale"` + FetchedAt string `json:"fetched_at"` +} diff --git a/internal/source/manager.go b/internal/source/manager.go index db85662..5e9a005 100644 --- a/internal/source/manager.go +++ b/internal/source/manager.go @@ -3,7 +3,10 @@ package source import ( "context" "fmt" + "path/filepath" + "strings" + "bauer/internal/figma" "bauer/internal/gdocs" ) @@ -36,3 +39,50 @@ func (m *Manager) Fetch(ctx context.Context, req Request) (*SourceBundle, error) return bundle, nil } + +// FetchFigma fetches and normalizes Figma design data. +// It downloads screenshots to screenshotDir. +func (m *Manager) FetchFigma(ctx context.Context, client *figma.Client, ref *figma.LinkRef, screenshotDir string) (*figma.NormalizedDesign, error) { + meta, err := client.GetMeta(ctx, ref.FileKey) + if err != nil { + return nil, fmt.Errorf("fetching figma metadata: %w", err) + } + + nodeIDs := []string{} + if ref.NodeID != "" { + nodeIDs = []string{ref.NodeID} + } + nodes, err := client.GetNodes(ctx, ref.FileKey, nodeIDs) + if err != nil { + return nil, fmt.Errorf("fetching figma nodes: %w", err) + } + + comments, err := client.GetComments(ctx, ref.FileKey) + if err != nil { + return nil, fmt.Errorf("fetching figma comments: %w", err) + } + + // Request screenshots for the specified node(s) + screenshotPaths := map[string]string{} + if len(nodeIDs) > 0 { + imageURLs, err := client.GetImages(ctx, ref.FileKey, nodeIDs) + if err != nil { + // Non-fatal: log and continue without screenshots + fmt.Printf("warning: could not fetch figma screenshots: %v\n", err) + } else { + for nodeID, imgURL := range imageURLs { + if imgURL == "" { + continue + } + destPath := filepath.Join(screenshotDir, fmt.Sprintf("shot-node-%s.png", strings.ReplaceAll(nodeID, ":", "-"))) + if err := client.DownloadImage(ctx, imgURL, destPath); err != nil { + fmt.Printf("warning: could not download screenshot for node %s: %v\n", nodeID, err) + continue + } + screenshotPaths[nodeID] = destPath + } + } + } + + return figma.Normalize(ref.FileKey, ref.NodeID, meta, nodes, comments, screenshotPaths), nil +} diff --git a/internal/source/types.go b/internal/source/types.go index 633b371..bf30fcd 100644 --- a/internal/source/types.go +++ b/internal/source/types.go @@ -1,6 +1,9 @@ package source -import "bauer/internal/gdocs" +import ( + "bauer/internal/figma" + "bauer/internal/gdocs" +) // SourceBundle is the normalized combined output from all source adapters. // It is what the orchestrator operates on — not raw gdocs or figma types directly. @@ -8,7 +11,6 @@ type SourceBundle struct { // Document is the Google Docs extraction result. Always present when a DocID is set. Document *gdocs.ProcessingResult `json:"document,omitempty"` // Design holds the optional Figma normalized design output. - // It is nil when no --figma-url was supplied. Will be *figma.NormalizedDesign - // once the figma package lands in a later branch. - Design any `json:"design,omitempty"` + // It is nil when no --figma-url was supplied. + Design *figma.NormalizedDesign `json:"design,omitempty"` } From f409c284cfe0d233d5947a46ba3f834e269e61bb Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Tue, 26 May 2026 11:14:33 +0300 Subject: [PATCH 2/3] fix: address PR review comments (figma URL parsing, anchor extraction) --- cmd/bauer/main.go | 2 ++ internal/figma/client.go | 2 +- internal/figma/link.go | 12 +++++++----- internal/figma/link_test.go | 5 +++++ internal/figma/normalize.go | 4 ++-- internal/source/manager.go | 13 ++++++++++--- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/cmd/bauer/main.go b/cmd/bauer/main.go index 46d83fa..062ecd0 100644 --- a/cmd/bauer/main.go +++ b/cmd/bauer/main.go @@ -31,6 +31,7 @@ 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 (optional)") fs.Usage = func() { fmt.Fprintf(os.Stderr, "Usage:\n\n") @@ -71,6 +72,7 @@ func main() { ArtifactsDir: *artifactsDir, BranchPrefix: *branchPrefix, GitHubRepo: *githubRepo, + FigmaURL: *figmaURL, } fs.Visit(func(f *flag.Flag) { switch f.Name { diff --git a/internal/figma/client.go b/internal/figma/client.go index 360fbd0..ebf3b6c 100644 --- a/internal/figma/client.go +++ b/internal/figma/client.go @@ -79,7 +79,7 @@ func (c *Client) GetImages(ctx context.Context, fileKey string, nodeIDs []string } // DownloadImage downloads a pre-signed image URL (no auth needed) to destPath. -// The file is created with 0o644 permissions. +// The file is created with default permissions (0666 & ~umask). func (c *Client) DownloadImage(ctx context.Context, presignedURL, destPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignedURL, nil) if err != nil { diff --git a/internal/figma/link.go b/internal/figma/link.go index d5c15fc..129151a 100644 --- a/internal/figma/link.go +++ b/internal/figma/link.go @@ -19,16 +19,18 @@ type LinkRef struct { // Accepts /file/ and /design/ URL patterns. // Returns a clear error for non-Figma URLs. func ParseLink(rawURL string) (*LinkRef, error) { + u, err := url.Parse(rawURL) + if err != nil || (u.Host != "www.figma.com" && u.Host != "figma.com") { + return nil, fmt.Errorf("not a valid Figma link: %q (expected figma.com/file/... or figma.com/design/...)", rawURL) + } + matches := figmaFilePattern.FindStringSubmatch(rawURL) if len(matches) < 2 { return nil, fmt.Errorf("not a valid Figma link: %q (expected figma.com/file/... or figma.com/design/...)", rawURL) } ref := &LinkRef{FileKey: matches[1], RawURL: rawURL} - u, err := url.Parse(rawURL) - if err == nil { - // url.Query().Get() URL-decodes automatically: "1%3A42" → "1:42" - ref.NodeID = u.Query().Get("node-id") - } + // url.Query().Get() URL-decodes automatically: "1%3A42" → "1:42" + ref.NodeID = u.Query().Get("node-id") return ref, nil } diff --git a/internal/figma/link_test.go b/internal/figma/link_test.go index e5ff274..9f8e92d 100644 --- a/internal/figma/link_test.go +++ b/internal/figma/link_test.go @@ -37,6 +37,11 @@ func TestParseLink(t *testing.T) { input: "https://www.example.com/something", wantErr: true, }, + { + name: "figma substring in non-figma host", + input: "https://evil.com/redirect?url=https://figma.com/file/abc123/Fake", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/figma/normalize.go b/internal/figma/normalize.go index 73326c2..28caa4a 100644 --- a/internal/figma/normalize.go +++ b/internal/figma/normalize.go @@ -70,8 +70,8 @@ func Normalize( return design } -// extractAnchors recursively extracts DesignAnchor values from a document node subtree. -// path is the breadcrumb from the root to the current node (used for NodePath). +// extractAnchors extracts a DesignAnchor from a document node, collecting text and +// component IDs from its direct children. path is the breadcrumb from the root (used for NodePath). func extractAnchors(nodeID string, doc *DocumentNode, path []string) []DesignAnchor { currentPath := append(append([]string{}, path...), doc.Name) anchor := DesignAnchor{ diff --git a/internal/source/manager.go b/internal/source/manager.go index 5e9a005..88ef521 100644 --- a/internal/source/manager.go +++ b/internal/source/manager.go @@ -52,9 +52,16 @@ func (m *Manager) FetchFigma(ctx context.Context, client *figma.Client, ref *fig if ref.NodeID != "" { nodeIDs = []string{ref.NodeID} } - nodes, err := client.GetNodes(ctx, ref.FileKey, nodeIDs) - if err != nil { - return nil, fmt.Errorf("fetching figma nodes: %w", err) + + var nodes *figma.NodesResponse + if len(nodeIDs) == 0 { + fmt.Printf("warning: whole-file Figma link — no specific node requested, skipping node fetch\n") + nodes = &figma.NodesResponse{} + } else { + nodes, err = client.GetNodes(ctx, ref.FileKey, nodeIDs) + if err != nil { + return nil, fmt.Errorf("fetching figma nodes: %w", err) + } } comments, err := client.GetComments(ctx, ref.FileKey) From 675c930107c0cea98d923e7d5e5e08852cb04661 Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Tue, 26 May 2026 12:05:55 +0300 Subject: [PATCH 3/3] fix: address second-round PR review comments --- internal/figma/client.go | 2 +- internal/figma/client_test.go | 6 ++++-- internal/figma/link.go | 7 ++++++- internal/figma/normalize.go | 25 ++++++++++++++++++++----- internal/source/manager.go | 19 ++++++++++++++----- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/internal/figma/client.go b/internal/figma/client.go index ebf3b6c..51586cc 100644 --- a/internal/figma/client.go +++ b/internal/figma/client.go @@ -128,7 +128,7 @@ func doGet[T any](ctx context.Context, c *Client, endpoint string) (*T, error) { case http.StatusOK: // success path; continue case http.StatusUnauthorized, http.StatusForbidden: - return nil, fmt.Errorf("figma API authentication failed (status %d): check BAUER_FIGMA_TOKEN", resp.StatusCode) + return nil, fmt.Errorf("figma API authentication failed (status %d): check BAUER_FIGMA_TOKEN or FIGMA_TOKEN", resp.StatusCode) case http.StatusTooManyRequests: return nil, fmt.Errorf("figma API rate limit exceeded (status 429): retry after a delay") case http.StatusNotFound: diff --git a/internal/figma/client_test.go b/internal/figma/client_test.go index fe4afce..f3ea8f3 100644 --- a/internal/figma/client_test.go +++ b/internal/figma/client_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "bauer/internal/figma" @@ -156,7 +157,8 @@ type prefixTransport struct { func (t *prefixTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Rewrite the host to point to the test server. req2 := req.Clone(req.Context()) - req2.URL.Scheme = "http" - req2.URL.Host = t.base[len("http://"):] + parsed, _ := url.Parse(t.base) + req2.URL.Scheme = parsed.Scheme + req2.URL.Host = parsed.Host return http.DefaultTransport.RoundTrip(req2) } diff --git a/internal/figma/link.go b/internal/figma/link.go index 129151a..024bc1a 100644 --- a/internal/figma/link.go +++ b/internal/figma/link.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "regexp" + "strings" ) var figmaFilePattern = regexp.MustCompile(`figma\.com/(?:file|design)/([A-Za-z0-9_-]+)`) @@ -20,7 +21,11 @@ type LinkRef struct { // Returns a clear error for non-Figma URLs. func ParseLink(rawURL string) (*LinkRef, error) { u, err := url.Parse(rawURL) - if err != nil || (u.Host != "www.figma.com" && u.Host != "figma.com") { + if err != nil { + return nil, fmt.Errorf("not a valid Figma link: %q (expected figma.com/file/... or figma.com/design/...)", rawURL) + } + host := u.Hostname() + if !strings.EqualFold(host, "www.figma.com") && !strings.EqualFold(host, "figma.com") { return nil, fmt.Errorf("not a valid Figma link: %q (expected figma.com/file/... or figma.com/design/...)", rawURL) } diff --git a/internal/figma/normalize.go b/internal/figma/normalize.go index 28caa4a..d10260e 100644 --- a/internal/figma/normalize.go +++ b/internal/figma/normalize.go @@ -1,6 +1,9 @@ package figma -import "time" +import ( + "sort" + "time" +) // Normalize converts raw Figma API responses into a NormalizedDesign. // @@ -36,8 +39,14 @@ func Normalize( } } else { // Whole-file fetch: collect anchors from every node returned. - // Use a sorted iteration order if stability matters for tests. - for nodeID, entry := range nodes.Nodes { + // Sorted iteration for deterministic output. + sortedNodeIDs := make([]string, 0, len(nodes.Nodes)) + for nodeID := range nodes.Nodes { + sortedNodeIDs = append(sortedNodeIDs, nodeID) + } + sort.Strings(sortedNodeIDs) + for _, nodeID := range sortedNodeIDs { + entry := nodes.Nodes[nodeID] design.Anchors = append(design.Anchors, extractAnchors(nodeID, &entry.Document, nil)...) } } @@ -57,11 +66,17 @@ func Normalize( } // Map screenshot paths to ScreenshotArtifact records. + // Sorted iteration for deterministic output. now := time.Now().UTC().Format(time.RFC3339) - for nodeID, localPath := range screenshotPaths { + sortedScreenshotIDs := make([]string, 0, len(screenshotPaths)) + for nodeID := range screenshotPaths { + sortedScreenshotIDs = append(sortedScreenshotIDs, nodeID) + } + sort.Strings(sortedScreenshotIDs) + for _, nodeID := range sortedScreenshotIDs { design.Screenshots = append(design.Screenshots, ScreenshotArtifact{ NodeID: nodeID, - LocalPath: localPath, + LocalPath: screenshotPaths[nodeID], Scale: 2, FetchedAt: now, }) diff --git a/internal/source/manager.go b/internal/source/manager.go index 88ef521..0d84ac7 100644 --- a/internal/source/manager.go +++ b/internal/source/manager.go @@ -3,8 +3,9 @@ package source import ( "context" "fmt" + "log/slog" "path/filepath" - "strings" + "regexp" "bauer/internal/figma" "bauer/internal/gdocs" @@ -55,7 +56,7 @@ func (m *Manager) FetchFigma(ctx context.Context, client *figma.Client, ref *fig var nodes *figma.NodesResponse if len(nodeIDs) == 0 { - fmt.Printf("warning: whole-file Figma link — no specific node requested, skipping node fetch\n") + slog.Warn("whole-file Figma link — no specific node requested, skipping node fetch") nodes = &figma.NodesResponse{} } else { nodes, err = client.GetNodes(ctx, ref.FileKey, nodeIDs) @@ -75,15 +76,16 @@ func (m *Manager) FetchFigma(ctx context.Context, client *figma.Client, ref *fig imageURLs, err := client.GetImages(ctx, ref.FileKey, nodeIDs) if err != nil { // Non-fatal: log and continue without screenshots - fmt.Printf("warning: could not fetch figma screenshots: %v\n", err) + slog.Warn("could not fetch figma screenshots", slog.Any("error", err)) } else { for nodeID, imgURL := range imageURLs { if imgURL == "" { continue } - destPath := filepath.Join(screenshotDir, fmt.Sprintf("shot-node-%s.png", strings.ReplaceAll(nodeID, ":", "-"))) + safeNodeID := sanitizeNodeID(nodeID) + destPath := filepath.Join(screenshotDir, fmt.Sprintf("shot-node-%s.png", safeNodeID)) if err := client.DownloadImage(ctx, imgURL, destPath); err != nil { - fmt.Printf("warning: could not download screenshot for node %s: %v\n", nodeID, err) + slog.Warn("could not download screenshot", slog.String("node_id", nodeID), slog.Any("error", err)) continue } screenshotPaths[nodeID] = destPath @@ -93,3 +95,10 @@ func (m *Manager) FetchFigma(ctx context.Context, client *figma.Client, ref *fig return figma.Normalize(ref.FileKey, ref.NodeID, meta, nodes, comments, screenshotPaths), nil } + +// sanitizeNodeID removes unsafe characters from a node ID for use in filenames. +var nodeIDSafePattern = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +func sanitizeNodeID(nodeID string) string { + return nodeIDSafePattern.ReplaceAllString(nodeID, "_") +}