diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f3a5665..398885b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.84" + ".": "0.1.0-alpha.85" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb9004..3e82c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## 0.1.0-alpha.85 (2026-03-19) + +Full Changelog: [v0.1.0-alpha.84...v0.1.0-alpha.85](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.84...v0.1.0-alpha.85) + +### Features + +* add git Show and CurrentBranch helpers ([7427170](https://github.com/stainless-api/stainless-api-cli/commit/742717047b54e4f56e5a1d5f4447fc4e633fcd1d)) +* **cmd/build:** add spacing between header and targets in builds list ([63df786](https://github.com/stainless-api/stainless-api-cli/commit/63df786562d05cae6d20d891a71a42dc6d286562)) +* **cmd/builddiagnostic:** use diagnostics component for diagnostics list ([407ce31](https://github.com/stainless-api/stainless-api-cli/commit/407ce3128688896f29f019eac428c15ded055355)) +* **cmd/build:** use build component view for builds list ([0c0c4ee](https://github.com/stainless-api/stainless-api-cli/commit/0c0c4ee740627b83995989538d8808943b6ce53d)) +* **cmd/dev:** refactor dev command to use compare builds ([b7cc41a](https://github.com/stainless-api/stainless-api-cli/commit/b7cc41a9d81e5442aeffeca81730f51c94b83f1e)) +* **components/build:** rewrite build pipeline view as single-line with header ([270e85b](https://github.com/stainless-api/stainless-api-cli/commit/270e85bd52601694c07ddd264b952b8ab0a18cc4)) +* **components/build:** single newline after header, show download path ([cba8ce9](https://github.com/stainless-api/stainless-api-cli/commit/cba8ce92565082a607a0510d54c3b6b56e95ac93)) +* **components/dev:** remove config section and waiting message from preview ([83b7995](https://github.com/stainless-api/stainless-api-cli/commit/83b7995c29b9435d1eee2f2edcaaddf1a5457725)) +* **components/diagnostics:** add line number and column numbers ([970ee80](https://github.com/stainless-api/stainless-api-cli/commit/970ee8039334c4f1b6c4a6e4ae4f8b7aa43686b7)) +* **components/diagnostics:** rewrite diagnostics view with Rust-style formatting ([c094b0f](https://github.com/stainless-api/stainless-api-cli/commit/c094b0f12cca78e4028311a8624696a842b643ea)) + + +### Bug Fixes + +* **components/dev:** preserve preview content on quit ([fcb4d88](https://github.com/stainless-api/stainless-api-cli/commit/fcb4d8846b0a84dcc64f05e00fa4a8bdd7a23f45)) +* fill project property more uniformly ([23b8227](https://github.com/stainless-api/stainless-api-cli/commit/23b822757485c5aab9796ea36733a20d79363b3b)) +* make download error display better ([31a38dc](https://github.com/stainless-api/stainless-api-cli/commit/31a38dcc710de76b127fc76d01be7fadce7430c9)) +* only set client options when the corresponding CLI flag or env var is explicitly set ([64c31cf](https://github.com/stainless-api/stainless-api-cli/commit/64c31cf020960b1dc7ed20840aa850073d5587ff)) +* read check step conclusion from top-level field ([e16abc3](https://github.com/stainless-api/stainless-api-cli/commit/e16abc3a768322b84c089967e114d8a485b97238)) + + +### Chores + +* automatically generate demo gifs ([a057170](https://github.com/stainless-api/stainless-api-cli/commit/a057170713df9e759738dc76d2325a7a00120a2a)) +* **internal:** version bump ([d6f4985](https://github.com/stainless-api/stainless-api-cli/commit/d6f49850f5572ccff1f65a40a54efde6f0427fcc)) + + +### Refactors + +* auto-set Before hook on all subcommands via traversal ([73a12cd](https://github.com/stainless-api/stainless-api-cli/commit/73a12cd0a3a9bf2392ada6c3873a2c8ad23c373e)) +* **cmd/lint:** move getDiagnostics to cmd/lint.go ([44a8a3a](https://github.com/stainless-api/stainless-api-cli/commit/44a8a3a98f9b632732bda3152808bf0138c65b6d)) +* **cmd/lint:** remove unused canSkip logic ([c7d5b10](https://github.com/stainless-api/stainless-api-cli/commit/c7d5b10184eef720bbb731b7dc5b4eb1edfae34a)) +* **cmd/lint:** remove unused canSkip logic ([48c573e](https://github.com/stainless-api/stainless-api-cli/commit/48c573e52b46bd27ce1d7f7decd268f21a43607e)) +* don't use deprecated .Completed property ([397114c](https://github.com/stainless-api/stainless-api-cli/commit/397114c2f0ac3587405c67f25f3a52f0ead4b277)) + ## 0.1.0-alpha.84 (2026-03-16) Full Changelog: [v0.1.0-alpha.83...v0.1.0-alpha.84](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.83...v0.1.0-alpha.84) diff --git a/assets/auth-login.gif b/assets/auth-login.gif new file mode 100644 index 0000000..0241be3 Binary files /dev/null and b/assets/auth-login.gif differ diff --git a/assets/auth-login.tape b/assets/auth-login.tape new file mode 100644 index 0000000..8e21cdf --- /dev/null +++ b/assets/auth-login.tape @@ -0,0 +1,18 @@ +Output assets/auth-login.gif +Set Shell "bash" +Set FontSize 14 +Set Width 900 +Set Height 300 +Set Theme "Catppuccin Mocha" +Set Padding 20 + +Type "stl auth login" +Sleep 500ms +Enter +Sleep 2s + +# Accept "Open browser?" confirm +Enter +Sleep 3s + +Sleep 1s diff --git a/assets/build:diagnostics-list.gif b/assets/build:diagnostics-list.gif new file mode 100644 index 0000000..760d5df Binary files /dev/null and b/assets/build:diagnostics-list.gif differ diff --git a/assets/builds-diagnostics-list.tape b/assets/builds-diagnostics-list.tape new file mode 100644 index 0000000..50a583e --- /dev/null +++ b/assets/builds-diagnostics-list.tape @@ -0,0 +1,14 @@ +Output "assets/builds-diagnostics-list.gif" +Set Shell "bash" +Set FontSize 14 +Set Width 1200 +Set Height 600 +Set Theme "Catppuccin Mocha" +Set Padding 20 + +Type "stl builds:diagnostics list --build-id bui_0cmmtsksxj000425s640c55yf1" +Sleep 500ms +Enter +Sleep 3s + +Sleep 1s diff --git a/assets/builds-list.gif b/assets/builds-list.gif new file mode 100644 index 0000000..a511a57 Binary files /dev/null and b/assets/builds-list.gif differ diff --git a/assets/builds-list.tape b/assets/builds-list.tape new file mode 100644 index 0000000..96f94a8 --- /dev/null +++ b/assets/builds-list.tape @@ -0,0 +1,14 @@ +Output assets/builds-list.gif +Set Shell "bash" +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Theme "Catppuccin Mocha" +Set Padding 20 + +Type "stl builds list --project acme-api --max-items 3" +Sleep 500ms +Enter +Sleep 3s + +Sleep 1s diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..1e395e5 Binary files /dev/null and b/assets/demo.gif differ diff --git a/assets/demo.tape b/assets/demo.tape new file mode 100644 index 0000000..55f19d9 --- /dev/null +++ b/assets/demo.tape @@ -0,0 +1,48 @@ +Output assets/demo.gif +Set Shell "bash" +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Theme "Catppuccin Mocha" +Set Padding 20 + +# Init +Type "stl init" +Sleep 500ms +Enter +Sleep 2s + +# Accept "Open browser?" confirm +Enter +Sleep 3s + + +# Select project: Down to "acme-api", then Enter +Down +Sleep 300ms +Enter +Sleep 2s + +# Accept openapi spec path default +Enter +Sleep 1s + +# Accept stainless config path default +Enter +Sleep 2s + +# Accept target output paths (one Enter per target) +Enter +Sleep 500ms +Enter +Sleep 500ms +Enter +Sleep 8s + +# Preview +Type "stl preview" +Sleep 500ms +Enter +Sleep 15s +Ctrl+C +Sleep 1s diff --git a/assets/preview.gif b/assets/preview.gif new file mode 100644 index 0000000..a664f25 Binary files /dev/null and b/assets/preview.gif differ diff --git a/assets/preview.tape b/assets/preview.tape new file mode 100644 index 0000000..020e1e9 --- /dev/null +++ b/assets/preview.tape @@ -0,0 +1,16 @@ +Output assets/preview.gif +Set Shell "bash" +Set FontSize 14 +Set Width 1200 +Set Height 600 +Set Theme "Catppuccin Mocha" +Set Padding 20 + +Hide +Type "stl preview --project acme-api --oas ./openapi.yml --config ./stainless.yml" +Show +Sleep 1.5s +Enter +Sleep 15s +Ctrl+C +Sleep 1s diff --git a/internal/cmd/mock-server/main.go b/internal/cmd/mock-server/main.go new file mode 100644 index 0000000..b59e48b --- /dev/null +++ b/internal/cmd/mock-server/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + + "github.com/stainless-api/stainless-api-cli/internal/mockstainless" +) + +func main() { + port := flag.Int("port", 4010, "port to listen on") + flag.Parse() + + mock := mockstainless.NewMock( + mockstainless.WithDefaultOrg(), + mockstainless.WithDefaultProject(), + mockstainless.WithDefaultCompareBuild(), + mockstainless.WithDeviceAuth(1), + mockstainless.WithGitRepos(), + ) + defer mock.Cleanup() + addr := fmt.Sprintf(":%d", *port) + fmt.Printf("Mock server listening on %s\n", addr) + if err := http.ListenAndServe(addr, mock.Server()); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} diff --git a/internal/mockstainless/builders.go b/internal/mockstainless/builders.go new file mode 100644 index 0000000..2c48055 --- /dev/null +++ b/internal/mockstainless/builders.go @@ -0,0 +1,201 @@ +package mockstainless + +import "time" + +// --- CheckStep builders --- + +func CheckStepNotStarted() M { + return M{"status": "not_started"} +} + +func CheckStepInProgress() M { + return M{"status": "in_progress", "url": ""} +} + +func CheckStepCompleted(conclusion string, opts ...func(M)) M { + m := M{"status": "completed", "conclusion": conclusion, "url": ""} + for _, opt := range opts { + opt(m) + } + return m +} + +// --- Commit builders --- + +func CommitNotStarted() M { + return M{"status": "not_started"} +} + +func CommitInProgress() M { + return M{"status": "in_progress"} +} + +func CommitCompleted(conclusion string, opts ...func(M)) M { + m := M{ + "status": "completed", + "conclusion": conclusion, + } + for _, opt := range opts { + opt(m) + } + return m +} + +func WithCommitData(owner, repo, sha string, additions, deletions int) func(M) { + return func(m M) { + m["commit"] = M{ + "sha": sha, + "tree_oid": "tree_" + sha[:7], + "repo": M{ + "owner": owner, + "name": repo, + }, + "stats": M{ + "additions": additions, + "deletions": deletions, + "total": additions + deletions, + }, + } + } +} + +func WithMergeConflictPR(owner, repo string, number int) func(M) { + return func(m M) { + m["merge_conflict_pr"] = M{ + "repo": M{ + "owner": owner, + "name": repo, + }, + "number": number, + } + } +} + +// --- BuildTarget builders --- + +type TargetOption func(M) + +// Target creates a build target with the given status and commit state. +// Lint, build, and test default to not_started. +func Target(status string, commit M, opts ...TargetOption) M { + m := M{ + "object": "build_target", + "status": status, + "install_url": "", + "commit": commit, + } + for _, opt := range opts { + opt(m) + } + return m +} + +func WithLint(step M) TargetOption { return func(m M) { m["lint"] = step } } +func WithBuild(step M) TargetOption { return func(m M) { m["build"] = step } } +func WithTest(step M) TargetOption { return func(m M) { m["test"] = step } } + +// Convenience target constructors + +func CompletedTarget(owner, repo, sha string, additions, deletions int) M { + return Target("completed", + CommitCompleted("success", WithCommitData(owner, repo, sha, additions, deletions)), + WithLint(CheckStepCompleted("success")), + WithBuild(CheckStepCompleted("success")), + WithTest(CheckStepCompleted("success")), + ) +} + +func WarningTarget(owner, repo, sha string, additions, deletions int) M { + return Target("completed", + CommitCompleted("warning", WithCommitData(owner, repo, sha, additions, deletions)), + WithLint(CheckStepCompleted("success")), + WithBuild(CheckStepCompleted("success")), + WithTest(CheckStepCompleted("success")), + ) +} + +func ErrorTarget(owner, repo, sha string, additions, deletions int) M { + return Target("completed", + CommitCompleted("error", WithCommitData(owner, repo, sha, additions, deletions)), + WithLint(CheckStepCompleted("success")), + WithBuild(CheckStepCompleted("success")), + WithTest(CheckStepCompleted("failure")), + ) +} + +func FatalTarget() M { + return Target("completed", CommitCompleted("fatal")) +} + +func MergeConflictTarget(owner, repo string, prNum int) M { + return Target("completed", + CommitCompleted("merge_conflict", WithMergeConflictPR(owner, repo, prNum)), + ) +} + +func NotStartedTarget() M { + return Target("not_started", CommitNotStarted()) +} + +func InProgressTarget() M { + return Target("codegen", CommitInProgress()) +} + +// --- Build builders --- + +type BuildOption func(M) + +// Build creates a build with sensible defaults. +func Build(id string, opts ...BuildOption) M { + m := M{ + "id": id, + "config_commit": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + "created_at": time.Now().Format(time.RFC3339), + "org": DefaultOrg, + "project": DefaultProject, + "targets": M{}, + } + for _, opt := range opts { + opt(m) + } + return m +} + +func WithTarget(name string, target M) BuildOption { + return func(m M) { + targets := m["targets"].(M) + targets[name] = target + } +} + +func WithCreatedAt(t time.Time) BuildOption { + return func(m M) { m["created_at"] = t.Format(time.RFC3339) } +} + +func WithConfigCommit(sha string) BuildOption { + return func(m M) { m["config_commit"] = sha } +} + +// --- Diagnostic builders --- + +type DiagnosticOption func(M) + +func Diagnostic(code, level, message string, opts ...DiagnosticOption) M { + m := M{ + "code": code, + "level": level, + "message": message, + "ignored": false, + "more": nil, + } + for _, opt := range opts { + opt(m) + } + return m +} + +func WithOASRef(ref string) DiagnosticOption { return func(m M) { m["oas_ref"] = ref } } +func WithConfigRef(ref string) DiagnosticOption { return func(m M) { m["config_ref"] = ref } } +func WithMore(markdown string) DiagnosticOption { + return func(m M) { m["more"] = M{"type": "markdown", "markdown": markdown} } +} diff --git a/internal/mockstainless/mock.go b/internal/mockstainless/mock.go new file mode 100644 index 0000000..794b0d5 --- /dev/null +++ b/internal/mockstainless/mock.go @@ -0,0 +1,555 @@ +package mockstainless + +import ( + "bytes" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + "time" +) + +// M is a shorthand for map[string]any, used throughout for JSON-serializable data. +type M = map[string]any + +const ( + DefaultOrg = "acme-corp" + DefaultProject = "acme-api" +) + +// Mock holds all data for a mock Stainless API server. +type Mock struct { + Builds []*ProgressiveBuild + Orgs []M + Projects []M + ProjectConfigs M + CompareBuild *CompareBuildConfig + AuthPendingCount int + + mu sync.Mutex + buildIndex map[string]*ProgressiveBuild + nextBuildSeq int + enableGitRepos bool + gitRepos map[string]gitRepo // key: "owner/name" + tempDir string + requests []RecordedRequest +} + +type RecordedRequest struct { + Method string + Path string + RawQuery string + Body string +} + +type gitRepo struct { + Path string // path to the git repo + Ref string // commit SHA on main branch +} + +// CompareBuildConfig configures the POST /v0/builds/compare endpoint. +type CompareBuildConfig struct { + Base M + Head M + PreviewBuild *ProgressiveBuild +} + +func (m *Mock) init() { + m.buildIndex = make(map[string]*ProgressiveBuild, len(m.Builds)) + for _, b := range m.Builds { + m.buildIndex[b.ID] = b + } + if m.CompareBuild != nil && m.CompareBuild.PreviewBuild != nil { + m.buildIndex[m.CompareBuild.PreviewBuild.ID] = m.CompareBuild.PreviewBuild + } + m.nextBuildSeq = len(m.buildIndex) + if m.enableGitRepos { + m.initGitRepos() + } +} + +// Cleanup removes temporary resources (git repos). +func (m *Mock) Cleanup() { + if m.tempDir != "" { + os.RemoveAll(m.tempDir) + } +} + +// initGitRepos creates local git repos for each unique repo found in build CompletedData. +func (m *Mock) initGitRepos() { + m.gitRepos = make(map[string]gitRepo) + tempDir, err := os.MkdirTemp("", "mock-git-repos-*") + if err != nil { + return + } + m.tempDir = tempDir + + // Collect unique repos from all builds, including compare preview builds. + type repoKey struct{ owner, name string } + seen := map[repoKey]bool{} + + collectRepos := func(b *ProgressiveBuild) { + if b == nil { + return + } + for _, targetData := range b.CompletedData { + commitStep, _ := targetData["commit"].(M) + commitObj, _ := commitStep["commit"].(M) + repo, _ := commitObj["repo"].(M) + owner, _ := repo["owner"].(string) + name, _ := repo["name"].(string) + if owner == "" || name == "" { + continue + } + key := repoKey{owner, name} + if seen[key] { + continue + } + seen[key] = true + + repoPath := filepath.Join(tempDir, name) + ref, err := createMockGitRepo(repoPath, name) + if err != nil { + continue + } + m.gitRepos[owner+"/"+name] = gitRepo{Path: repoPath, Ref: ref} + } + } + for _, b := range m.Builds { + collectRepos(b) + } + if m.CompareBuild != nil { + collectRepos(m.CompareBuild.PreviewBuild) + } +} + +// createMockGitRepo creates a git repo with a single commit and returns the commit SHA. +func createMockGitRepo(dir, name string) (string, error) { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + cmds := [][]string{ + {"git", "-C", dir, "init", "-b", "main"}, + {"git", "-C", dir, "config", "user.email", "mock@example.com"}, + {"git", "-C", dir, "config", "user.name", "Mock"}, + } + for _, args := range cmds { + if err := exec.Command(args[0], args[1:]...).Run(); err != nil { + return "", err + } + } + + // Create a README + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# "+name+"\n"), 0644); err != nil { + return "", err + } + + cmds = [][]string{ + {"git", "-C", dir, "add", "."}, + {"git", "-C", dir, "commit", "-m", "Initial commit"}, + } + for _, args := range cmds { + if err := exec.Command(args[0], args[1:]...).Run(); err != nil { + return "", err + } + } + + // Get the commit SHA + var out bytes.Buffer + cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD") + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return "", err + } + + return string(bytes.TrimSpace(out.Bytes())), nil +} + +// GetGitRepo returns the local git repo info for a given owner/name, if available. +func (m *Mock) GetGitRepo(owner, name string) (path, ref string, ok bool) { + repo, ok := m.gitRepos[owner+"/"+name] + if !ok { + return "", "", false + } + return repo.Path, repo.Ref, true +} + +func (m *Mock) GetBuild(id string) *ProgressiveBuild { + m.mu.Lock() + defer m.mu.Unlock() + return m.buildIndex[id] +} + +func (m *Mock) RecordRequest(request RecordedRequest) { + m.mu.Lock() + defer m.mu.Unlock() + m.requests = append(m.requests, request) +} + +func (m *Mock) Requests() []RecordedRequest { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]RecordedRequest, len(m.requests)) + copy(out, m.requests) + return out +} + +func (m *Mock) ResetRequests() { + m.mu.Lock() + defer m.mu.Unlock() + m.requests = nil +} + +func (m *Mock) Diagnostics(id string) []M { + m.mu.Lock() + defer m.mu.Unlock() + if pb, ok := m.buildIndex[id]; ok && pb.Diagnostics != nil { + return pb.Diagnostics + } + return []M{} +} + +func (m *Mock) CreateBuildFromTemplate(template *ProgressiveBuild) *ProgressiveBuild { + if template == nil { + return nil + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.nextBuildSeq++ + build := cloneProgressiveBuild(template) + build.ID = fmt.Sprintf("bui_mock_%06d", m.nextBuildSeq) + build.StartTime = time.Now() + + m.Builds = append([]*ProgressiveBuild{build}, m.Builds...) + m.buildIndex[build.ID] = build + return build +} + +func cloneProgressiveBuild(template *ProgressiveBuild) *ProgressiveBuild { + build := &ProgressiveBuild{ + ID: template.ID, + ConfigCommit: template.ConfigCommit, + Targets: append([]string(nil), template.Targets...), + CompletedData: make(map[string]M, len(template.CompletedData)), + Diagnostics: make([]M, len(template.Diagnostics)), + Delay: template.Delay, + } + + for name, target := range template.CompletedData { + build.CompletedData[name] = cloneMap(target) + } + for i, diagnostic := range template.Diagnostics { + build.Diagnostics[i] = cloneMap(diagnostic) + } + + return build +} + +func cloneMap(src M) M { + if src == nil { + return nil + } + out := make(M, len(src)) + for key, value := range src { + out[key] = cloneValue(value) + } + return out +} + +func cloneSlice(src []any) []any { + if src == nil { + return nil + } + out := make([]any, len(src)) + for i, value := range src { + out[i] = cloneValue(value) + } + return out +} + +func cloneValue(value any) any { + switch v := value.(type) { + case M: + return cloneMap(v) + case []any: + return cloneSlice(v) + default: + return v + } +} + +// MockOption configures a Mock via NewMock. +type MockOption func(*Mock) + +// NewMock creates a new mock with the given options. +func NewMock(opts ...MockOption) *Mock { + m := &Mock{} + for _, opt := range opts { + opt(m) + } + m.init() + return m +} + +// Server returns an http.Handler serving the mock's endpoints. +func (m *Mock) Server() http.Handler { + return newServeMux(m) +} + +// WithGitRepos creates local git repos for each target in the mock's builds. +// This enables the build_target_outputs endpoint to return file:// URLs that +// work with git fetch. Call Mock.Cleanup() when done to remove temp directories. +func WithGitRepos() MockOption { + return func(m *Mock) { + m.enableGitRepos = true + } +} + +// WithDeviceAuth sets how many "authorization_pending" responses the token +// endpoint returns before succeeding. +func WithDeviceAuth(pendingCount int) MockOption { + return func(m *Mock) { + m.AuthPendingCount = pendingCount + } +} + +// WithAutomaticDeviceAuth configures instant auth success (zero pending responses). +func WithAutomaticDeviceAuth() MockOption { + return WithDeviceAuth(0) +} + +// MockOrg describes an organization to register in the mock. +type MockOrg struct { + Name string // slug, required + DisplayName string // defaults to Name +} + +func (o MockOrg) toM() M { + displayName := o.DisplayName + if displayName == "" { + displayName = o.Name + } + return M{ + "slug": o.Name, + "display_name": displayName, + "object": "org", + "enable_ai_commit_messages": false, + } +} + +// WithOrg adds an organization to the mock. +func WithOrg(org MockOrg) MockOption { + return func(m *Mock) { + m.Orgs = append(m.Orgs, org.toM()) + } +} + +// MockProject describes a project to register in the mock. +type MockProject struct { + Name string // slug, required + DisplayName string // defaults to Name + Org string // defaults to first configured org's slug + Targets []string // defaults to ["typescript", "python", "go"] + Builds []*ProgressiveBuild // added to the mock's build list + Configs M // project config files +} + +func (p MockProject) toM(org string) M { + displayName := p.DisplayName + if displayName == "" { + displayName = p.Name + } + targets := p.Targets + if len(targets) == 0 { + targets = []string{"typescript", "python", "go"} + } + return M{ + "slug": p.Name, + "display_name": displayName, + "object": "project", + "org": org, + "config_repo": fmt.Sprintf("https://github.com/%s/%s", org, p.Name), + "targets": targets, + } +} + +// WithProject adds a project (and its builds) to the mock. +func WithProject(project MockProject) MockOption { + return func(m *Mock) { + org := project.Org + if org == "" && len(m.Orgs) > 0 { + org = m.Orgs[0]["slug"].(string) + } + m.Projects = append(m.Projects, project.toM(org)) + m.Builds = append(m.Builds, project.Builds...) + if project.Configs != nil { + m.ProjectConfigs = project.Configs + } + } +} + +// WithCompareBuild enables the POST /v0/builds/compare endpoint. +func WithCompareBuild(cfg CompareBuildConfig) MockOption { + return func(m *Mock) { + m.CompareBuild = &cfg + } +} + +// WithDefaultOrg adds the default acme-corp organization. +func WithDefaultOrg() MockOption { + return WithOrg(MockOrg{Name: "acme-corp", DisplayName: "Acme Corp"}) +} + +// WithDefaultProject adds the default acme-api project with 4 demo builds and config files. +func WithDefaultProject() MockOption { + return func(m *Mock) { + now := time.Now() + WithProject(MockProject{ + Name: "acme-api", + DisplayName: "Acme API", + Org: "acme-corp", + Targets: []string{"typescript", "python", "go"}, + Configs: M{ + "stainless.yml": M{ + "content": "# Stainless configuration\norganization:\n name: acme-corp\n docs_url: https://docs.acme.com\n\nclient:\n name: Acme\n\nendpoints:\n list_pets:\n path: /pets\n method: get\n create_pet:\n path: /pets\n method: post\n get_pet:\n path: /pets/{id}\n method: get\n", + }, + "openapi.json": M{ + "content": "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Acme Pet Store API\",\"version\":\"1.0.0\"},\"paths\":{\"/pets\":{\"get\":{\"summary\":\"List pets\"},\"post\":{\"summary\":\"Create pet\"}},\"/pets/{id}\":{\"get\":{\"summary\":\"Get pet\"}}}}", + }, + }, + Builds: []*ProgressiveBuild{ + { + ID: "bui_0cmmtv8r2j000425s640dp4kwn", + ConfigCommit: "e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4", + Targets: []string{"typescript", "python", "go"}, + CompletedData: map[string]M{ + "typescript": CompletedTarget("acme-corp", "acme-typescript", "f4e8a2c91d3b7056ef12c489a37d6b0e51f8c2a4", 247, 83), + "python": CompletedTarget("acme-corp", "acme-python", "b3a9d7e21c5f8046ea31d589c47b6a0f52e8d3b5", 189, 42), + "go": CompletedTarget("acme-corp", "acme-go", "7b3d9e1f25a8c460d2f7b91e3c5a8d0f64e2b7c1", 156, 61), + }, + StartTime: now, + }, + { + ID: "bui_0cmmtsksxj000425s640c55yf1", + ConfigCommit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + Targets: []string{"typescript", "python", "go", "java", "kotlin", "ruby"}, + CompletedData: map[string]M{ + "typescript": CompletedTarget("acme-corp", "acme-typescript", "f4e8a2c91d3b7056ef12c489a37d6b0e51f8c2a4", 247, 83), + "python": WarningTarget("acme-corp", "acme-python", "b3a9d7e21c5f8046ea31d589c47b6a0f52e8d3b5", 189, 42), + "go": ErrorTarget("acme-corp", "acme-go", "7b3d9e1f25a8c460d2f7b91e3c5a8d0f64e2b7c1", 156, 61), + "java": MergeConflictTarget("acme-corp", "acme-java", 42), + "kotlin": FatalTarget(), + "ruby": NotStartedTarget(), + }, + Diagnostics: []M{ + Diagnostic("Schema/TypeMismatch", "error", "Expected `string` type but got `integer` in response schema for `get /pets/{pet_id}`.", + WithOASRef("#/paths/%2Fpets%2F%7Bpet_id%7D/get/responses/200/content/application%2Fjson/schema/properties/age"), + WithMore("The `age` property is declared as `string` in the schema but the example value is an integer.\n\nTo fix this, change the type to `integer` or update the example value."), + ), + Diagnostic("Schema/CannotInferUnionVariantName", "warning", "Placeholder name generated for union variant.", + WithOASRef("#/paths/%2Fpets%2F%7Bpet_id%7D/get/responses/200/content/application%2Fjson/schema/anyOf/1"), + WithMore("We were unable to infer a good name for this union variant, so we gave it an arbitrary placeholder name.\n\nTo resolve this issue, do one of the following:\n\n- Define a [model](https://www.stainless.com/docs/guides/configure#models)\n- Set a `title` property on the schema\n- Extract the schema to `#/components/schemas`\n- Provide a name by adding an `x-stainless-variantName` property to the schema containing the name you want to use"), + ), + Diagnostic("Schema/IsAmbiguous", "warning", "This schema does not have at least one of `type`,\n`oneOf`, `anyOf`, or `allOf`, so its type has been interpreted as `unknown`.", + WithOASRef("#/components/schemas/PetMetadata"), + WithMore("If the schema should have a specific type, then add `type` to it.\n\nIf the schema should accept anything, then add [`x-stainless-any: true`](https://www.stainless.com/docs/reference/openapi-support#unknown-and-any)\nto suppress this note."), + ), + Diagnostic("Endpoint/IsIgnored", "note", "`get /internal/health` is in `unspecified_endpoints`, so code will not be\ngenerated for it.", + WithOASRef("#/paths/%2Finternal%2Fhealth/get"), + WithConfigRef("#/unspecified_endpoints/0"), + WithMore("If this is intentional, then ignore this note. Otherwise, remove the endpoint from\n`unspecified_endpoints` and add it to `resources`."), + ), + Diagnostic("Schema/ObjectHasNoProperties", "note", "This schema has neither `properties` nor `additionalProperties` so\nits type has been interpreted as `unknown`.", + WithOASRef("#/paths/%2Fpets%2F%7Bpet_id%7D%2Fvaccinations/get/responses/200/content/application%2Fjson/schema"), + WithMore("If the schema should be a map, then add [`additionalProperties`](https://json-schema.org/understanding-json-schema/reference/object#additionalproperties)\nto it.\n\nIf the schema should be an empty object type, then add `x-stainless-empty-object: true` to it."), + ), + }, + StartTime: now.Add(-10 * time.Minute), + }, + { + ID: "bui_0cmmtrmq4z000425s640hpf9gx", + ConfigCommit: "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1", + Targets: []string{"typescript", "python", "go"}, + CompletedData: map[string]M{ + "typescript": CompletedTarget("acme-corp", "acme-typescript", "d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0", 52, 18), + "python": Target("completed", + CommitCompleted("warning", WithCommitData("acme-corp", "acme-python", "e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2", 67, 23)), + WithLint(CheckStepCompleted("success")), + WithBuild(CheckStepCompleted("failure")), + WithTest(CheckStepCompleted("skipped")), + ), + "go": CompletedTarget("acme-corp", "acme-go", "c2a5f8e31b6d9074a3e5c8f12d7b4a69e0f3c5b8", 98, 34), + }, + Diagnostics: []M{ + Diagnostic("Schema/CannotInferUnionVariantName", "warning", "Placeholder name generated for union variant.", + WithOASRef("#/paths/%2Fpets/get/responses/200/content/application%2Fjson/schema/anyOf/0"), + ), + }, + StartTime: now.Add(-2 * time.Hour), + }, + { + ID: "bui_0cmmtg5e8n000425s6403bkywd", + ConfigCommit: "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2", + Targets: []string{"typescript", "python", "go"}, + CompletedData: map[string]M{ + "typescript": CompletedTarget("acme-corp", "acme-typescript", "a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8", 312, 98), + "python": CompletedTarget("acme-corp", "acme-python", "f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", 276, 114), + "go": CompletedTarget("acme-corp", "acme-go", "b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0", 198, 77), + }, + StartTime: now.Add(-24 * time.Hour), + }, + }, + })(m) + } +} + +// WithDefaultCompareBuild adds a compare endpoint with a preview build. +func WithDefaultCompareBuild() MockOption { + return func(m *Mock) { + now := time.Now() + previewTargets := []string{"typescript", "python", "go"} + + base := Build("build_preview_base_01", + WithConfigCommit("c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2"), + WithCreatedAt(now), + ) + head := Build("build_preview_head_01", + WithConfigCommit("d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3"), + WithCreatedAt(now), + ) + for _, t := range previewTargets { + base["targets"].(M)[t] = CompletedTarget("acme-corp", "acme-"+t, "a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8", 0, 0) + head["targets"].(M)[t] = NotStartedTarget() + } + + previewBuild := &ProgressiveBuild{ + ID: "build_preview_head_01", + ConfigCommit: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", + Targets: previewTargets, + CompletedData: map[string]M{ + "typescript": CompletedTarget("acme-corp", "acme-typescript", "e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4", 134, 47), + "python": WarningTarget("acme-corp", "acme-python", "a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6", 89, 31), + "go": ErrorTarget("acme-corp", "acme-go", "c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7", 156, 61), + }, + Diagnostics: []M{ + Diagnostic("Schema/TypeMismatch", "error", "Expected `string` type but got `integer` in response schema for `get /pets/{pet_id}`.", + WithOASRef("#/paths/%2Fpets%2F%7Bpet_id%7D/get/responses/200/content/application%2Fjson/schema/properties/age"), + ), + Diagnostic("Schema/CannotInferUnionVariantName", "warning", "Placeholder name generated for union variant.", + WithOASRef("#/paths/%2Fpets%2F%7Bpet_id%7D/get/responses/200/content/application%2Fjson/schema/anyOf/1"), + ), + }, + } + + WithCompareBuild(CompareBuildConfig{ + Base: base, + Head: head, + PreviewBuild: previewBuild, + })(m) + } +} diff --git a/internal/mockstainless/progressive.go b/internal/mockstainless/progressive.go new file mode 100644 index 0000000..79a7150 --- /dev/null +++ b/internal/mockstainless/progressive.go @@ -0,0 +1,119 @@ +package mockstainless + +import ( + "sync" + "time" +) + +// CallCounter is a thread-safe call counter. +type CallCounter struct { + mu sync.Mutex + count int +} + +func (c *CallCounter) Increment() int { + c.mu.Lock() + defer c.mu.Unlock() + c.count++ + return c.count +} + +// ProgressiveBuild returns a build that fills in incrementally over time. +// Each target progresses through 7 phases: +// +// not_started → codegen → committed → lint → build → test → completed +// +// Target i begins its first phase at Delay*i, and each phase lasts Delay. +// So target i reaches "completed" at Delay*(i+6). +type ProgressiveBuild struct { + ID string + ConfigCommit string + Targets []string // target names in completion order + CompletedData map[string]M // final state per target + Diagnostics []M // diagnostics returned for this build + Delay time.Duration + StartTime time.Time + mu sync.Mutex +} + +// jitter returns a deterministic pseudo-random duration in [0, max) based on a seed. +// Uses a simple integer hash so the same seed always produces the same jitter. +func jitter(seed int, max time.Duration) time.Duration { + // mix bits (based on splitmix / murmurhash finalizer) + h := uint64(seed+1) * 0x9e3779b97f4a7c15 + h ^= h >> 30 + h *= 0xbf58476d1ce4e5b9 + return time.Duration(h % uint64(max)) +} + +// Snapshot returns the build state at the current time. +// If StartTime is zero the build has not been activated yet and all targets +// are returned as not_started. +func (p *ProgressiveBuild) Snapshot() M { + p.mu.Lock() + started := !p.StartTime.IsZero() + var elapsed time.Duration + if started { + elapsed = time.Since(p.StartTime) + } + p.mu.Unlock() + + targets := M{} + for i, name := range p.Targets { + if !started { + targets[name] = NotStartedTarget() + continue + } + cd := p.CompletedData[name] + // Stagger each target by ~1s with deterministic jitter + offset := time.Duration(i)*time.Second + jitter(i*7, 400*time.Millisecond) + targets[name] = targetSnapshot(i, elapsed-offset, cd) + } + + build := Build(p.ID, + WithConfigCommit(p.ConfigCommit), + WithCreatedAt(p.StartTime), + ) + build["targets"] = targets + return build +} + +// targetSnapshot returns the target state based on elapsed time since the target started. +// targetIdx is used to seed deterministic jitter for each step duration. +func targetSnapshot(targetIdx int, elapsed time.Duration, completed M) M { + if elapsed <= 0 { + return NotStartedTarget() + } + + codegenDur := 2*time.Second + jitter(targetIdx*7+1, 600*time.Millisecond) + if elapsed <= codegenDur { + return InProgressTarget() + } + + target := Target("completed", completed["commit"].(M)) + for i, name := range []string{"lint", "build", "test"} { + step, ok := completed[name].(M) + if !ok { + continue + } + stepElapsed := elapsed - codegenDur + stepQueueDelay := time.Second + jitter(targetIdx*7+2+i, 800*time.Millisecond) + stepFinishDelay := 3*time.Second + jitter(targetIdx*7+2+i, 800*time.Millisecond) + if stepElapsed < stepQueueDelay { + target[name] = CheckStepNotStarted() + } else if stepElapsed < stepQueueDelay+stepFinishDelay { + target[name] = CheckStepInProgress() + } else { + target[name] = step + } + } + + return target +} + +// Reset restarts the progression from now. +func (p *ProgressiveBuild) Reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.StartTime = time.Now() +} diff --git a/internal/mockstainless/server.go b/internal/mockstainless/server.go new file mode 100644 index 0000000..8a81d39 --- /dev/null +++ b/internal/mockstainless/server.go @@ -0,0 +1,356 @@ +package mockstainless + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/tidwall/gjson" +) + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(v) +} + +// Page wraps data in a paginated response envelope. +func Page(data []M) M { + return M{ + "data": data, + "next_cursor": "", + } +} + +// newServeMux creates an http.Handler with all mock endpoints registered. +func newServeMux(m *Mock) http.Handler { + authCounter := &CallCounter{} + + mux := http.NewServeMux() + + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mux.HandleFunc("POST /api/oauth/device", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, M{ + "device_code": "demo_device_code_abc123", + "user_code": "DEMO-CODE", + "verification_uri": "https://app.stainless.com/activate", + "verification_uri_complete": "https://app.stainless.com/activate?code=DEMO-CODE", + "expires_in": 300, + "interval": 1, + }) + }) + + mux.HandleFunc("POST /v0/oauth/token", func(w http.ResponseWriter, r *http.Request) { + count := authCounter.Increment() + if count <= m.AuthPendingCount { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "authorization_pending", + }) + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "access_token": "demo_access_token_xyz789", + "refresh_token": "demo_refresh_token_abc456", + "token_type": "bearer", + }) + }) + + mux.HandleFunc("GET /v0/orgs", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, M{ + "data": m.Orgs, + "has_more": false, + }) + }) + + mux.HandleFunc("GET /v0/projects", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, Page(m.Projects)) + }) + + mux.HandleFunc("GET /v0/projects/{project}", func(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("project") + for _, p := range m.Projects { + if p["slug"] == slug { + writeJSON(w, http.StatusOK, p) + return + } + } + if len(m.Projects) > 0 { + writeJSON(w, http.StatusOK, m.Projects[0]) + } + }) + + mux.HandleFunc("PATCH /v0/projects/{project}", func(w http.ResponseWriter, r *http.Request) { + project := r.PathValue("project") + if project == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + body := mustReadBody(r) + writeJSON(w, http.StatusOK, M{ + "slug": project, + "display_name": gjson.GetBytes(body, "display_name").String(), + "object": "project", + }) + }) + + mux.HandleFunc("POST /v0/projects/{project}/generate_commit_message", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + writeJSON(w, http.StatusOK, M{ + "message": "mock commit message", + }) + }) + + mux.HandleFunc("GET /v0/projects/{project}/configs", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, m.ProjectConfigs) + }) + + mux.HandleFunc("POST /v0/projects/{project}/configs/guess", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + writeJSON(w, http.StatusOK, M{ + "stainless.yml": M{ + "content": "# guessed", + }, + }) + }) + + mux.HandleFunc("POST /v0/projects/{project}/branches", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + body := mustReadBody(r) + writeJSON(w, http.StatusOK, M{ + "branch": gjson.GetBytes(body, "branch").String(), + "object": "project_branch", + }) + }) + + mux.HandleFunc("GET /v0/projects/{project}/branches", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + writeJSON(w, http.StatusOK, Page([]M{ + {"branch": "main", "object": "project_branch"}, + })) + }) + + mux.HandleFunc("GET /v0/projects/{project}/branches/{branch}", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + writeJSON(w, http.StatusOK, M{ + "branch": r.PathValue("branch"), + "object": "project_branch", + }) + }) + + mux.HandleFunc("DELETE /v0/projects/{project}/branches/{branch}", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + writeJSON(w, http.StatusOK, M{"deleted": true}) + }) + + mux.HandleFunc("PUT /v0/projects/{project}/branches/{branch}/rebase", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + writeJSON(w, http.StatusOK, M{ + "branch": r.PathValue("branch"), + "object": "project_branch", + }) + }) + + mux.HandleFunc("PUT /v0/projects/{project}/branches/{branch}/reset", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + writeJSON(w, http.StatusOK, M{ + "branch": r.PathValue("branch"), + "object": "project_branch", + }) + }) + + mux.HandleFunc("POST /v0/builds", func(w http.ResponseWriter, r *http.Request) { + body := mustReadBody(r) + if gjson.GetBytes(body, "project").String() == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + if len(m.Builds) == 0 { + writeJSON(w, http.StatusNotFound, M{"error": "missing build"}) + return + } + build := m.CreateBuildFromTemplate(m.Builds[0]) + if build == nil { + writeJSON(w, http.StatusNotFound, M{"error": "missing build"}) + return + } + writeJSON(w, http.StatusOK, build.Snapshot()) + }) + + mux.HandleFunc("GET /v0/builds", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("project") == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + builds := make([]M, len(m.Builds)) + for i, b := range m.Builds { + builds[i] = b.Snapshot() + } + writeJSON(w, http.StatusOK, Page(builds)) + }) + + mux.HandleFunc("GET /v0/builds/{id}", func(w http.ResponseWriter, r *http.Request) { + if pb := m.GetBuild(r.PathValue("id")); pb != nil { + writeJSON(w, http.StatusOK, pb.Snapshot()) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + mux.HandleFunc("GET /v0/builds/{id}/diagnostics", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, Page(m.Diagnostics(r.PathValue("id")))) + }) + + mux.HandleFunc("GET /v0/build_target_outputs", func(w http.ResponseWriter, r *http.Request) { + buildID := r.URL.Query().Get("build_id") + target := r.URL.Query().Get("target") + outputType := r.URL.Query().Get("output") + sourceType := r.URL.Query().Get("type") + + pb := m.GetBuild(buildID) + if pb == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + targetData, ok := pb.CompletedData[target] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + // Extract commit info: target["commit"]["commit"]["sha"] and repo info + commitStep, _ := targetData["commit"].(M) + commitObj, _ := commitStep["commit"].(M) + repo, _ := commitObj["repo"].(M) + sha, _ := commitObj["sha"].(string) + owner, _ := repo["owner"].(string) + name, _ := repo["name"].(string) + + if sha == "" || owner == "" || name == "" { + w.WriteHeader(http.StatusNotFound) + return + } + + gitURL := fmt.Sprintf("https://github.com/%s/%s", owner, name) + ref := sha + + // Use local git repo if available + if repoPath, localRef, ok := m.GetGitRepo(owner, name); ok { + gitURL = "file://" + repoPath + ref = localRef + } + + switch outputType { + case "git": + writeJSON(w, http.StatusOK, M{ + "output": "git", + "target": target, + "type": sourceType, + "url": gitURL, + "ref": ref, + "token": "mock_token_123", + }) + default: + writeJSON(w, http.StatusOK, M{ + "output": "url", + "target": target, + "type": sourceType, + "url": gitURL + "/archive/" + ref + ".tar.gz", + }) + } + }) + + if m.CompareBuild != nil { + mux.HandleFunc("POST /v0/builds/compare", func(w http.ResponseWriter, r *http.Request) { + body := mustReadBody(r) + if gjson.GetBytes(body, "project").String() == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project is required"}) + return + } + headBuild := m.CreateBuildFromTemplate(m.CompareBuild.PreviewBuild) + if headBuild == nil { + writeJSON(w, http.StatusNotFound, M{"error": "missing preview build"}) + return + } + head := cloneMap(m.CompareBuild.Head) + head["id"] = headBuild.ID + head["created_at"] = time.Now().Format(time.RFC3339) + writeJSON(w, http.StatusOK, M{ + "base": m.CompareBuild.Base, + "head": head, + }) + }) + } + + mux.HandleFunc("POST /api/generate/spec", func(w http.ResponseWriter, r *http.Request) { + body := mustReadBody(r) + if gjson.GetBytes(body, "project").String() == "" || + gjson.GetBytes(body, "source.openapi_spec").String() == "" || + gjson.GetBytes(body, "source.stainless_config").String() == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "project, openapi_spec, and stainless_config are required"}) + return + } + writeJSON(w, http.StatusOK, M{ + "spec": M{ + "diagnostics": M{}, + }, + }) + }) + + // Add simulated latency to all requests (except health checks). + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := mustReadBody(r) + m.RecordRequest(RecordedRequest{ + Method: r.Method, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + Body: string(body), + }) + r.Body = io.NopCloser(bytes.NewReader(body)) + + if r.URL.Path != "/health" { + time.Sleep(150 * time.Millisecond) + } + mux.ServeHTTP(w, r) + }) +} + +func mustReadBody(r *http.Request) []byte { + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + return body +} diff --git a/pkg/cmd/auth.go b/pkg/cmd/auth.go index 0b0ac8c..1cc3009 100644 --- a/pkg/cmd/auth.go +++ b/pkg/cmd/auth.go @@ -39,7 +39,6 @@ var authLogin = cli.Command{ Usage: "Open browser for authentication (use --browser=false to skip)", }, }, - Before: before, Action: handleAuthLogin, HideHelpCommand: true, } @@ -47,7 +46,6 @@ var authLogin = cli.Command{ var authLogout = cli.Command{ Name: "logout", Usage: "Log out and remove saved credentials", - Before: before, Action: handleAuthLogout, HideHelpCommand: true, } @@ -55,7 +53,6 @@ var authLogout = cli.Command{ var authStatus = cli.Command{ Name: "status", Usage: "Check authentication status", - Before: before, Action: handleAuthStatus, HideHelpCommand: true, } diff --git a/pkg/cmd/auth_test.go b/pkg/cmd/auth_test.go index 4e9df05..a677484 100644 --- a/pkg/cmd/auth_test.go +++ b/pkg/cmd/auth_test.go @@ -9,46 +9,14 @@ import ( "path/filepath" "testing" + "github.com/stainless-api/stainless-api-cli/internal/mockstainless" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAuthLogin(t *testing.T) { - tokenCallCount := 0 - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/oauth/device": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "device_code": "test_device_code", - "user_code": "TEST-CODE", - "verification_uri": "https://example.com/activate", - "verification_uri_complete": "https://example.com/activate?code=TEST-CODE", - "expires_in": 300, - "interval": 1, - }) - case "/v0/oauth/token": - tokenCallCount++ - if tokenCallCount == 1 { - // First poll returns authorization_pending - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "error": "authorization_pending", - }) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "access_token": "test_access_token", - "refresh_token": "test_refresh_token", - "token_type": "bearer", - }) - default: - http.NotFound(w, r) - } - })) + mock := mockstainless.NewMock(mockstainless.WithDeviceAuth(1)) + server := httptest.NewServer(mock.Server()) defer server.Close() // Use a temp dir as HOME so auth config doesn't touch real config @@ -72,12 +40,9 @@ func TestAuthLogin(t *testing.T) { var saved AuthConfig require.NoError(t, json.Unmarshal(data, &saved)) - assert.Equal(t, "test_access_token", saved.AccessToken) - assert.Equal(t, "test_refresh_token", saved.RefreshToken) + assert.Equal(t, "demo_access_token_xyz789", saved.AccessToken) + assert.Equal(t, "demo_refresh_token_abc456", saved.RefreshToken) assert.Equal(t, "bearer", saved.TokenType) - - // Verify the token endpoint was polled at least twice (once pending, once success) - assert.GreaterOrEqual(t, tokenCallCount, 2) } func TestAuthLoad(t *testing.T) { diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 4500849..fc56f6c 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -158,7 +158,6 @@ var buildsCreate = requestflag.WithInnerFlags(cli.Command{ }, }, Action: handleBuildsCreate, - Before: before, HideHelpCommand: true, }, map[string][]requestflag.HasOuterFlag{ "target-commit-messages": { @@ -229,7 +228,6 @@ var buildsRetrieve = cli.Command{ }, }, Action: handleBuildsRetrieve, - Before: before, HideHelpCommand: true, } @@ -304,7 +302,6 @@ var buildsCompare = requestflag.WithInnerFlags(cli.Command{ }, }, Action: handleBuildsCompare, - Before: before, HideHelpCommand: true, }, map[string][]requestflag.HasOuterFlag{ "base": { @@ -526,7 +523,7 @@ func (c buildCompletionModel) IsCompleted() bool { // Check if download is completed (if applicable) downloadIsCompleted := true - if buildTarget.IsCommitCompleted() && stainlessutils.IsGoodCommitConclusion(buildTarget.Commit.Completed.Conclusion) { + if buildTarget.IsCommitCompleted() && buildTarget.IsGoodCommitConclusion() { if download, ok := c.Build.Downloads[target]; ok { downloadIsCompleted = download.Status == "completed" } @@ -624,6 +621,23 @@ func handleBuildsList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } + + if format == "auto" && isTerminal(os.Stdout) { + for iter.Next() { + if maxItems == 0 { + break + } + maxItems-- + b := iter.Current() + fmt.Print(cbuild.ViewHeader("BUILD", b)) + fmt.Println() + m := cbuild.Model{Build: b} + fmt.Print(m.View()) + fmt.Println() + } + return iter.Err() + } + return ShowJSONIterator(os.Stdout, "builds list", iter, format, transform, maxItems) } } diff --git a/pkg/cmd/builddiagnostic.go b/pkg/cmd/builddiagnostic.go index 1a472f8..a5957d4 100644 --- a/pkg/cmd/builddiagnostic.go +++ b/pkg/cmd/builddiagnostic.go @@ -9,6 +9,8 @@ import ( "github.com/stainless-api/stainless-api-cli/internal/apiquery" "github.com/stainless-api/stainless-api-cli/internal/requestflag" + "github.com/stainless-api/stainless-api-cli/pkg/components/diagnostics" + "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go" "github.com/stainless-api/stainless-api-go/option" "github.com/tidwall/gjson" @@ -52,7 +54,6 @@ var buildsDiagnosticsList = cli.Command{ Usage: "The maximum number of items to return (use -1 for unlimited).", }, }, - Before: before, Action: handleBuildsDiagnosticsList, HideHelpCommand: true, } @@ -68,6 +69,8 @@ func handleBuildsDiagnosticsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } + wc := getWorkspace(ctx) + params := stainless.BuildDiagnosticListParams{} options, err := flagOptions( @@ -108,6 +111,24 @@ func handleBuildsDiagnosticsList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } + if format == "auto" && isTerminal(os.Stdout) { + var diags []stainless.BuildDiagnostic + for iter.Next() { + if maxItems >= 0 && len(diags) >= int(maxItems) { + break + } + diags = append(diags, iter.Current()) + } + if err := iter.Err(); err != nil { + return err + } + fmt.Print(diagnostics.ViewDiagnostics(diags, int(maxItems), + workspace.Relative(wc.OpenAPISpec), + workspace.Relative(wc.StainlessConfig), + )) + return nil + } + return ShowJSONIterator(os.Stdout, "builds:diagnostics list", iter, format, transform, maxItems) } } diff --git a/pkg/cmd/buildtargetoutput.go b/pkg/cmd/buildtargetoutput.go index a7b1cfc..da7b48a 100644 --- a/pkg/cmd/buildtargetoutput.go +++ b/pkg/cmd/buildtargetoutput.go @@ -62,7 +62,6 @@ var buildsTargetOutputsRetrieve = cli.Command{ QueryPath: "path", }, }, - Before: before, Action: handleBuildsTargetOutputsRetrieve, } diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index ae89793..a0eb190 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -13,9 +13,9 @@ import ( "strings" "github.com/stainless-api/stainless-api-cli/internal/autocomplete" + "github.com/stainless-api/stainless-api-cli/internal/requestflag" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/workspace" - "github.com/stainless-api/stainless-api-cli/internal/requestflag" docs "github.com/urfave/cli-docs/v3" "github.com/urfave/cli/v3" ) @@ -240,6 +240,24 @@ stl builds create --branch `, }, HideHelpCommand: true, } + + // Recursively set Before on all subcommands that have an Action. + // This ensures workspace config is always loaded without needing to + // manually add Before: before to every command definition. + // Excludes: + // - "init": the workspace Before would cause stale config values to be treated as user-supplied flags. + // - "__complete": workspace warnings on stderr become bogus completion + // candidates in shells that merge stderr into stdout (e.g. PowerShell). + var setBefore func(cmd *cli.Command) + setBefore = func(cmd *cli.Command) { + for _, sub := range cmd.Commands { + if sub.Action != nil && sub.Before == nil && sub.Name != "init" && sub.Name != "__complete" { + sub.Before = before + } + setBefore(sub) + } + } + setBefore(Command) } func before(ctx context.Context, cmd *cli.Command) (context.Context, error) { @@ -247,23 +265,42 @@ func before(ctx context.Context, cmd *cli.Command) (context.Context, error) { if _, err := wc.Find(); err != nil { console.Warn("%s", err) } - ctx = context.WithValue(ctx, "workspace_config", wc) var names []string for _, flag := range cmd.Flags { names = append(names, flag.Names()...) } + // Set the in-memory version of the workspace to the values that the flags override + if cmd.IsSet("project") { + wc.Project = cmd.String("project") + } + if cmd.IsSet("openapi-spec") { + wc.OpenAPISpec = cmd.String("openapi-spec") + } + if cmd.IsSet("stainless-config") { + wc.StainlessConfig = cmd.String("stainless-config") + } + if slices.Contains(names, "project") && wc.Project != "" && !cmd.IsSet("project") { cmd.Set("project", wc.Project) } - if slices.Contains(names, "openapi-spec") && wc.OpenAPISpec != "" && !cmd.IsSet("openapi-spec") && !cmd.IsSet("revision") { + + // if any of the revisions are supplied, then it's more confusing to partially fill in data from the + // workspace so we just don't. + isRevisionSupplied := cmd.IsSet("stainless-config") || cmd.IsSet("openapi-spec") || cmd.IsSet("revision") + + if slices.Contains(names, "openapi-spec") && wc.OpenAPISpec != "" && !isRevisionSupplied { cmd.Set("openapi-spec", wc.OpenAPISpec) } - if slices.Contains(names, "stainless-config") && wc.StainlessConfig != "" && !cmd.IsSet("stainless-config") && !cmd.IsSet("revision") { + if slices.Contains(names, "stainless-config") && wc.StainlessConfig != "" && !isRevisionSupplied { cmd.Set("stainless-config", wc.StainlessConfig) } + // Store workspace config after merging CLI overrides so downstream + // consumers always see the effective paths. + ctx = context.WithValue(ctx, "workspace_config", wc) + return ctx, nil } @@ -271,6 +308,21 @@ func getWorkspace(ctx context.Context) workspace.Config { return ctx.Value("workspace_config").(workspace.Config) } +func setFlagValue(cmd *cli.Command, name string, value string) error { + for _, flag := range cmd.Flags { + for _, flagName := range flag.Names() { + if flagName != name { + continue + } + if setter, ok := flag.(interface{ Set(string, string) error }); ok { + return setter.Set(name, value) + } + return cmd.Set(name, value) + } + } + return cmd.Set(name, value) +} + func generateManpages(ctx context.Context, c *cli.Command) error { manpage, err := docs.ToManWithSection(Command, 1) if err != nil { diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index f271454..2eab7e7 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -18,7 +18,6 @@ import ( "syscall" "github.com/stainless-api/stainless-api-cli/internal/jsonview" - "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go/option" "github.com/charmbracelet/x/term" @@ -41,11 +40,11 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), } - if cmd.IsSet("api-key") { + if cmd.IsSet("api-key") && cmd.String("api-key") != "" { opts = append(opts, option.WithAPIKey(cmd.String("api-key"))) } - if cmd.IsSet("project") { + if cmd.IsSet("project") && cmd.String("project") != "" { opts = append(opts, option.WithProject(cmd.String("project"))) } @@ -73,14 +72,6 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { } } - if project := os.Getenv("STAINLESS_PROJECT"); project == "" { - workspaceConfig := workspace.Config{} - found, err := workspaceConfig.Find() - if err == nil && found && workspaceConfig.Project != "" { - cmd.Set("project", workspaceConfig.Project) - } - } - return opts } diff --git a/pkg/cmd/dev.go b/pkg/cmd/dev.go index a42653c..64e6646 100644 --- a/pkg/cmd/dev.go +++ b/pkg/cmd/dev.go @@ -4,24 +4,21 @@ import ( "context" "crypto/rand" "encoding/base64" - "encoding/json" "errors" "fmt" "os" - "os/exec" "path" - "strings" + "path/filepath" "time" - "github.com/charmbracelet/huh" "github.com/stainless-api/stainless-api-cli/pkg/components/build" "github.com/stainless-api/stainless-api-cli/pkg/components/dev" "github.com/stainless-api/stainless-api-cli/pkg/console" + "github.com/stainless-api/stainless-api-cli/pkg/git" "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go" "github.com/stainless-api/stainless-api-go/option" "github.com/stainless-api/stainless-api-go/shared" - "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) @@ -46,14 +43,9 @@ var devCommand = cli.Command{ Usage: "Path to Stainless config file", }, &cli.StringFlag{ - Name: "branch", - Aliases: []string{"b"}, - Usage: "Which branch to use", - }, - &cli.StringSliceFlag{ - Name: "target", - Aliases: []string{"t"}, - Usage: "The target build language(s)", + Name: "base", + Value: "HEAD", + Usage: "Git ref to use as the base revision for comparison", }, &cli.BoolFlag{ Name: "watch", @@ -62,13 +54,11 @@ var devCommand = cli.Command{ Usage: "Run in 'watch' mode to loop and rebuild when files change.", }, }, - Before: before, Action: runPreview, } func runPreview(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("watch") { - // Clear the screen and move the cursor to the top fmt.Print("\033[2J\033[H") os.Stdout.Sync() } @@ -77,53 +67,8 @@ func runPreview(ctx context.Context, cmd *cli.Command) error { wc := getWorkspace(ctx) - gitUser, err := getGitUsername() - if err != nil { - console.Warn("Couldn't get a git user: %s", err) - gitUser = "user" - } - - var selectedBranch string - if cmd.IsSet("branch") { - selectedBranch = cmd.String("branch") - } else { - selectedBranch, err = chooseBranch(gitUser) - if err != nil { - return err - } - } - console.Property("branch", selectedBranch) - - // Phase 2: Language selection - var selectedTargets []string - targetInfos := getAvailableTargetInfo(ctx, client, cmd.String("project"), wc) - if cmd.IsSet("target") { - selectedTargets = cmd.StringSlice("target") - for _, target := range selectedTargets { - if !isValidTarget(targetInfos, stainless.Target(target)) { - return fmt.Errorf("invalid language target: %s", target) - } - } - } else { - selectedTargets, err = chooseSelectedTargets(targetInfos) - } - - if len(selectedTargets) == 0 { - return fmt.Errorf("no languages selected") - } - - console.Property("targets", strings.Join(selectedTargets, ", ")) - - // Convert string targets to stainless.Target - targets := make([]stainless.Target, len(selectedTargets)) - for i, target := range selectedTargets { - targets[i] = stainless.Target(target) - } - - // Phase 3: Start build and monitor progress in a loop for { - // Start the build process - if err := runDevBuild(ctx, client, wc, cmd, selectedBranch, targets); err != nil { + if err := runDevBuild(ctx, client, wc, cmd); err != nil { if errors.Is(err, build.ErrUserCancelled) { return nil } @@ -134,96 +79,139 @@ func runPreview(ctx context.Context, cmd *cli.Command) error { break } - // Clear the screen and move the cursor to the top fmt.Print("\nRebuilding...\n\n\033[2J\033[H") os.Stdout.Sync() - console.Property("branch", selectedBranch) - console.Property("targets", strings.Join(selectedTargets, ", ")) } return nil } -func chooseBranch(gitUser string) (string, error) { +// generateEphemeralBranches creates a paired set of ephemeral branch names +// for a compare build: one for the base and one for the head. +func generateEphemeralBranches(branchName string) (baseBranch, headBranch string) { now := time.Now() randomBytes := make([]byte, 3) rand.Read(randomBytes) - randomSuffix := base64.RawURLEncoding.EncodeToString(randomBytes) - randomBranch := fmt.Sprintf("%s/%d%02d%02d-%s", gitUser, now.Year(), now.Month(), now.Day(), randomSuffix) + entropy := fmt.Sprintf("%d%02d%02d-%s", now.Year(), now.Month(), now.Day(), base64.RawURLEncoding.EncodeToString(randomBytes)) + baseBranch = fmt.Sprintf("ephemeral-base-%s/%s", entropy, branchName) + headBranch = fmt.Sprintf("ephemeral-%s/%s", entropy, branchName) + return +} - branchOptions := []huh.Option[string]{} - if currentBranch, err := getCurrentGitBranch(); err == nil && currentBranch != "main" && currentBranch != "master" { - branchOptions = append(branchOptions, - huh.NewOption(currentBranch, currentBranch), - ) - } - branchOptions = append(branchOptions, - huh.NewOption(fmt.Sprintf("%s/dev", gitUser), fmt.Sprintf("%s/dev", gitUser)), - huh.NewOption(fmt.Sprintf("%s/", gitUser), randomBranch), - ) +// readFileInputMap reads files from disk and returns them as a file input map +// suitable for a build revision. +func readFileInputMap(oasPath, configPath string) (map[string]shared.FileInputUnionParam, error) { + files := make(map[string]shared.FileInputUnionParam) - var selectedBranch string - branchForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("branch"). - Description("Select a Stainless project branch to use for development"). - Options(branchOptions...). - Value(&selectedBranch), - ), - ).WithTheme(console.GetFormTheme(0)) + if oasPath != "" { + content, err := os.ReadFile(oasPath) + if err != nil { + return nil, fmt.Errorf("failed to read openapi-spec file: %v", err) + } + files["openapi"+path.Ext(oasPath)] = shared.FileInputParamOfFileInputContent(string(content)) + } - if err := branchForm.Run(); err != nil { - return selectedBranch, fmt.Errorf("branch selection failed: %v", err) + if configPath != "" { + content, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read stainless-config file: %v", err) + } + files["stainless"+path.Ext(configPath)] = shared.FileInputParamOfFileInputContent(string(content)) } - return selectedBranch, nil + return files, nil } -func chooseSelectedTargets(targetInfos []TargetInfo) ([]string, error) { - targetOptions := targetInfoToOptions(targetInfos) +// gitShowFileInputMap tries to read files at a given git ref and returns them +// as a file input map. Returns nil (not error) if any file can't be read from git. +func gitShowFileInputMap(repoDir, ref, oasPath, configPath string) map[string]shared.FileInputUnionParam { + files := make(map[string]shared.FileInputUnionParam) - var selectedTargets []string - targetForm := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("targets"). - Description("Select targets to generate (space to select, enter to confirm, select none to select all):"). - Options(targetOptions...). - Value(&selectedTargets), - ), - ).WithTheme(console.GetFormTheme(0)) + if oasPath != "" { + relPath, err := filepath.Rel(repoDir, oasPath) + if err != nil { + return nil + } + content, err := git.Show(repoDir, ref, relPath) + if err != nil { + return nil + } + files["openapi"+path.Ext(oasPath)] = shared.FileInputParamOfFileInputContent(string(content)) + } - if err := targetForm.Run(); err != nil { - return nil, fmt.Errorf("target selection failed: %v", err) + if configPath != "" { + relPath, err := filepath.Rel(repoDir, configPath) + if err != nil { + return nil + } + content, err := git.Show(repoDir, ref, relPath) + if err != nil { + return nil + } + files["stainless"+path.Ext(configPath)] = shared.FileInputParamOfFileInputContent(string(content)) } - return selectedTargets, nil + + return files } -func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Config, cmd *cli.Command, branch string, languages []stainless.Target) error { - projectName := cmd.String("project") - buildReq := stainless.BuildNewParams{ - Project: stainless.String(projectName), - Branch: stainless.String(branch), - Targets: languages, - AllowEmpty: stainless.Bool(true), +// gitRepoRoot returns the top-level directory of the git repo, or "" if not in a repo. +func gitRepoRoot(dir string) string { + sha, err := git.RevParse(dir, "--show-toplevel") + if err != nil { + return "" } + return sha +} - if name, oas, err := convertFileFlag(cmd, "openapi-spec"); err != nil { - return err - } else if oas != nil { - if buildReq.Revision.OfFileInputMap == nil { - buildReq.Revision.OfFileInputMap = make(map[string]shared.FileInputUnionParam) +func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Config, cmd *cli.Command) error { + projectName := cmd.String("project") + oasPath := cmd.String("openapi-spec") + configPath := cmd.String("stainless-config") + + // Determine git state and branch name + branchName := "main" + repoDir := gitRepoRoot(".") + inGitRepo := repoDir != "" + + if inGitRepo { + if b, err := git.CurrentBranch(repoDir); err == nil { + branchName = b } - buildReq.Revision.OfFileInputMap["openapi"+path.Ext(name)] = shared.FileInputParamOfFileInputContent(string(oas)) } + baseBranch, headBranch := generateEphemeralBranches(branchName) - if name, config, err := convertFileFlag(cmd, "stainless-config"); err != nil { + // Build head revision from current files on disk + headFiles, err := readFileInputMap(oasPath, configPath) + if err != nil { return err - } else if config != nil { - if buildReq.Revision.OfFileInputMap == nil { - buildReq.Revision.OfFileInputMap = make(map[string]shared.FileInputUnionParam) + } + + // Build base revision: try git show at --base ref, otherwise fall back to "main" + var baseRevision stainless.BuildCompareParamsBaseRevisionUnion + + baseRef := cmd.String("base") + if inGitRepo && oasPath != "" { + files := gitShowFileInputMap(repoDir, baseRef, oasPath, configPath) + if len(files) > 0 { + baseRevision.OfFileInputMap = files + } else { + baseRevision.OfString = stainless.String("main") } - buildReq.Revision.OfFileInputMap["stainless"+path.Ext(name)] = shared.FileInputParamOfFileInputContent(string(config)) + } else { + baseRevision.OfString = stainless.String("main") + } + + compareReq := stainless.BuildCompareParams{ + Project: stainless.String(projectName), + Base: stainless.BuildCompareParamsBase{ + Branch: baseBranch, + Revision: baseRevision, + }, + Head: stainless.BuildCompareParamsHead{ + Branch: headBranch, + Revision: stainless.BuildCompareParamsHeadRevisionUnion{ + OfFileInputMap: headFiles, + }, + }, } downloads := make(map[stainless.Target]string) @@ -231,28 +219,29 @@ func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Conf downloads[stainless.Target(targetName)] = targetConfig.OutputPath } - model := dev.NewModel( - client, - ctx, - branch, - func() (*stainless.Build, error) { + model := dev.NewModel(dev.ModelConfig{ + Client: client, + Ctx: ctx, + Branch: headBranch, + Start: func() (*stainless.Build, error) { options := []option.RequestOption{} if cmd.Bool("debug") { options = append(options, debugMiddlewareOption) } - build, err := client.Builds.New( + resp, err := client.Builds.Compare( ctx, - buildReq, + compareReq, options..., ) if err != nil { - return nil, fmt.Errorf("failed to create build: %v", err) + return nil, fmt.Errorf("failed to create compare build: %v", err) } - return build, err + return &resp.Head, nil }, - downloads, - cmd.Bool("watch"), - ) + DownloadPaths: downloads, + Watch: cmd.Bool("watch"), + }) + model.Diagnostics.WorkspaceConfig = wc p := console.NewProgram(model) finalModel, err := p.Run() @@ -265,117 +254,3 @@ func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Conf } return nil } - -func getGitUsername() (string, error) { - cmd := exec.Command("git", "config", "user.name") - output, err := cmd.Output() - if err != nil { - return "", err - } - - username := strings.TrimSpace(string(output)) - if username == "" { - return "", fmt.Errorf("git username not configured") - } - - // Convert to lowercase and replace spaces with hyphens for branch name - return strings.ToLower(strings.ReplaceAll(username, " ", "-")), nil -} - -func getCurrentGitBranch() (string, error) { - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - output, err := cmd.Output() - if err != nil { - return "", err - } - - branch := strings.TrimSpace(string(output)) - if branch == "" { - return "", fmt.Errorf("could not determine current git branch") - } - - return branch, nil -} - -type GenerateSpecParams struct { - Project string `json:"project"` - Source struct { - Type string `json:"type"` - OpenAPISpec string `json:"openapi_spec"` - StainlessConfig string `json:"stainless_config"` - } `json:"source"` -} - -func getDiagnostics(ctx context.Context, cmd *cli.Command, client stainless.Client, wc workspace.Config) ([]stainless.BuildDiagnostic, error) { - var specParams GenerateSpecParams - if cmd.IsSet("project") { - specParams.Project = cmd.String("project") - } else { - specParams.Project = wc.Project - } - specParams.Source.Type = "upload" - - configPath := wc.StainlessConfig - if cmd.IsSet("stainless-config") { - configPath = cmd.String("stainless-config") - } else if configPath == "" { - return nil, fmt.Errorf("You must provide a stainless configuration file with `--config /path/to/stainless.yml` or run this command from an initialized workspace.") - } - - stainlessConfig, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("Could not read your stainless configuration file:\n%w", err) - } - specParams.Source.StainlessConfig = string(stainlessConfig) - - oasPath := wc.OpenAPISpec - if cmd.IsSet("openapi-spec") { - oasPath = cmd.String("openapi-spec") - } else if oasPath == "" { - return nil, fmt.Errorf("You must provide an OpenAPI specification with `--oas /path/to/openapi.json` or run this command from an initialized workspace.") - } - - openAPISpec, err := os.ReadFile(oasPath) - if err != nil { - return nil, fmt.Errorf("Could not read your stainless configuration file:\n%w", err) - } - specParams.Source.OpenAPISpec = string(openAPISpec) - - options := []option.RequestOption{} - if cmd.Bool("debug") { - options = append(options, debugMiddlewareOption) - } - var result []byte - err = client.Post( - ctx, - "api/generate/spec", - specParams, - &result, - options..., - ) - if err != nil { - return nil, err - } - - transform := "spec.diagnostics.@values.@flatten.#(ignored==false)#" - jsonObj := gjson.Parse(string(result)).Get(transform) - var diagnostics []stainless.BuildDiagnostic - json.Unmarshal([]byte(jsonObj.Raw), &diagnostics) - return diagnostics, nil -} - -func hasBlockingDiagnostic(diagnostics []stainless.BuildDiagnostic) bool { - for _, d := range diagnostics { - if !d.Ignored { - switch d.Level { - case stainless.BuildDiagnosticLevelFatal: - case stainless.BuildDiagnosticLevelError: - case stainless.BuildDiagnosticLevelWarning: - return true - case stainless.BuildDiagnosticLevelNote: - continue - } - } - } - return false -} diff --git a/pkg/cmd/lint.go b/pkg/cmd/lint.go index ad1fa46..5f8cecc 100644 --- a/pkg/cmd/lint.go +++ b/pkg/cmd/lint.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "log" "os" @@ -17,6 +18,8 @@ import ( "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go" + "github.com/stainless-api/stainless-api-go/option" + "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) @@ -45,14 +48,13 @@ var lintCommand = cli.Command{ Usage: "Watch for files to change and re-run linting", }, }, - Before: before, Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("watch") { // Clear the screen and move the cursor to the top fmt.Print("\033[2J\033[H") os.Stdout.Sync() } - return runLinter(ctx, cmd, false) + return runLinter(ctx, cmd) }, } @@ -62,7 +64,6 @@ type lintModel struct { error error watching bool skipped bool - canSkip bool ctx context.Context cmd *cli.Command client stainless.Client @@ -95,11 +96,6 @@ func (m lintModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cmd = msg.cmd m.client = msg.client - if m.canSkip && !hasBlockingDiagnostic(m.diagnostics) { - m.watching = false - return m, tea.Quit - } - if m.watching { return m, func() tea.Msg { if err := waitTillConfigChanges(m.ctx, m.cmd, m.wc); err != nil { @@ -112,7 +108,7 @@ func (m lintModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case configChangedEvent: m.diagnostics = nil // Clear diagnostics while linting - return m, getDiagnosticsCmd(m.ctx, m.cmd, m.client, m.wc) + return m, getDiagnosticsCmd(m.ctx, m.cmd, m.client) case spinner.TickMsg: var cmd tea.Cmd @@ -138,7 +134,10 @@ func (m lintModel) View() string { content = m.spinner.View() + " Linting" } } else { - content = diagnostics.ViewDiagnostics(m.diagnostics, -1) + content = diagnostics.ViewDiagnostics(m.diagnostics, -1, + workspace.Relative(m.wc.OpenAPISpec), + workspace.Relative(m.wc.StainlessConfig), + ) if m.skipped { content += "\nContinuing..." } else if m.watching { @@ -158,9 +157,9 @@ type diagnosticsMsg struct { client stainless.Client } -func getDiagnosticsCmd(ctx context.Context, cmd *cli.Command, client stainless.Client, wc workspace.Config) tea.Cmd { +func getDiagnosticsCmd(ctx context.Context, cmd *cli.Command, client stainless.Client) tea.Cmd { return func() tea.Msg { - diagnostics, err := getDiagnostics(ctx, cmd, client, wc) + diagnostics, err := getDiagnostics(ctx, cmd, client) return diagnosticsMsg{ diagnostics: diagnostics, err: err, @@ -171,29 +170,76 @@ func getDiagnosticsCmd(ctx context.Context, cmd *cli.Command, client stainless.C } } -func (m lintModel) ShortHelp() []key.Binding { - if m.canSkip { - return []key.Binding{ - key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit")), - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "skip diagnostics")), - } - } else { - return []key.Binding{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))} +type GenerateSpecParams struct { + Project string `json:"project"` + Source struct { + Type string `json:"type"` + OpenAPISpec string `json:"openapi_spec"` + StainlessConfig string `json:"stainless_config"` + } `json:"source"` +} + +func getDiagnostics(ctx context.Context, cmd *cli.Command, client stainless.Client) ([]stainless.BuildDiagnostic, error) { + var specParams GenerateSpecParams + specParams.Project = cmd.String("project") + + specParams.Source.Type = "upload" + + var ( + oas []byte + config []byte + err error + ) + + if _, config, err = convertFileFlag(cmd, "stainless-config"); err != nil { + return nil, err + } + if _, oas, err = convertFileFlag(cmd, "openapi-spec"); err != nil { + return nil, err + } + + if config == nil { + return nil, fmt.Errorf("You must provide a stainless configuration file with `--config /path/to/stainless.yml` or run this command from an initialized workspace.") } + if oas == nil { + return nil, fmt.Errorf("You must provide an OpenAPI specification with `--oas /path/to/openapi.json` or run this command from an initialized workspace.") + } + + specParams.Source.StainlessConfig = string(config) + specParams.Source.OpenAPISpec = string(oas) + + options := []option.RequestOption{} + if cmd.Bool("debug") { + options = append(options, debugMiddlewareOption) + } + var result []byte + err = client.Post( + ctx, + "api/generate/spec", + specParams, + &result, + options..., + ) + if err != nil { + return nil, err + } + + transform := "spec.diagnostics.@values.@flatten.#(ignored==false)#" + jsonObj := gjson.Parse(string(result)).Get(transform) + var diagnostics []stainless.BuildDiagnostic + json.Unmarshal([]byte(jsonObj.Raw), &diagnostics) + return diagnostics, nil +} + +func (m lintModel) ShortHelp() []key.Binding { + return []key.Binding{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))} } func (m lintModel) FullHelp() [][]key.Binding { - if m.canSkip { - return [][]key.Binding{{ - key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit")), - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "skip diagnostics")), - }} - } else { - return [][]key.Binding{{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))}} - } + return [][]key.Binding{{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))}} } -func runLinter(ctx context.Context, cmd *cli.Command, canSkip bool) error { +func runLinter(ctx context.Context, cmd *cli.Command) error { client := stainless.NewClient(getDefaultRequestOptions(cmd)...) wc := getWorkspace(ctx) @@ -205,10 +251,10 @@ func runLinter(ctx context.Context, cmd *cli.Command, canSkip bool) error { m := lintModel{ spinner: s, watching: cmd.Bool("watch"), - canSkip: canSkip, ctx: ctx, cmd: cmd, client: client, + wc: wc, stopPolling: make(chan struct{}), help: help.New(), } @@ -218,7 +264,7 @@ func runLinter(ctx context.Context, cmd *cli.Command, canSkip bool) error { // Start the diagnostics process go func() { time.Sleep(100 * time.Millisecond) // Small delay to let the UI initialize - p.Send(getDiagnosticsCmd(ctx, cmd, client, wc)()) + p.Send(getDiagnosticsCmd(ctx, cmd, client)()) }() model, err := p.Run() @@ -235,9 +281,16 @@ func runLinter(ctx context.Context, cmd *cli.Command, canSkip bool) error { return finalModel.error } - // If not in watch mode and we have blocking diagnostics, exit with error code - if !cmd.Bool("watch") && hasBlockingDiagnostic(finalModel.diagnostics) { - os.Exit(1) + if !cmd.Bool("watch") { + for _, d := range finalModel.diagnostics { + if d.Ignored { + continue + } + switch d.Level { + case stainless.BuildDiagnosticLevelFatal, stainless.BuildDiagnosticLevelError, stainless.BuildDiagnosticLevelWarning: + return cli.Exit("", 1) + } + } } return nil diff --git a/pkg/cmd/mcp.go b/pkg/cmd/mcp.go index f9507af..fcb5bbb 100644 --- a/pkg/cmd/mcp.go +++ b/pkg/cmd/mcp.go @@ -15,7 +15,6 @@ var mcpCommand = cli.Command{ Name: "mcp", Usage: "Run Stainless MCP server", Description: "Wrapper around @stainless-api/mcp@latest with environment variables set", - Before: before, Action: handleMCP, ArgsUsage: "[MCP_ARGS...]", HideHelpCommand: true, diff --git a/pkg/cmd/org.go b/pkg/cmd/org.go index 7682f51..a8bc462 100644 --- a/pkg/cmd/org.go +++ b/pkg/cmd/org.go @@ -25,7 +25,6 @@ var orgsRetrieve = cli.Command{ Required: true, }, }, - Before: before, Action: handleOrgsRetrieve, HideHelpCommand: true, } @@ -35,7 +34,6 @@ var orgsList = cli.Command{ Usage: "List organizations accessible to the current authentication method.", Suggest: true, Flags: []cli.Flag{}, - Before: before, Action: handleOrgsList, HideHelpCommand: true, } diff --git a/pkg/cmd/project.go b/pkg/cmd/project.go index 8135aa8..62cc145 100644 --- a/pkg/cmd/project.go +++ b/pkg/cmd/project.go @@ -51,7 +51,6 @@ var projectsCreate = cli.Command{ BodyPath: "targets", }, }, - Before: before, Action: handleProjectsCreate, HideHelpCommand: true, } @@ -65,7 +64,6 @@ var projectsRetrieve = cli.Command{ Name: "project", }, }, - Before: before, Action: handleProjectsRetrieve, HideHelpCommand: true, } @@ -83,7 +81,6 @@ var projectsUpdate = cli.Command{ BodyPath: "display_name", }, }, - Before: before, Action: handleProjectsUpdate, HideHelpCommand: true, } @@ -114,7 +111,6 @@ var projectsList = cli.Command{ Usage: "The maximum number of items to return (use -1 for unlimited).", }, }, - Before: before, Action: handleProjectsList, HideHelpCommand: true, } diff --git a/pkg/cmd/projectbranch.go b/pkg/cmd/projectbranch.go index 9e27dbc..51de70a 100644 --- a/pkg/cmd/projectbranch.go +++ b/pkg/cmd/projectbranch.go @@ -42,7 +42,6 @@ var projectsBranchesCreate = cli.Command{ BodyPath: "force", }, }, - Before: before, Action: handleProjectsBranchesCreate, HideHelpCommand: true, } @@ -60,7 +59,6 @@ var projectsBranchesRetrieve = cli.Command{ Required: true, }, }, - Before: before, Action: handleProjectsBranchesRetrieve, HideHelpCommand: true, } @@ -90,7 +88,6 @@ var projectsBranchesList = cli.Command{ Usage: "The maximum number of items to return (use -1 for unlimited).", }, }, - Before: before, Action: handleProjectsBranchesList, HideHelpCommand: true, } @@ -108,7 +105,6 @@ var projectsBranchesDelete = cli.Command{ Required: true, }, }, - Before: before, Action: handleProjectsBranchesDelete, HideHelpCommand: true, } @@ -133,7 +129,6 @@ var projectsBranchesRebase = cli.Command{ QueryPath: "base", }, }, - Before: before, Action: handleProjectsBranchesRebase, HideHelpCommand: true, } @@ -156,7 +151,6 @@ var projectsBranchesReset = cli.Command{ QueryPath: "target_config_sha", }, }, - Before: before, Action: handleProjectsBranchesReset, HideHelpCommand: true, } diff --git a/pkg/cmd/projectconfig.go b/pkg/cmd/projectconfig.go index 9e4f2b2..dc8b6f2 100644 --- a/pkg/cmd/projectconfig.go +++ b/pkg/cmd/projectconfig.go @@ -36,7 +36,6 @@ var projectsConfigsRetrieve = cli.Command{ QueryPath: "include", }, }, - Before: before, Action: handleProjectsConfigsRetrieve, HideHelpCommand: true, } @@ -64,7 +63,6 @@ var projectsConfigsGuess = cli.Command{ BodyPath: "branch", }, }, - Before: before, Action: handleProjectsConfigsGuess, HideHelpCommand: true, } diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index f74cff4..0f5d543 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.0-alpha.84" // x-release-please-version +const Version = "0.1.0-alpha.85" // x-release-please-version diff --git a/pkg/cmd/workspace.go b/pkg/cmd/workspace.go index 7023925..8e0ce8c 100644 --- a/pkg/cmd/workspace.go +++ b/pkg/cmd/workspace.go @@ -64,7 +64,6 @@ var workspaceInit = cli.Command{ Value: true, }, }, - Before: before, Action: handleInit, HideHelpCommand: true, } @@ -72,7 +71,6 @@ var workspaceInit = cli.Command{ var workspaceStatus = cli.Command{ Name: "status", Usage: "Show workspace configuration status", - Before: before, Action: handleWorkspaceStatus, HideHelpCommand: true, } diff --git a/pkg/cmd/workspace_integration_test.go b/pkg/cmd/workspace_integration_test.go new file mode 100644 index 0000000..0c37bfc --- /dev/null +++ b/pkg/cmd/workspace_integration_test.go @@ -0,0 +1,404 @@ +package cmd + +import ( + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mockstainless" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "slices" +) + +type workspaceFixture struct { + Dir string + WorkspaceProject string + OverrideProject string + WorkspaceOASPath string + WorkspaceConfigPath string + OverrideOASPath string + OverrideConfigPath string + WorkspaceOAS string + WorkspaceConfig string + OverrideOAS string + OverrideConfig string +} + +func TestWorkspaceProjectAutofillIntegration(t *testing.T) { + t.Parallel() + + server := newMockServer(t) + + t.Run("workspace", func(t *testing.T) { + fixture := newWorkspaceFixture(t) + server.ResetRequests() + runCLI(t, fixture.Dir, server.URL(), "projects", "retrieve", "--api-key", "string") + + request := findRequest(t, server.Requests(), "GET", "/v0/projects/"+fixture.WorkspaceProject) + assertProjectPathSuffix(t, request, fixture.WorkspaceProject) + }) + + t.Run("flag override", func(t *testing.T) { + fixture := newWorkspaceFixture(t) + server.ResetRequests() + runCLI(t, fixture.Dir, server.URL(), "projects", "retrieve", "--api-key", "string", "--project", fixture.OverrideProject) + + request := findRequest(t, server.Requests(), "GET", "/v0/projects/"+fixture.OverrideProject) + assertProjectPathSuffix(t, request, fixture.OverrideProject) + }) + + t.Run("without workspace fails", func(t *testing.T) { + server.ResetRequests() + runCLIExpectError(t, t.TempDir(), server.URL(), "projects", "retrieve", "--api-key", "string") + }) +} + +func TestWorkspaceFileAutofillIntegration(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + args []string + server *mockServer + requestIndex func(t *testing.T, requests []mockstainless.RecordedRequest) mockstainless.RecordedRequest + assertFiles func(t *testing.T, request mockstainless.RecordedRequest, fixture workspaceFixture) + expectError bool + } + + cases := []testCase{ + { + name: "builds create", + args: []string{"builds", "create", "--api-key", "string", "--wait", "none"}, + server: newMockServer(t), + requestIndex: func(t *testing.T, requests []mockstainless.RecordedRequest) mockstainless.RecordedRequest { + return findRequest(t, requests, "POST", "/v0/builds") + }, + assertFiles: assertBuildCreateFiles, + }, + { + name: "lint", + args: []string{"lint", "--api-key", "string"}, + server: newMockServer(t), + requestIndex: func(t *testing.T, requests []mockstainless.RecordedRequest) mockstainless.RecordedRequest { + return findRequest(t, requests, "POST", "/api/generate/spec") + }, + assertFiles: assertLintFiles, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Run("workspace", func(t *testing.T) { + fixture := newWorkspaceFixture(t) + tc.server.ResetRequests() + if tc.expectError { + runCLIExpectError(t, fixture.Dir, tc.server.URL(), tc.args...) + } else { + runCLI(t, fixture.Dir, tc.server.URL(), tc.args...) + } + + request := tc.requestIndex(t, tc.server.Requests()) + tc.assertFiles(t, request, fixture) + assertProjectBody(t, request, fixture.WorkspaceProject) + }) + + t.Run("openapi override suppresses workspace config", func(t *testing.T) { + fixture := newWorkspaceFixture(t) + tc.server.ResetRequests() + args := append(slices.Clone(tc.args), "--oas", fixture.OverrideOASPath) + if tc.name == "lint" { + runCLIExpectError(t, fixture.Dir, tc.server.URL(), args...) + assertNoRequest(t, tc.server.Requests(), "POST", "/api/generate/spec") + return + } + + runCLI(t, fixture.Dir, tc.server.URL(), args...) + request := tc.requestIndex(t, tc.server.Requests()) + assertProjectBody(t, request, fixture.WorkspaceProject) + assertOpenAPIOnly(t, request, fixture.OverrideOAS) + }) + + t.Run("config override suppresses workspace openapi", func(t *testing.T) { + fixture := newWorkspaceFixture(t) + tc.server.ResetRequests() + args := append(slices.Clone(tc.args), "--config", fixture.OverrideConfigPath) + if tc.name == "lint" { + runCLIExpectError(t, fixture.Dir, tc.server.URL(), args...) + assertNoRequest(t, tc.server.Requests(), "POST", "/api/generate/spec") + return + } + + runCLI(t, fixture.Dir, tc.server.URL(), args...) + request := tc.requestIndex(t, tc.server.Requests()) + assertProjectBody(t, request, fixture.WorkspaceProject) + assertConfigOnly(t, request, fixture.OverrideConfig) + }) + + t.Run("alias flags override workspace", func(t *testing.T) { + fixture := newWorkspaceFixture(t) + tc.server.ResetRequests() + args := append(slices.Clone(tc.args), + "--project", fixture.OverrideProject, + "--oas", fixture.OverrideOASPath, + "--config", fixture.OverrideConfigPath, + ) + if tc.expectError { + runCLIExpectError(t, fixture.Dir, tc.server.URL(), args...) + } else { + runCLI(t, fixture.Dir, tc.server.URL(), args...) + } + + request := tc.requestIndex(t, tc.server.Requests()) + tc.assertFiles(t, request, workspaceFixture{ + WorkspaceProject: fixture.OverrideProject, + WorkspaceOAS: fixture.OverrideOAS, + WorkspaceConfig: fixture.OverrideConfig, + }) + assertProjectBody(t, request, fixture.OverrideProject) + }) + + t.Run("long flags override workspace", func(t *testing.T) { + fixture := newWorkspaceFixture(t) + tc.server.ResetRequests() + args := append(slices.Clone(tc.args), + "--project", fixture.OverrideProject, + "--openapi-spec", fixture.OverrideOASPath, + "--stainless-config", fixture.OverrideConfigPath, + ) + if tc.expectError { + runCLIExpectError(t, fixture.Dir, tc.server.URL(), args...) + } else { + runCLI(t, fixture.Dir, tc.server.URL(), args...) + } + + request := tc.requestIndex(t, tc.server.Requests()) + tc.assertFiles(t, request, workspaceFixture{ + WorkspaceProject: fixture.OverrideProject, + WorkspaceOAS: fixture.OverrideOAS, + WorkspaceConfig: fixture.OverrideConfig, + }) + assertProjectBody(t, request, fixture.OverrideProject) + }) + + t.Run("without workspace fails", func(t *testing.T) { + tc.server.ResetRequests() + runCLIExpectError(t, t.TempDir(), tc.server.URL(), tc.args...) + }) + }) + } +} + +type mockServer struct { + server *httptest.Server + mock *mockstainless.Mock +} + +type mockOption func(*mockstainless.Mock) + +var ( + buildCLIOnce sync.Once + buildCLIPath string + buildCLIErr error +) + +var () + +func newMockServer(t *testing.T, opts ...mockOption) *mockServer { + t.Helper() + + mock := mockstainless.NewMock( + mockstainless.WithDefaultOrg(), + mockstainless.WithDefaultProject(), + mockstainless.WithDefaultCompareBuild(), + ) + for _, opt := range opts { + opt(mock) + } + + server := httptest.NewServer(mock.Server()) + t.Cleanup(func() { + server.Close() + mock.Cleanup() + }) + + return &mockServer{server: server, mock: mock} +} + +func (s *mockServer) URL() string { + return s.server.URL +} + +func (s *mockServer) ResetRequests() { + s.mock.ResetRequests() +} + +func (s *mockServer) Requests() []mockstainless.RecordedRequest { + return s.mock.Requests() +} + +func runCLI(t *testing.T, dir string, baseURL string, args ...string) string { + t.Helper() + return runCLIWithExpectation(t, dir, baseURL, false, args...) +} + +func runCLIExpectError(t *testing.T, dir string, baseURL string, args ...string) string { + t.Helper() + return runCLIWithExpectation(t, dir, baseURL, true, args...) +} + +func runCLIWithExpectation(t *testing.T, dir string, baseURL string, expectError bool, args ...string) string { + t.Helper() + + binary := buildCLI(t) + commandArgs := append([]string{"--base-url", baseURL}, args...) + t.Logf("Testing command: %s %s", binary, strings.Join(commandArgs, " ")) + + cmd := exec.Command(binary, commandArgs...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "TERM=dumb", "CI=1") + outputBytes, err := cmd.CombinedOutput() + output := string(outputBytes) + + if expectError { + assert.Error(t, err, "Expected command to fail\nOutput: %s", output) + } else { + assert.NoError(t, err, "Test failed\nError: %v\nOutput: %s", err, output) + } + + return output +} + +func buildCLI(t *testing.T) string { + t.Helper() + + buildCLIOnce.Do(func() { + _, filename, _, ok := runtime.Caller(0) + if !ok { + buildCLIErr = assert.AnError + return + } + + repoRoot := filepath.Join(filepath.Dir(filename), "..", "..") + buildCLIPath = filepath.Join(os.TempDir(), "stl-workspace-integration-bin") + + cmd := exec.Command("go", "build", "-o", buildCLIPath, "./cmd/stl") + cmd.Dir = repoRoot + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("go build output:\n%s", output) + buildCLIErr = err + } + }) + + require.NoError(t, buildCLIErr) + return buildCLIPath +} + +func newWorkspaceFixture(t *testing.T) workspaceFixture { + t.Helper() + + dir := t.TempDir() + fixture := workspaceFixture{ + Dir: dir, + WorkspaceProject: "workspace-project", + OverrideProject: "flag-project", + WorkspaceOASPath: filepath.Join(dir, "workspace-openapi.yaml"), + WorkspaceConfigPath: filepath.Join(dir, "workspace-stainless.yaml"), + OverrideOASPath: filepath.Join(dir, "override-openapi.yaml"), + OverrideConfigPath: filepath.Join(dir, "override-stainless.yaml"), + WorkspaceOAS: "workspace-openapi", + WorkspaceConfig: "workspace-config", + OverrideOAS: "override-openapi", + OverrideConfig: "override-config", + } + + require.NoError(t, os.WriteFile(fixture.WorkspaceOASPath, []byte(fixture.WorkspaceOAS), 0644)) + require.NoError(t, os.WriteFile(fixture.WorkspaceConfigPath, []byte(fixture.WorkspaceConfig), 0644)) + require.NoError(t, os.WriteFile(fixture.OverrideOASPath, []byte(fixture.OverrideOAS), 0644)) + require.NoError(t, os.WriteFile(fixture.OverrideConfigPath, []byte(fixture.OverrideConfig), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".stainless"), 0755)) + + workspaceConfig := map[string]any{ + "project": fixture.WorkspaceProject, + "openapi_spec": "../" + filepath.Base(fixture.WorkspaceOASPath), + "stainless_config": "../" + filepath.Base(fixture.WorkspaceConfigPath), + } + data, err := json.Marshal(workspaceConfig) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".stainless", "workspace.json"), data, 0644)) + + return fixture +} + +func findRequest(t *testing.T, requests []mockstainless.RecordedRequest, method string, pathSuffix string) mockstainless.RecordedRequest { + t.Helper() + for _, request := range requests { + if request.Method == method && request.Path == pathSuffix { + return request + } + } + require.Failf(t, "request not found", "method=%s path=%s requests=%v", method, pathSuffix, requests) + return mockstainless.RecordedRequest{} +} + +func assertNoRequest(t *testing.T, requests []mockstainless.RecordedRequest, method string, path string) { + t.Helper() + for _, request := range requests { + if request.Method == method && request.Path == path { + require.Failf(t, "unexpected request", "method=%s path=%s requests=%v", method, path, requests) + } + } +} + +func assertProjectBody(t *testing.T, request mockstainless.RecordedRequest, project string) { + t.Helper() + assert.Equal(t, project, gjson.Get(request.Body, "project").String()) +} + +func assertProjectPathSuffix(t *testing.T, request mockstainless.RecordedRequest, project string) { + t.Helper() + assert.Equal(t, "/v0/projects/"+project, request.Path) +} + +func assertBuildCreateFiles(t *testing.T, request mockstainless.RecordedRequest, fixture workspaceFixture) { + t.Helper() + assert.Equal(t, fixture.WorkspaceOAS, gjson.Get(request.Body, "revision.openapi\\.yaml.content").String()) + assert.Equal(t, fixture.WorkspaceConfig, gjson.Get(request.Body, "revision.stainless\\.yaml.content").String()) +} + +func assertLintFiles(t *testing.T, request mockstainless.RecordedRequest, fixture workspaceFixture) { + t.Helper() + assert.Equal(t, fixture.WorkspaceOAS, gjson.Get(request.Body, "source.openapi_spec").String()) + assert.Equal(t, fixture.WorkspaceConfig, gjson.Get(request.Body, "source.stainless_config").String()) +} + +func assertOpenAPIOnly(t *testing.T, request mockstainless.RecordedRequest, openapi string) { + t.Helper() + if request.Path == "/v0/builds" { + assert.Equal(t, openapi, gjson.Get(request.Body, "revision.openapi\\.yaml.content").String()) + assert.False(t, gjson.Get(request.Body, "revision.stainless\\.yaml").Exists()) + return + } + assert.Equal(t, openapi, gjson.Get(request.Body, "head.revision.openapi\\.yaml.content").String()) + assert.False(t, gjson.Get(request.Body, "head.revision.stainless\\.yaml").Exists()) +} + +func assertConfigOnly(t *testing.T, request mockstainless.RecordedRequest, config string) { + t.Helper() + if request.Path == "/v0/builds" { + assert.Equal(t, config, gjson.Get(request.Body, "revision.stainless\\.yaml.content").String()) + assert.False(t, gjson.Get(request.Body, "revision.openapi\\.yaml").Exists()) + return + } + assert.Equal(t, config, gjson.Get(request.Body, "head.revision.stainless\\.yaml.content").String()) + assert.False(t, gjson.Get(request.Body, "head.revision.openapi\\.yaml").Exists()) +} diff --git a/pkg/components/build/model.go b/pkg/components/build/model.go index c9aaec2..cfba8f0 100644 --- a/pkg/components/build/model.go +++ b/pkg/components/build/model.go @@ -12,7 +12,9 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/git" "github.com/stainless-api/stainless-api-cli/pkg/stainlessutils" @@ -30,6 +32,7 @@ type Model struct { Downloads map[stainless.Target]DownloadStatus // When a BuildTarget has a commit available, this target will download it, if it has been specified in the initialization. Err error // This will be populated if the model concludes with an error CommitOnly bool // When true, only show the commit step in the pipeline view + Spinner spinner.Model // Spinner for in-progress animation } type DownloadStatus struct { @@ -61,19 +64,27 @@ func NewModel(client stainless.Client, ctx context.Context, build stainless.Buil } } + s := spinner.New() + s.Spinner = spinner.MiniDot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + return Model{ Build: build, Client: client, Ctx: ctx, Branch: branch, Downloads: downloads, + Spinner: s, } } func (m Model) Init() tea.Cmd { - return func() tea.Msg { - return TickMsg(time.Now()) - } + return tea.Batch( + m.Spinner.Tick, + func() tea.Msg { + return TickMsg(time.Now()) + }, + ) } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { @@ -120,6 +131,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } } + case spinner.TickMsg: + var cmd tea.Cmd + m.Spinner, cmd = m.Spinner.Update(msg) + cmds = append(cmds, cmd) + case ErrorMsg: m.Err = msg cmds = append(cmds, tea.Quit) @@ -141,7 +157,12 @@ func (m Model) downloadTarget(target stainless.Target) tea.Cmd { params, ) if err != nil { - return ErrorMsg(err) + return DownloadMsg{ + Target: target, + Status: "completed", + Conclusion: "failure", + Error: err.Error(), + } } err = PullOutputWithRetry(outputRes.Output, outputRes.URL, outputRes.Ref, m.Branch, m.Downloads[target].Path, console.NewGroup(true), 3) if err != nil { diff --git a/pkg/components/build/testdata/view_build_pipeline.snapshot b/pkg/components/build/testdata/view_build_pipeline.snapshot new file mode 100644 index 0000000..533cf80 --- /dev/null +++ b/pkg/components/build/testdata/view_build_pipeline.snapshot @@ -0,0 +1,55 @@ +typescript  queued ○ download + + +typescript  queued ○ download + + +typescript  generating | ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+100/-30) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (unchanged) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+50/-10) with warning diagnostic(s) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+50/-10) with error diagnostic(s) ○ lint ○ build ○ test ○ download + + +typescript  fatal error ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/pull/42merge conflict #42]8;; ○ lint ○ build ○ test ○ download + + +typescript  payment required ○ lint ○ build ○ test ○ download + + +typescript  cancelled ○ lint ○ build ○ test ○ download + + +typescript  timed out ○ lint ○ build ○ test ○ download + + +typescript  no-op ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/def5678901234def5678]8;; (+3/-3) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ○ lint ● build ✓ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ✓ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ⚠ download + Error: connection refused + + diff --git a/pkg/components/build/view.go b/pkg/components/build/view.go index 1a524e6..acc93c1 100644 --- a/pkg/components/build/view.go +++ b/pkg/components/build/view.go @@ -2,94 +2,220 @@ package build import ( "fmt" + "os" + "path/filepath" "strings" + "time" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/lipgloss" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/stainlessutils" "github.com/stainless-api/stainless-api-go" ) +var ( + headerLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("6")).Bold(true) + headerIDStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) + +// ViewHeader renders a styled header with a label badge, build ID, config commit, and relative timestamp. +func ViewHeader(label string, b stainless.Build) string { + var s strings.Builder + s.WriteString("\n") + s.WriteString(headerLabelStyle.Render(" " + label + " ")) + if b.ID != "" { + s.WriteString(" ") + s.WriteString(headerIDStyle.Render(b.ID)) + } + configCommit := b.ConfigCommit + if len(configCommit) > 7 { + configCommit = configCommit[:7] + } + if configCommit != "" { + s.WriteString(" ") + s.WriteString(headerIDStyle.Render(configCommit)) + } + if !b.CreatedAt.IsZero() { + s.WriteString(" ") + s.WriteString(headerIDStyle.Render(relativeTime(b.CreatedAt))) + } + s.WriteString("\n") + return s.String() +} + +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + m := int(d.Minutes()) + if m == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", m) + case d < 24*time.Hour: + h := int(d.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + default: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} + func (m Model) View() string { if m.Err != nil { return m.Err.Error() } - return View(m.Build, m.Downloads, m.CommitOnly) -} - -func View(build stainless.Build, downloads map[stainless.Target]DownloadStatus, commitOnly bool) string { s := strings.Builder{} - buildObj := stainlessutils.NewBuild(build) + buildObj := stainlessutils.NewBuild(m.Build) languages := buildObj.Languages() - // Target rows with colors for _, target := range languages { - pipeline := ViewBuildPipeline(build, target, downloads, commitOnly) - langStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - - s.WriteString(fmt.Sprintf("%s %s\n", langStyle.Render(fmt.Sprintf("%-13s", string(target))), pipeline)) + s.WriteString(ViewBuildPipeline(m.Build, target, m.Downloads, m.CommitOnly, m.Spinner)) } - // s.WriteString("\n") - - // completed := 0 - // building := 0 - // for _, target := range languages { - // buildTarget := buildObj.BuildTarget(target) - // if buildTarget != nil { - // if buildTarget.IsCompleted() { - // completed++ - // } else if buildTarget.IsInProgress() { - // building++ - // } - // } - // } - - // statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - // statusText := fmt.Sprintf("%d completed, %d building, %d pending\n", - // completed, building, len(languages)-completed-building) - // s.WriteString(statusStyle.Render(statusText)) - return s.String() } -// View renders the build pipeline for a target -func ViewBuildPipeline(build stainless.Build, target stainless.Target, downloads map[stainless.Target]DownloadStatus, commitOnly bool) string { +// commitStatusWidth is the fixed visible width for the commit status column, +// based on the longest expected content: "71d249c (unchanged) with error diagnostic(s)" +const commitStatusWidth = 44 + +// ViewBuildPipeline renders the build pipeline for a target on a single line. +// Format: +func ViewBuildPipeline(build stainless.Build, target stainless.Target, downloads map[stainless.Target]DownloadStatus, commitOnly bool, sp spinner.Model) string { + langStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + buildObj := stainlessutils.NewBuild(build) buildTarget := buildObj.BuildTarget(target) if buildTarget == nil { return "" } - stepOrder := buildTarget.Steps() - var pipeline strings.Builder - - for _, step := range stepOrder { - if commitOnly && step != "commit" { - continue - } - status, url, conclusion := buildTarget.StepInfo(step) - if status == "" { - continue // Skip steps that don't exist for this target - } - if pipeline.Len() > 0 { - pipeline.WriteString(" ") + // Build commit status text + var commitStatus strings.Builder + commitStep := buildTarget.Commit + switch commitStep.Status { + case "", "not_started", "queued": + commitStatus.WriteString(grayStyle.Render("queued")) + case "in_progress": + commitStatus.WriteString(grayStyle.Render("generating ") + sp.View()) + case "completed": + conclusion := commitStep.Conclusion + switch conclusion { + case "merge_conflict", "upstream_merge_conflict": + pr := commitStep.MergeConflictPr + prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%.0f", pr.Repo.Owner, pr.Repo.Name, pr.Number) + commitStatus.WriteString(yellowStyle.Render(console.Hyperlink(prURL, fmt.Sprintf("merge conflict #%.0f", pr.Number)))) + case "fatal": + commitStatus.WriteString(redStyle.Render("fatal error")) + case "payment_required": + commitStatus.WriteString(redStyle.Render("payment required")) + case "cancelled": + commitStatus.WriteString(grayStyle.Render("cancelled")) + case "timed_out": + commitStatus.WriteString(redStyle.Render("timed out")) + case "noop": + commitStatus.WriteString(grayStyle.Render("no-op")) + case "success", "note", "warning", "error", "version_bump": + // These conclusions all produce a commit + commit := commitStep.Commit + sha := commit.Sha + if len(sha) > 7 { + sha = sha[:7] + } + commitURL := fmt.Sprintf("https://github.com/%s/%s/commit/%s", commit.Repo.Owner, commit.Repo.Name, commit.Sha) + additions := commit.Stats.Additions + deletions := commit.Stats.Deletions + commitStatus.WriteString(console.Hyperlink(commitURL, sha)) + if additions > 0 || deletions > 0 { + commitStatus.WriteString(" " + grayStyle.Render("(") + + greenStyle.Render(fmt.Sprintf("+%d", additions)) + + grayStyle.Render("/") + + redStyle.Render(fmt.Sprintf("-%d", deletions)) + + grayStyle.Render(")")) + } else { + commitStatus.WriteString(" " + grayStyle.Render("(unchanged)")) + } + switch conclusion { + case "error": + commitStatus.WriteString(" with " + redStyle.Render("error") + " diagnostic(s)") + case "warning": + commitStatus.WriteString(" with " + yellowStyle.Render("warning") + " diagnostic(s)") + } + default: + commitStatus.WriteString(grayStyle.Render(conclusion)) } - // align our naming of the commit step with the version in the Studio - if step == "commit" { - step = "codegen" + } + + // Pad commit status to fixed width so step symbols align vertically + statusStr := commitStatus.String() + if pad := commitStatusWidth - lipgloss.Width(statusStr); pad > 0 { + statusStr += strings.Repeat(" ", pad) + } + + // Build the line + var line strings.Builder + line.WriteString(langStyle.Render(fmt.Sprintf("%-13s", string(target))) + " ") + line.WriteString(statusStr) + + // Collect post-commit steps + download (only when commit step is completed) + var stepParts []string + if !commitOnly && commitStep.Status == "completed" { + for _, step := range buildTarget.Steps() { + if step == "commit" { + continue + } + stepStatus, stepURL, stepConclusion := buildTarget.StepInfo(step) + if stepStatus == "" { + continue + } + stepLabel := step + if stepURL != "" { + stepLabel = console.Hyperlink(stepURL, step) + } + stepParts = append(stepParts, ViewStepSymbol(stepStatus, stepConclusion)+" "+stepLabel) } - pipeline.WriteString(ViewStepSymbol(status, conclusion) + " " + console.Hyperlink(url, step)) } if download, ok := downloads[target]; ok { - pipeline.WriteString(" " + ViewStepSymbol(download.Status, download.Conclusion) + " " + "download") + downloadLabel := "download" + if download.Path != "" { + displayPath := download.Path + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, displayPath); err == nil { + displayPath = rel + } + } + downloadLabel += " " + grayStyle.Render("("+displayPath+")") + } + stepParts = append(stepParts, ViewStepSymbol(download.Status, download.Conclusion)+" "+downloadLabel) if download.Conclusion == "failure" && download.Error != "" { errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - pipeline.WriteString("\n" + errorStyle.Render(" Error: "+download.Error)) + line.WriteString(" " + strings.Join(stepParts, " ")) + line.WriteString("\n" + errorStyle.Render(" Error: "+download.Error)) + line.WriteString("\n") + return line.String() } } - return pipeline.String() + if len(stepParts) > 0 { + line.WriteString(" " + strings.Join(stepParts, " ")) + } + line.WriteString("\n") + + return line.String() } // ViewStepSymbol returns a colored symbol for a build step status @@ -114,6 +240,8 @@ func ViewStepSymbol(status, conclusion string) string { return redStyle.Render("⚠") case "fatal": return redStyle.Render("✗") + case "cancelled", "skipped": + return grayStyle.Render("⊘") case "merge_conflict", "upstream_merge_conflict": return yellowStyle.Render("m") default: diff --git a/pkg/components/build/view_test.go b/pkg/components/build/view_test.go new file mode 100644 index 0000000..b4135e1 --- /dev/null +++ b/pkg/components/build/view_test.go @@ -0,0 +1,197 @@ +package build + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stainless-api/stainless-api-go" +) + +var update = flag.Bool("update", false, "update snapshot files") + +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.ANSI) + os.Exit(m.Run()) +} + +func mustBuild(t *testing.T, jsonStr string) stainless.Build { + t.Helper() + var b stainless.Build + if err := json.Unmarshal([]byte(jsonStr), &b); err != nil { + t.Fatalf("failed to unmarshal build JSON: %v", err) + } + return b +} + +func newSpinner() spinner.Model { + return spinner.New() +} + +// snapshot compares got against the snapshot file testdata/.snapshot. +// When -update is passed, it writes/overwrites the snapshot file instead. +func snapshot(t *testing.T, name string, got string) { + t.Helper() + path := filepath.Join("testdata", name+".snapshot") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("snapshot file %s not found; run with -update to create it: %v", path, err) + } + if string(want) != got { + t.Errorf("snapshot mismatch for %s\nwant: %q\ngot: %q\nrun with -update to update", name, string(want), got) + } +} + +const checkSteps = `"lint": {"status": "not_started"}, "build": {"status": "not_started"}, "test": {"status": "not_started"}` + +func TestViewBuildPipeline(t *testing.T) { + sp := newSpinner() + var out strings.Builder + dl := map[stainless.Target]DownloadStatus{"typescript": {Status: "not_started"}} + + // queued (not_started) + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "not_started"}, `+checkSteps+`, "status": "not_started", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // queued (queued status) + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "queued"}, `+checkSteps+`, "status": "not_started", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // in_progress + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "in_progress"}, `+checkSteps+`, "status": "codegen", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // success with changes + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 100, "deletions": 30, "total": 130}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // success unchanged + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 0, "deletions": 0, "total": 0}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // warning conclusion + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "warning", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 50, "deletions": 10, "total": 60}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // error conclusion + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "error", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 50, "deletions": 10, "total": 60}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // fatal + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "fatal"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // merge_conflict + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "merge_conflict", "merge_conflict_pr": {"number": 42, "repo": {"owner": "org", "name": "repo"}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // payment_required + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "payment_required"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // cancelled + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "cancelled"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // timed_out + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "timed_out"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // noop + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "noop"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // version_bump + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "version_bump", "commit": {"sha": "def5678901234", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 3, "deletions": 3, "total": 6}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // post-commit steps (lint/build/test in various states) + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, "lint": {"status": "not_started"}, "build": {"status": "in_progress"}, "test": {"status": "completed", "conclusion": "success", "url": ""}, "status": "postgen", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // commitOnly hides post-commit steps + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, `+checkSteps+`, "status": "postgen", "object": "build_target", "install_url": ""}} + }`), "typescript", nil, true, sp)) + out.WriteString("\n\n") + + // download success + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", map[stainless.Target]DownloadStatus{"typescript": {Status: "completed", Conclusion: "success"}}, true, sp)) + out.WriteString("\n\n") + + // download failure + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", map[stainless.Target]DownloadStatus{"typescript": {Status: "completed", Conclusion: "failure", Error: "connection refused"}}, true, sp)) + out.WriteString("\n\n") + + // nil target + out.WriteString(ViewBuildPipeline(mustBuild(t, `{"id": "build_1", "targets": {}}`), "typescript", nil, false, sp)) + + snapshot(t, "view_build_pipeline", out.String()) +} diff --git a/pkg/components/dev/model.go b/pkg/components/dev/model.go index 61aac63..95a191a 100644 --- a/pkg/components/dev/model.go +++ b/pkg/components/dev/model.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/stainless-api/stainless-api-cli/pkg/components/build" "github.com/stainless-api/stainless-api-cli/pkg/components/diagnostics" @@ -36,15 +37,25 @@ type Model struct { type ErrorMsg error type FileChangeMsg struct{} -func NewModel(client stainless.Client, ctx context.Context, branch string, fn func() (*stainless.Build, error), downloadPaths map[stainless.Target]string, watch bool) Model { +type ModelConfig struct { + Client stainless.Client + Ctx context.Context + Branch string + Start func() (*stainless.Build, error) + DownloadPaths map[stainless.Target]string + Watch bool +} + +func NewModel(cfg ModelConfig) Model { return Model{ - start: fn, - Client: client, - Ctx: ctx, - Branch: branch, + start: cfg.Start, + Client: cfg.Client, + Ctx: cfg.Ctx, + Branch: cfg.Branch, + Watch: cfg.Watch, Help: help.New(), - Build: build.NewModel(client, ctx, stainless.Build{}, branch, downloadPaths), - Diagnostics: diagnostics.NewModel(client, ctx, nil), + Build: build.NewModel(cfg.Client, cfg.Ctx, stainless.Build{}, cfg.Branch, cfg.DownloadPaths), + Diagnostics: diagnostics.NewModel(cfg.Client, cfg.Ctx, nil), } } @@ -81,7 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - case build.TickMsg, build.DownloadMsg, build.ErrorMsg: + case build.TickMsg, build.DownloadMsg, build.ErrorMsg, spinner.TickMsg: m.Build, cmd = m.Build.Update(msg) cmds = append(cmds, cmd) diff --git a/pkg/components/dev/view.go b/pkg/components/dev/view.go index c9dd85a..e582889 100644 --- a/pkg/components/dev/view.go +++ b/pkg/components/dev/view.go @@ -7,14 +7,15 @@ import ( "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/stainless-api/stainless-api-cli/pkg/components/build" "github.com/stainless-api/stainless-api-cli/pkg/console" ) -func (m Model) View() string { - if m.Err != nil { - return m.Err.Error() - } +var ( + grayStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) +func (m Model) View() string { s := strings.Builder{} idx := slices.IndexFunc(parts, func(part ViewPart) bool { @@ -25,6 +26,10 @@ func (m Model) View() string { parts[i].View(&m, &s) } + if m.Err != nil && m.Err != ErrUserCancelled { + s.WriteString("\n" + m.Err.Error() + "\n") + } + return s.String() } @@ -38,38 +43,38 @@ var parts = []ViewPart{ { Name: "header", View: func(m *Model, s *strings.Builder) { - buildIDStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("6")).Bold(true) - if m.Build.ID != "" { - fmt.Fprintf(s, "\n\n%s %s\n\n", buildIDStyle.Render(" BUILD "), m.Build.ID) - } else { - fmt.Fprintf(s, "\n\n%s\n\n", buildIDStyle.Render(" BUILD ")) - } + s.WriteString(build.ViewHeader("PREVIEW", m.Build.Build)) }, }, { Name: "build diagnostics", View: func(m *Model, s *strings.Builder) { - if m.Diagnostics.Diagnostics == nil { - s.WriteString(console.SProperty(0, "build diagnostics", "(waiting for build to finish)")) - } else { + if m.Diagnostics.Diagnostics != nil { + s.WriteString("\n") s.WriteString(m.Diagnostics.View()) } }, }, { - Name: "studio", + Name: "build_status", View: func(m *Model, s *strings.Builder) { - if m.Build.ID != "" { - url := fmt.Sprintf("https://app.stainless.com/%s/%s/studio?branch=%s", m.Build.Org, m.Build.Project, m.Branch) - s.WriteString(console.SProperty(0, "studio", console.Hyperlink(url, url))) + s.WriteString("\n") + if m.Build.ID == "" { + s.WriteString(m.Build.Spinner.View() + " " + grayStyle.Render("Creating build...") + "\n") + } else { + s.WriteString(m.Build.View()) } }, }, { - Name: "build_status", + Name: "studio", View: func(m *Model, s *strings.Builder) { - s.WriteString("\n") - s.WriteString(m.Build.View()) + if m.Build.ID != "" { + url := fmt.Sprintf("https://app.stainless.com/%s/%s/studio?branch=%s", m.Build.Org, m.Build.Project, m.Branch) + s.WriteString("\n") + s.WriteString(grayStyle.Render(console.Hyperlink(url, "Open in Studio"))) + s.WriteString("\n") + } }, }, { diff --git a/pkg/components/diagnostics/model.go b/pkg/components/diagnostics/model.go index 3cb892e..8e3af26 100644 --- a/pkg/components/diagnostics/model.go +++ b/pkg/components/diagnostics/model.go @@ -5,16 +5,18 @@ import ( "errors" tea "github.com/charmbracelet/bubbletea" + "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go" ) var ErrUserCancelled = errors.New("user cancelled") type Model struct { - Diagnostics []stainless.BuildDiagnostic - Client stainless.Client - Ctx context.Context - Err error + Diagnostics []stainless.BuildDiagnostic + Client stainless.Client + Ctx context.Context + Err error + WorkspaceConfig workspace.Config } type FetchDiagnosticsMsg []stainless.BuildDiagnostic @@ -58,7 +60,10 @@ func (m Model) View() string { if m.Diagnostics == nil { return "" } - return ViewDiagnostics(m.Diagnostics, 10) + return ViewDiagnostics(m.Diagnostics, 10, + workspace.Relative(m.WorkspaceConfig.OpenAPISpec), + workspace.Relative(m.WorkspaceConfig.StainlessConfig), + ) } func (m Model) FetchDiagnostics(buildID string) tea.Cmd { diff --git a/pkg/components/diagnostics/testdata/view_diagnostics.snapshot b/pkg/components/diagnostics/testdata/view_diagnostics.snapshot new file mode 100644 index 0000000..6cf3c13 --- /dev/null +++ b/pkg/components/diagnostics/testdata/view_diagnostics.snapshot @@ -0,0 +1,27 @@ +(no diagnostics) + +(no diagnostics) + +error: failed to fetch diagnostics: connection refused + +error[MissingField]: The field 'name' is required but missing + --> openapi.yml:6:16: /paths/~1users/post/requestBody + --> stainless.yml:7:15: /endpoints/~1users/post + +fatal[FatalError]: Build failed due to configuration error + --> openapi.yml:4:9: /paths/~1users + Check your stainless.yml for syntax errors. + See docs for details. + +warning[DeprecatedUsage]: The x-deprecated extension is deprecated + --> openapi.yml:12:16: /paths/~1foo/get + +... and 1 more diagnostics + +error[MissingField]: Field 'id' is required + --> specs/openapi.json:5:16: /paths/~1pets/get + --> .stainless/stainless.yaml:4:15: /endpoints/~1pets/get + +error[BothSpecifiedAndUnspecified]: Endpoint is in both places + --> openapi.json:6:16: #/paths/%2Fusers/post/requestBody + --> openapi.stainless.yml:4:15: #/endpoints/%2Fusers/post diff --git a/pkg/components/diagnostics/view.go b/pkg/components/diagnostics/view.go index 542fe62..0a7fd25 100644 --- a/pkg/components/diagnostics/view.go +++ b/pkg/components/diagnostics/view.go @@ -2,162 +2,310 @@ package diagnostics import ( "fmt" + "net/url" "os" + "path/filepath" + "strconv" "strings" - "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" - "github.com/stainless-api/stainless-api-cli/pkg/console" + "github.com/goccy/go-yaml/ast" + "github.com/goccy/go-yaml/parser" "github.com/stainless-api/stainless-api-go" - "golang.org/x/term" ) -// ViewDiagnosticsError renders an error when fetching diagnostics fails -func ViewDiagnosticsError(err error) string { - var s strings.Builder - errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - s.WriteString(console.SProperty(0, "build diagnostics", errorStyle.Render("(error: "+err.Error()+")"))) - return s.String() +var ( + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) + noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + codeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + refStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) + +type sourceResolver struct { + parsed map[string]parsedSource } -// ViewDiagnosticIcon returns a colored icon for a diagnostic level -func ViewDiagnosticIcon(level stainless.BuildDiagnosticLevel) string { +type parsedSource struct { + file *ast.File + err error +} + +// levelLabel returns the colored level prefix and bracket-wrapped code for a diagnostic. +func levelLabel(level stainless.BuildDiagnosticLevel, code string) string { + var levelStr string switch level { case stainless.BuildDiagnosticLevelFatal: - return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true).Render("(F)") + levelStr = errorStyle.Render("fatal") + code = errorStyle.UnsetBold().Render("[" + code + "]") case stainless.BuildDiagnosticLevelError: - return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("(E)") + levelStr = errorStyle.Render("error") + code = errorStyle.UnsetBold().Render("[" + code + "]") case stainless.BuildDiagnosticLevelWarning: - return lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("(W)") + levelStr = warningStyle.Render("warning") + code = warningStyle.UnsetBold().Render("[" + code + "]") case stainless.BuildDiagnosticLevelNote: - return lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Render("(i)") + levelStr = noteStyle.Render("note") + code = noteStyle.Render("[" + code + "]") default: - return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("•") + levelStr = code + code = "" + } + if code != "" { + return levelStr + code } + return levelStr } -var renderer *glamour.TermRenderer - -func init() { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 || width > 120 { - width = 120 - } - renderer, _ = glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(width), - ) +// ViewDiagnosticsError renders an error when fetching diagnostics fails +func ViewDiagnosticsError(err error) string { + return errorStyle.Render("error") + ": failed to fetch diagnostics: " + err.Error() + "\n" } -// renderMarkdown renders markdown content using glamour -func renderMarkdown(content string) string { - if renderer == nil { - return content +// ViewDiagnostics renders build diagnostics in Rust-style formatting. +// Notes are hidden by default. oasPath and configPath should be display paths, +// typically relative to the current working directory. +func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int, oasPath, configPath string) string { + // Filter out notes + var visible []stainless.BuildDiagnostic + for _, d := range diagnostics { + if d.Level != stainless.BuildDiagnosticLevelNote { + visible = append(visible, d) + } } - rendered, err := renderer.Render(content) - if err != nil { - return content + if len(visible) == 0 { + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + return grayStyle.Render("(no diagnostics)") + "\n" } - return strings.Trim(rendered, "\n ") -} + var s strings.Builder + resolver := sourceResolver{parsed: map[string]parsedSource{}} -// countDiagnosticsBySeverity counts diagnostics by severity level -func countDiagnosticsBySeverity(diagnostics []stainless.BuildDiagnostic) (fatal, errors, warnings, notes int) { - for _, diag := range diagnostics { - switch diag.Level { - case stainless.BuildDiagnosticLevelFatal: - fatal++ - case stainless.BuildDiagnosticLevelError: - errors++ - case stainless.BuildDiagnosticLevelWarning: - warnings++ - case stainless.BuildDiagnosticLevelNote: - notes++ - } + truncated := false + shown := len(visible) + if maxDiagnostics >= 0 && len(visible) > maxDiagnostics { + truncated = true + shown = maxDiagnostics } - return -} -// ViewDiagnostics renders build diagnostics with formatting -func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int) string { - var s strings.Builder + rendered := 0 + for _, diag := range visible { + if maxDiagnostics >= 0 && rendered >= maxDiagnostics { + break + } - if len(diagnostics) > 0 { - // Count diagnostics by severity - fatal, errors, warnings, notes := countDiagnosticsBySeverity(diagnostics) + if rendered > 0 { + s.WriteString("\n") + } + rendered++ + + // Header: error[Code]: message + s.WriteString(levelLabel(diag.Level, diag.Code)) + s.WriteString(": ") + s.WriteString(diag.Message) + s.WriteString("\n") - // Create summary string - var summaryParts []string - if fatal > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d fatal", fatal)) + // Source references + if diag.OasRef != "" { + s.WriteString(refStyle.Render(" --> " + resolver.resolveRef(oasPath, "openapi.yml", diag.OasRef))) + s.WriteString("\n") } - if errors > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d errors", errors)) + if diag.ConfigRef != "" { + s.WriteString(refStyle.Render(" --> " + resolver.resolveRef(configPath, "stainless.yml", diag.ConfigRef))) + s.WriteString("\n") } - if warnings > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d warnings", warnings)) + + // Additional content from More field + if diag.More.AsAny() != nil { + switch more := diag.More.AsAny().(type) { + case stainless.BuildDiagnosticMoreMarkdown: + text := strings.TrimSpace(more.Markdown) + if text != "" { + for _, line := range strings.Split(text, "\n") { + s.WriteString(" ") + s.WriteString(line) + s.WriteString("\n") + } + } + case stainless.BuildDiagnosticMoreRaw: + text := strings.TrimSpace(more.Raw) + if text != "" { + for _, line := range strings.Split(text, "\n") { + s.WriteString(" ") + s.WriteString(line) + s.WriteString("\n") + } + } + } } - if notes > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d notes", notes)) + } + + if truncated { + s.WriteString(fmt.Sprintf("\n... and %d more diagnostics\n", len(visible)-shown)) + } + + return s.String() +} + +func (r *sourceResolver) resolveRef(path, fallbackLabel, pointer string) string { + label := sourceLabel(path, fallbackLabel) + if line, column, ok := r.resolvePointer(path, pointer); ok { + return fmt.Sprintf("%s:%d:%d: %s", label, line, column, pointer) + } + return label + ": " + pointer +} + +func sourceLabel(path, fallbackLabel string) string { + if path == "" { + return fallbackLabel + } + return path +} + +func (r *sourceResolver) resolvePointer(displayPath, pointer string) (int, int, bool) { + path, ok := resolveSourcePath(displayPath) + if !ok { + return 0, 0, false + } + + parsed, ok := r.parsed[path] + if !ok { + content, err := os.ReadFile(path) + if err != nil { + parsed = parsedSource{err: err} + } else { + file, err := parser.ParseBytes(content, 0) + parsed = parsedSource{file: file, err: err} } + r.parsed[path] = parsed + } + if parsed.err != nil || parsed.file == nil { + return 0, 0, false + } + + node, ok := resolveJSONPointer(parsed.file, pointer) + if !ok { + return 0, 0, false + } - summary := strings.Join(summaryParts, ", ") - if summary != "" { - summary = fmt.Sprintf(" (%s)", summary) + token := node.GetToken() + if token == nil || token.Position == nil { + return 0, 0, false + } + return token.Position.Line, token.Position.Column, true +} + +func resolveSourcePath(displayPath string) (string, bool) { + if displayPath == "" { + return "", false + } + + path, err := filepath.Abs(displayPath) + if err != nil { + return "", false + } + return path, true +} + +func resolveJSONPointer(file *ast.File, pointer string) (ast.Node, bool) { + node := firstDocumentNode(file) + if node == nil { + return nil, false + } + + segments, ok := parseJSONPointer(pointer) + if !ok { + return nil, false + } + + for _, segment := range segments { + var found bool + node, found = descendNode(node, segment) + if !found { + return nil, false } + } - var sub strings.Builder + return node, true +} - if maxDiagnostics >= 0 && len(diagnostics) > maxDiagnostics { - sub.WriteString(fmt.Sprintf("Showing first %d of %d diagnostics:\n", maxDiagnostics, len(diagnostics))) +func firstDocumentNode(file *ast.File) ast.Node { + for _, doc := range file.Docs { + if doc.Body == nil || doc.Body.Type() == ast.DirectiveType { + continue } + return doc.Body + } + return nil +} - for i, diag := range diagnostics { - if maxDiagnostics >= 0 && i >= maxDiagnostics { - break - } +func parseJSONPointer(pointer string) ([]string, bool) { + if pointer == "" || pointer == "#" { + return nil, true + } - levelIcon := ViewDiagnosticIcon(diag.Level) - codeStyle := lipgloss.NewStyle().Bold(true) + switch { + case strings.HasPrefix(pointer, "#/"): + pointer = pointer[1:] + case !strings.HasPrefix(pointer, "/"): + return nil, false + } - if i > 0 { - sub.WriteString("\n") - } - sub.WriteString(fmt.Sprintf("%s %s\n", levelIcon, codeStyle.Render(diag.Code))) - sub.WriteString(fmt.Sprintf("%s\n", renderMarkdown(diag.Message))) - - if diag.Code == "FatalError" { - switch more := diag.More.AsAny().(type) { - case stainless.BuildDiagnosticMoreMarkdown: - sub.WriteString(fmt.Sprintf("%s\n", renderMarkdown(more.Markdown))) - case stainless.BuildDiagnosticMoreRaw: - sub.WriteString(fmt.Sprintf("%s\n", more.Raw)) - } - } + parts := strings.Split(pointer[1:], "/") + segments := make([]string, 0, len(parts)) + for _, part := range parts { + unescaped, err := url.PathUnescape(part) + if err != nil { + return nil, false + } + part = unescaped + part = strings.ReplaceAll(part, "~1", "/") + part = strings.ReplaceAll(part, "~0", "~") + segments = append(segments, part) + } + return segments, true +} - // Show source references if available - if diag.OasRef != "" { - refStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - sub.WriteString(fmt.Sprintf(" %s\n", refStyle.Render("OpenAPI: "+diag.OasRef))) - } - if diag.ConfigRef != "" { - refStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - sub.WriteString(fmt.Sprintf(" %s\n", refStyle.Render("Config: "+diag.ConfigRef))) +func descendNode(node ast.Node, segment string) (ast.Node, bool) { + switch node := node.(type) { + case *ast.MappingNode: + for _, value := range node.Values { + if mapKeyString(value.Key) == segment { + return value.Value, true } } + case *ast.SequenceNode: + idx, err := strconv.Atoi(segment) + if err != nil || idx < 0 || idx >= len(node.Values) { + return nil, false + } + return node.Values[idx], true + } + return nil, false +} - s.WriteString(console.SProperty(0, "build diagnostics", summary)) - s.WriteString(lipgloss.NewStyle(). - Padding(0). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("208")). - Render(strings.TrimRight(sub.String(), "\n")), - ) - } else { - s.WriteString(console.SProperty(0, "build diagnostics", "(no errors or warnings)")) +func mapKeyString(key ast.MapKeyNode) string { + if key == nil || key.GetToken() == nil { + return "" } - return s.String() + value := key.GetToken().Value + if len(value) == 0 { + return value + } + + switch value[0] { + case '"': + unquoted, err := strconv.Unquote(value) + if err == nil { + return unquoted + } + case '\'': + if len(value) > 1 && value[len(value)-1] == '\'' { + return value[1 : len(value)-1] + } + } + + return value } diff --git a/pkg/components/diagnostics/view_test.go b/pkg/components/diagnostics/view_test.go new file mode 100644 index 0000000..84c9470 --- /dev/null +++ b/pkg/components/diagnostics/view_test.go @@ -0,0 +1,252 @@ +package diagnostics + +import ( + "encoding/json" + "errors" + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stainless-api/stainless-api-go" +) + +var update = flag.Bool("update", false, "update snapshot files") +var packageDir string + +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.ANSI) + var err error + packageDir, err = os.Getwd() + if err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func mustDiags(t *testing.T, jsonStr string) []stainless.BuildDiagnostic { + t.Helper() + var d []stainless.BuildDiagnostic + if err := json.Unmarshal([]byte(jsonStr), &d); err != nil { + t.Fatalf("failed to unmarshal diagnostics JSON: %v", err) + } + return d +} + +func snapshot(t *testing.T, name string, got string) { + t.Helper() + path := filepath.Join(packageDir, "testdata", name+".snapshot") + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("snapshot file %s not found; run with -update to create it: %v", path, err) + } + if string(want) != got { + t.Errorf("snapshot mismatch for %s\nwant: %q\ngot: %q\nrun with -update to update", name, string(want), got) + } +} + +func TestViewDiagnostics(t *testing.T) { + var out strings.Builder + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + + oasPath := "openapi.yml" + if err := os.WriteFile(oasPath, []byte(strings.TrimSpace(` +openapi: 3.1.0 +paths: + /users: + post: + requestBody: + content: + application/json: + schema: + type: object + /foo: + get: + responses: + "200": + description: ok + /pets: + get: + responses: + "200": + description: ok +`)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + configPath := "stainless.yml" + if err := os.WriteFile(configPath, []byte(strings.TrimSpace(` +targets: + typescript: + package_name: example +endpoints: + /users: + post: + settings: + name: users-post + /pets: + get: + settings: + name: pets-get +`)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll("specs", 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(".stainless", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("specs", "openapi.json"), []byte(strings.TrimSpace(` +openapi: 3.1.0 +paths: + /pets: + get: + responses: + "200": + description: ok +`)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".stainless", "stainless.yaml"), []byte(strings.TrimSpace(` +endpoints: + /pets: + get: + settings: + name: pets-get +`)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("openapi.json", []byte(strings.TrimSpace(` +openapi: 3.1.0 +paths: + /users: + post: + requestBody: + content: + application/json: + schema: + type: object +`)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("openapi.stainless.yml", []byte(strings.TrimSpace(` +endpoints: + /users: + post: + settings: + name: users-post +`)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + // no diagnostics + out.WriteString(ViewDiagnostics(nil, 10, "", "")) + out.WriteString("\n") + + // notes only (hidden, treated as empty) + out.WriteString(ViewDiagnostics(mustDiags(t, `[ + {"code": "StyleSuggestion", "level": "note", "message": "Consider camelCase", "ignored": false, "more": null} + ]`), 10, "", "")) + out.WriteString("\n") + + // fetch error + out.WriteString(ViewDiagnosticsError(errors.New("connection refused"))) + out.WriteString("\n") + + // mixed: errors, warnings, notes, refs, more content, truncation (default labels) + out.WriteString(ViewDiagnostics(mustDiags(t, `[ + { + "code": "MissingField", + "level": "error", + "message": "The field 'name' is required but missing", + "ignored": false, + "more": null, + "oas_ref": "/paths/~1users/post/requestBody", + "config_ref": "/endpoints/~1users/post" + }, + { + "code": "FatalError", + "level": "fatal", + "message": "Build failed due to configuration error", + "ignored": false, + "more": {"type": "markdown", "markdown": "Check your stainless.yml for syntax errors.\nSee docs for details."}, + "oas_ref": "/paths/~1users" + }, + { + "code": "DeprecatedUsage", + "level": "warning", + "message": "The x-deprecated extension is deprecated", + "ignored": false, + "more": null, + "oas_ref": "/paths/~1foo/get" + }, + { + "code": "StyleSuggestion", + "level": "note", + "message": "Consider using camelCase", + "ignored": false, + "more": null + }, + { + "code": "Err3", + "level": "error", + "message": "Truncated away", + "ignored": false, + "more": null + } + ]`), 3, oasPath, configPath)) + out.WriteString("\n") + + // custom labels (e.g. relative paths from workspace config) + out.WriteString(ViewDiagnostics(mustDiags(t, `[ + { + "code": "MissingField", + "level": "error", + "message": "Field 'id' is required", + "ignored": false, + "more": null, + "oas_ref": "/paths/~1pets/get", + "config_ref": "/endpoints/~1pets/get" + } + ]`), 10, "specs/openapi.json", ".stainless/stainless.yaml")) + out.WriteString("\n") + + // fragment-style refs with percent-encoded path keys + out.WriteString(ViewDiagnostics(mustDiags(t, `[ + { + "code": "BothSpecifiedAndUnspecified", + "level": "error", + "message": "Endpoint is in both places", + "ignored": false, + "more": null, + "oas_ref": "#/paths/%2Fusers/post/requestBody", + "config_ref": "#/endpoints/%2Fusers/post" + } + ]`), 10, "openapi.json", "openapi.stainless.yml")) + + snapshot(t, "view_diagnostics", out.String()) +} diff --git a/pkg/git/git.go b/pkg/git/git.go index 1f35dcd..27a0368 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -79,3 +79,29 @@ func Fetch(dir, url string, refspecs ...string) error { } return nil } + +// Show returns the contents of a file at a given ref +func Show(dir, ref, path string) ([]byte, error) { + cmd := exec.Command("git", "-C", dir, "show", ref+":"+path) + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil, err + } + return stdout.Bytes(), nil +} + +// CurrentBranch returns the current branch name +func CurrentBranch(dir string) (string, error) { + cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + branch := strings.TrimSpace(stdout.String()) + if branch == "" { + return "", fmt.Errorf("could not determine current git branch") + } + return branch, nil +} diff --git a/pkg/stainlessutils/stainlessutils.go b/pkg/stainlessutils/stainlessutils.go index 5e02012..848c8df 100644 --- a/pkg/stainlessutils/stainlessutils.go +++ b/pkg/stainlessutils/stainlessutils.go @@ -188,26 +188,26 @@ func (bt *BuildTarget) StepInfo(step string) (status, url, conclusion string) { if u, ok := stepUnion.(stainless.BuildTargetCommitUnion); ok { status = u.Status if u.Status == "completed" { - conclusion = u.Completed.Conclusion + conclusion = u.Conclusion // Use merge conflict PR URL if available, otherwise use commit URL - if u.Completed.JSON.MergeConflictPr.Valid() { + if u.JSON.MergeConflictPr.Valid() { url = fmt.Sprintf("https://github.com/%s/%s/pull/%.0f", - u.Completed.MergeConflictPr.Repo.Owner, - u.Completed.MergeConflictPr.Repo.Name, - u.Completed.MergeConflictPr.Number) - } else if u.Completed.JSON.Commit.Valid() { + u.MergeConflictPr.Repo.Owner, + u.MergeConflictPr.Repo.Name, + u.MergeConflictPr.Number) + } else if u.JSON.Commit.Valid() { url = fmt.Sprintf("https://github.com/%s/%s/commit/%s", - u.Completed.Commit.Repo.Owner, - u.Completed.Commit.Repo.Name, - u.Completed.Commit.Sha) + u.Commit.Repo.Owner, + u.Commit.Repo.Name, + u.Commit.Sha) } } } if u, ok := stepUnion.(stainless.CheckStepUnion); ok { status = u.Status + url = u.URL if u.Status == "completed" { - conclusion = u.Completed.Conclusion - url = u.Completed.URL + conclusion = u.Conclusion } } return diff --git a/pkg/workspace/config.go b/pkg/workspace/config.go index d64653d..c17318f 100644 --- a/pkg/workspace/config.go +++ b/pkg/workspace/config.go @@ -18,6 +18,10 @@ func Resolve(baseDir, path string) string { } func Relative(path string) string { + if path == "" { + return "" + } + cwd, err := os.Getwd() if err != nil { return path diff --git a/scripts/build-demo-gif b/scripts/build-demo-gif new file mode 100755 index 0000000..a6c143c --- /dev/null +++ b/scripts/build-demo-gif @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." +REPO_ROOT="$(pwd)" + +PIDS=() +SERVERS=() + +kill_tree() { + local pid=$1 + # Kill children first (the actual server under go run), then the parent + pkill -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true +} + +cleanup() { + for pid in "${SERVERS[@]}"; do + kill_tree "$pid" + done + for pid in "${PIDS[@]}"; do + wait "$pid" 2>/dev/null || true + done + rm -f /tmp/stl + rm -rf /tmp/stl-demo-* +} +trap cleanup EXIT + +echo "==> Building stl" +go build -o /tmp/stl ./cmd/stl + +wait_for_server() { + local port=$1 + local attempts=0 + while ! curl -sf "http://localhost:${port}/health" > /dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 50 ]; then + echo "Timed out waiting for demo server on port ${port}" + exit 1 + fi + sleep 0.1 + done +} + +# Record a single tape with its own isolated server and HOME directory. +# Usage: record_tape +record_tape() { + local tape=$1 + local port=$2 + local name + name=$(basename "$tape" .tape) + local home="/tmp/stl-demo-${name}" + + # Start an isolated server + go run "${REPO_ROOT}/internal/cmd/mock-server" -port "$port" & + local server_pid=$! + SERVERS+=("$server_pid") + wait_for_server "$port" + + # Create isolated HOME with pre-created auth config + mkdir -p "${home}/.config/stainless" + cat > "${home}/.config/stainless/auth.json" <<'AUTHEOF' +{"access_token":"demo_access_token_xyz789","refresh_token":"demo_refresh_token_abc456","token_type":"bearer"} +AUTHEOF + + # Ensure /tmp is first in PATH inside the VHS shell. + # macOS /etc/profile runs path_helper which reorders PATH, so we + # need .bash_profile in the fake HOME to restore it. + cat > "${home}/.bash_profile" <<'PROFILEEOF' +export PATH="/tmp:$PATH" +PROFILEEOF + + # Create dummy spec files in the HOME dir + echo '{}' > "${home}/openapi.yml" + echo '{}' > "${home}/stainless.yml" + + # For tapes that start with auth login (demo), use a fresh HOME and work dir + if [ "$name" = "demo" ]; then + rm -rf "${home}" + local workdir="${home}/project" + mkdir -p "$workdir" + home="${workdir}" + # Re-create .bash_profile since we wiped the home dir + cat > "${home}/.bash_profile" <<'PROFILEEOF' +export PATH="/tmp:$PATH" +PROFILEEOF + fi + + echo "==> Recording $tape (port $port)" + # Run VHS from the HOME dir so stl init writes to the temp dir, not the repo. + # Use -o to write the GIF back to the repo's assets/ directory. + local gif + gif="${REPO_ROOT}/assets/${name}.gif" + (cd "$home" && \ + PATH="/tmp:$PATH" \ + HOME="$home" \ + STAINLESS_API_KEY="" \ + STAINLESS_BASE_URL="http://localhost:${port}" \ + BROWSER=true \ + vhs -o "$gif" "${REPO_ROOT}/${tape}") + + kill_tree "$server_pid" + echo "==> Done: $tape" +} + +# Record a single tape or all tapes +# Usage: build-demo-gif [name] e.g. build-demo-gif preview +port=4010 +if [ $# -eq 1 ]; then + tape="assets/${1}.tape" + if [ ! -f "$tape" ]; then + echo "Error: $tape not found" + exit 1 + fi + record_tape "$tape" "$port" +else + for tape in assets/*.tape; do + record_tape "$tape" "$port" & + PIDS+=($!) + port=$((port + 1)) + done +fi + +# Wait for all recordings to finish +failed=0 +for pid in "${PIDS[@]}"; do + if ! wait "$pid"; then + failed=1 + fi +done + +if [ "$failed" -ne 0 ]; then + echo "==> Some recordings failed" + exit 1 +fi + +echo "==> Done! Output: assets/*.gif"