Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,23 @@ tasks:
- golangci-lint run ./...

verify-figma:
desc: Verify Figma token and REST API access. Pass FILE_KEY=<your-figma-file-key>.
desc: "Verify Figma token and REST API access. Pass FILE_KEY=<your-figma-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
Expand Down
2 changes: 2 additions & 0 deletions cmd/bauer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -71,6 +72,7 @@ func main() {
ArtifactsDir: *artifactsDir,
BranchPrefix: *branchPrefix,
GitHubRepo: *githubRepo,
FigmaURL: *figmaURL,
}
fs.Visit(func(f *flag.Flag) {
switch f.Name {
Expand Down
19 changes: 16 additions & 3 deletions docs/implementation-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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/
Expand Down
4 changes: 4 additions & 0 deletions internal/config/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type CLIFlags struct {
TargetRepo string
ArtifactsDir string
BranchPrefix string
FigmaURL string
GitHubRepo string
OpenPR *bool
OpenIssue *bool
Expand All @@ -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)")
Comment on lines 45 to +46

// Custom usage message
flag.Usage = func() {
Expand All @@ -62,6 +64,7 @@ func Load() (*Config, error) {
{"--chunk-size", "<int>", "Total number of chunks to create (default: 1, or 5 if --page-refresh is set)"},
{"--output-dir", "<string>", "Directory for generated prompt files (default: bauer-output)"},
{"--artifacts-dir", "<string>", "Directory for run artifacts (default: ./bauer-artifacts)"},
{"--figma-url", "<string>", "Figma file or design URL for design reference (optional)"},
{"--model", "<string>", "Copilot model to use for sessions (default: gpt-5-mini-high)"},
{"--summary-model", "<string>", "Copilot model to use for summary session (default: gpt-5-mini-high)"},
{"--target-repo", "<string>", "Path to target repository where tasks should be executed (default: current directory)"},
Expand Down Expand Up @@ -97,6 +100,7 @@ func Load() (*Config, error) {
SummaryModel: *summaryModel,
TargetRepo: *targetRepo,
ArtifactsDir: *artifactsDir,
FigmaURL: *figmaURL,
}

if err := cfg.Validate(); err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
1 change: 1 addition & 0 deletions internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
145 changes: 145 additions & 0 deletions internal/figma/client.go
Original file line number Diff line number Diff line change
@@ -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 default permissions (0666 & ~umask).
func (c *Client) DownloadImage(ctx context.Context, presignedURL, destPath string) error {
Comment on lines +81 to +83
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
}
Comment on lines +81 to +109

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — adding tests for DownloadImage is out of scope for this PR. Tracked as a follow-up.


// 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 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:
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
}
Loading