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..f2db18a --- /dev/null +++ b/internal/mockstainless/mock.go @@ -0,0 +1,454 @@ +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 + enableGitRepos bool + gitRepos map[string]gitRepo // key: "owner/name" + tempDir 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 + } + 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) 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{} +} + +// 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..175258f --- /dev/null +++ b/internal/mockstainless/server.go @@ -0,0 +1,191 @@ +package mockstainless + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +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("GET /v0/projects/{project}/configs", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, m.ProjectConfigs) + }) + + mux.HandleFunc("GET /v0/builds", func(w http.ResponseWriter, r *http.Request) { + 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) { + if m.CompareBuild.PreviewBuild != nil { + m.CompareBuild.PreviewBuild.Reset() + } + writeJSON(w, http.StatusOK, M{ + "base": m.CompareBuild.Base, + "head": m.CompareBuild.Head, + }) + }) + } + + // Add simulated latency to all requests (except health checks). + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + time.Sleep(150 * time.Millisecond) + } + mux.ServeHTTP(w, r) + }) +} 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..5321048 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,21 @@ 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..7511661 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 } 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..f59725c 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,7 +48,6 @@ 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 @@ -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 { @@ -138,7 +134,7 @@ 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 { @@ -171,26 +167,95 @@ 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")), - } +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 { - return []key.Binding{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))} + 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 (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"))}} +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 +} + +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 { + 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 { @@ -205,10 +270,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(), } @@ -236,7 +301,7 @@ func runLinter(ctx context.Context, cmd *cli.Command, canSkip bool) error { } // If not in watch mode and we have blocking diagnostics, exit with error code - if !cmd.Bool("watch") && hasBlockingDiagnostic(finalModel.diagnostics) { + if !cmd.Bool("watch") { os.Exit(1) } 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/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/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..6fa472b 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,7 @@ 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..f7e937d --- /dev/null +++ b/pkg/components/diagnostics/testdata/view_diagnostics.snapshot @@ -0,0 +1,23 @@ +(no diagnostics) + +(no diagnostics) + +error: failed to fetch diagnostics: connection refused + +error[MissingField]: The field 'name' is required but missing + --> openapi.yml: /paths/~1users/post/requestBody + --> stainless.yml: /endpoints/~1users/post + +fatal[FatalError]: Build failed due to configuration error + --> openapi.yml: /paths/~1users + Check your stainless.yml for syntax errors. + See docs for details. + +warning[DeprecatedUsage]: The x-deprecated extension is deprecated + --> openapi.yml: /paths/~1foo/get + +... and 1 more diagnostics + +error[MissingField]: Field 'id' is required + --> specs/openapi.json: /paths/~1pets/get + --> .stainless/stainless.yaml: /endpoints/~1pets/get diff --git a/pkg/components/diagnostics/view.go b/pkg/components/diagnostics/view.go index 542fe62..cfed9ee 100644 --- a/pkg/components/diagnostics/view.go +++ b/pkg/components/diagnostics/view.go @@ -2,161 +2,137 @@ package diagnostics import ( "fmt" - "os" "strings" - "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" - "github.com/stainless-api/stainless-api-cli/pkg/console" "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")) +) -// ViewDiagnosticIcon returns a colored icon for a diagnostic level -func ViewDiagnosticIcon(level stainless.BuildDiagnosticLevel) string { +// 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. oasLabel and configLabel are the filenames +// shown in source references (e.g. "openapi.json", "stainless.yaml"). +func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int, oasLabel, configLabel string) string { + if oasLabel == "" { + oasLabel = "openapi.yml" } - - rendered, err := renderer.Render(content) - if err != nil { - return content + if configLabel == "" { + configLabel = "stainless.yml" } - - return strings.Trim(rendered, "\n ") -} - -// 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++ + // Filter out notes + var visible []stainless.BuildDiagnostic + for _, d := range diagnostics { + if d.Level != stainless.BuildDiagnosticLevelNote { + visible = append(visible, d) } } - return -} -// ViewDiagnostics renders build diagnostics with formatting -func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int) string { + if len(visible) == 0 { + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + return grayStyle.Render("(no diagnostics)") + "\n" + } + var s strings.Builder - if len(diagnostics) > 0 { - // Count diagnostics by severity - fatal, errors, warnings, notes := countDiagnosticsBySeverity(diagnostics) + truncated := false + shown := len(visible) + if maxDiagnostics >= 0 && len(visible) > maxDiagnostics { + truncated = true + shown = maxDiagnostics + } - // Create summary string - var summaryParts []string - if fatal > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d fatal", fatal)) - } - if errors > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d errors", errors)) - } - if warnings > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d warnings", warnings)) - } - if notes > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d notes", notes)) + rendered := 0 + for _, diag := range visible { + if maxDiagnostics >= 0 && rendered >= maxDiagnostics { + break } - summary := strings.Join(summaryParts, ", ") - if summary != "" { - summary = fmt.Sprintf(" (%s)", summary) + if rendered > 0 { + s.WriteString("\n") } - - var sub strings.Builder - - if maxDiagnostics >= 0 && len(diagnostics) > maxDiagnostics { - sub.WriteString(fmt.Sprintf("Showing first %d of %d diagnostics:\n", maxDiagnostics, len(diagnostics))) + rendered++ + + // Header: error[Code]: message + s.WriteString(levelLabel(diag.Level, diag.Code)) + s.WriteString(": ") + s.WriteString(diag.Message) + s.WriteString("\n") + + // Source references + if diag.OasRef != "" { + s.WriteString(refStyle.Render(" --> " + oasLabel + ": " + diag.OasRef)) + s.WriteString("\n") + } + if diag.ConfigRef != "" { + s.WriteString(refStyle.Render(" --> " + configLabel + ": " + diag.ConfigRef)) + s.WriteString("\n") } - for i, diag := range diagnostics { - if maxDiagnostics >= 0 && i >= maxDiagnostics { - break - } - - levelIcon := ViewDiagnosticIcon(diag.Level) - codeStyle := lipgloss.NewStyle().Bold(true) - - 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)) + // 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") + } } - } - - // 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))) } } + } - 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)")) + if truncated { + s.WriteString(fmt.Sprintf("\n... and %d more diagnostics\n", len(visible)-shown)) } return s.String() diff --git a/pkg/components/diagnostics/view_test.go b/pkg/components/diagnostics/view_test.go new file mode 100644 index 0000000..52d389b --- /dev/null +++ b/pkg/components/diagnostics/view_test.go @@ -0,0 +1,129 @@ +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") + +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.ANSI) + 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("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) + } +} + +func TestViewDiagnostics(t *testing.T) { + var out strings.Builder + + // 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, "", "")) + 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")) + + 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"