Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 234 additions & 2 deletions pkg/cmd/pipeline/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package pipeline

import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"

survey "github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
Expand All @@ -14,14 +17,29 @@ import (
"github.com/spf13/cobra"
)

type pipelineCreateOptions struct {
DryRun bool
}

func NewCmdPipelineCreate(f *factory.Factory) *cobra.Command {
var options pipelineCreateOptions

cmd := cobra.Command{
DisableFlagsInUseLine: true,
Use: "create",
Use: "create [flags]",
Short: "Creates a new pipeline",
Args: cobra.NoArgs,
Long: heredoc.Doc(`
Creates a new pipeline in the current org and outputs the URL to the pipeline.
Creates a new pipeline in the current org and outputs the URL to the pipeline.

You can specify a --dry-run flag to see the pipeline that would be created without actually creating it. This outputs a JSON representation of the pipeline to be created.
`),
Example: heredoc.Doc(`
# Create the default pipeline file
$ bk pipeline create

# View the pipeline that would be created without actually creating it
$ bk pipeline create --dry-run
`),
RunE: func(cmd *cobra.Command, args []string) error {
var repoURL string
Expand Down Expand Up @@ -92,10 +110,15 @@ func NewCmdPipelineCreate(f *factory.Factory) *cobra.Command {
}
}

if options.DryRun {
return createPipelineDryRun(cmd.Context(), f, answers.Pipeline, answers.Description, clusterID, repoURL)
}

return createPipeline(cmd.Context(), f, answers.Pipeline, answers.Description, clusterID, repoURL)
},
}

cmd.Flags().BoolVar(&options.DryRun, "dry-run", false, "Outputs the pipeline that would be created without actually creating it")
return &cmd
}

Expand Down Expand Up @@ -177,3 +200,212 @@ func createPipeline(ctx context.Context, f *factory.Factory, pipelineName, descr

return err
}

// PipelineDryRun is a custom struct for dry-run output that includes all fields
// without omitempty tags, ensuring empty strings and zero values are included in JSON output
type PipelineDryRun struct {
ID string `json:"id"`
GraphQLID string `json:"graphql_id"`
URL string `json:"url"`
WebURL string `json:"web_url"`
Name string `json:"name"`
Description string `json:"description"`
Slug string `json:"slug"`
Repository string `json:"repository"`
ClusterID string `json:"cluster_id"`
ClusterURL string `json:"cluster_url"`
BranchConfiguration string `json:"branch_configuration"`
DefaultBranch string `json:"default_branch"`
SkipQueuedBranchBuilds bool `json:"skip_queued_branch_builds"`
SkipQueuedBranchBuildsFilter string `json:"skip_queued_branch_builds_filter"`
CancelRunningBranchBuilds bool `json:"cancel_running_branch_builds"`
CancelRunningBranchBuildsFilter string `json:"cancel_running_branch_builds_filter"`
BuildsURL string `json:"builds_url"`
BadgeURL string `json:"badge_url"`
CreatedAt *buildkite.Timestamp `json:"created_at"`
Env map[string]any `json:"env"`
ScheduledBuildsCount int `json:"scheduled_builds_count"`
RunningBuildsCount int `json:"running_builds_count"`
ScheduledJobsCount int `json:"scheduled_jobs_count"`
RunningJobsCount int `json:"running_jobs_count"`
WaitingJobsCount int `json:"waiting_jobs_count"`
Visibility string `json:"visibility"`
Tags []string `json:"tags"`
Configuration string `json:"configuration"`
Steps []buildkite.Step `json:"steps"`
Provider buildkite.Provider `json:"provider"`
PipelineTemplateUUID string `json:"pipeline_template_uuid"`
AllowRebuilds bool `json:"allow_rebuilds"`
Emoji *string `json:"emoji"`
Color *string `json:"color"`
CreatedBy *buildkite.User `json:"created_by"`
}

func initialisePipelineDryRun() PipelineDryRun {
return PipelineDryRun{
Env: nil,
Tags: nil,
Steps: []buildkite.Step{},
Provider: buildkite.Provider{
Settings: &buildkite.GitHubSettings{},
},
AllowRebuilds: true,
}
}

func createPipelineDryRun(ctx context.Context, f *factory.Factory, pipelineName, description, clusterID, repoURL string) error {

pipelineSlug := generateSlug(pipelineName)

pipelineSlug, err := getAvailablePipelineSlug(ctx, f, pipelineSlug, pipelineName)
if err != nil {
return err
}

orgSlug := f.Config.OrganizationSlug()
pipeline := initialisePipelineDryRun()

// Set specific fields with actual values
pipeline.ID = "00000000-0000-0000-0000-000000000000"
pipeline.GraphQLID = "UGlwZWxpbmUtLS0wMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDA="
pipeline.URL = fmt.Sprintf("https://api.buildkite.com/v2/organizations/%s/pipelines/%s", orgSlug, pipelineSlug)
pipeline.WebURL = fmt.Sprintf("https://buildkite.com/%s/%s", orgSlug, pipelineSlug)
pipeline.Name = pipelineName
pipeline.Description = description
pipeline.Slug = pipelineSlug
pipeline.Repository = repoURL
pipeline.ClusterID = clusterID
pipeline.ClusterURL = getClusterUrl(orgSlug, clusterID)
pipeline.DefaultBranch = "main"
pipeline.BuildsURL = fmt.Sprintf("https://api.buildkite.com/v2/organizations/%s/pipelines/%s/builds", orgSlug, pipelineSlug)
pipeline.BadgeURL = fmt.Sprintf("https://badge.buildkite.com/%s.svg", "00000000000000000000000000000000000000000000000000")
pipeline.CreatedAt = buildkite.NewTimestamp(time.Now())
pipeline.Visibility = "private"
pipeline.Configuration = "steps:\n - label: \":pipeline:\"\n command: buildkite-agent pipeline upload"
pipeline.Steps = []buildkite.Step{
{
Type: ":pipeline:",
Name: ":pipeline:",
Command: "buildkite-agent pipeline upload",
},
}
pipeline.Provider = buildkite.Provider{
ID: "github",
WebhookURL: "https://webhook.buildkite.com/deliver/00000000000000000000000000000000000000000000000000",
Settings: &buildkite.GitHubSettings{
TriggerMode: "code",
BuildPullRequests: true,
BuildBranches: true,
PublishCommitStatus: true,
Repository: extractRepoPath(repoURL),
},
}

pipeline.CreatedBy = getCreatedByDetails(ctx, f)

jsonOutput, err := json.MarshalIndent(pipeline, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal dry run response: %w", err)
}

fmt.Println(string(jsonOutput))
return nil
}

// generateSlug creates a URL-friendly slug from the pipeline name
func generateSlug(name string) string {
// Trim leading and trailing spaces
name = strings.TrimSpace(name)

var slug strings.Builder
lastWasSeparator := false

for _, c := range strings.ToLower(name) {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
slug.WriteRune(c)
lastWasSeparator = false
} else if c == ' ' || c == '-' || c == '_' {
// Only add a hyphen if the last character wasn't already a separator
if !lastWasSeparator && slug.Len() > 0 {
slug.WriteRune('-')
lastWasSeparator = true
}
}
}

// Trim trailing hyphens
result := slug.String()
return strings.TrimRight(result, "-")
}

// extractRepoPath extracts the repository path from a git URL
func extractRepoPath(repoURL string) string {
// Handle git@github.com:org/repo.git format
if strings.HasPrefix(repoURL, "git@github.com:") {
path := strings.TrimPrefix(repoURL, "git@github.com:")
return strings.TrimSuffix(path, ".git")
}

// Handle https://github.com/org/repo.git format
if strings.HasPrefix(repoURL, "https://github.com/") {
path := strings.TrimPrefix(repoURL, "https://github.com/")
return strings.TrimSuffix(path, ".git")
}

return repoURL
}

func getAvailablePipelineSlug(ctx context.Context, f *factory.Factory, pipelineSlug, pipelineName string) (string, error) {
// Check if the original slug is available
pipeline, resp, err := f.RestAPIClient.Pipelines.Get(ctx, f.Config.OrganizationSlug(), pipelineSlug)
if err != nil {
if resp != nil && resp.StatusCode == 404 {
return pipelineSlug, nil // Original slug is available
}
return "", fmt.Errorf("failed to validate pipeline name")
}

// If a pipeline slug exists but with the same name, return a 422 error
if pipeline.Name == pipelineName {
return "", fmt.Errorf("a pipeline with the name '%s' already exists", pipelineName)
}

// Slug is taken, find the next available one by appending a counter
counter := 1
for {
newSlug := fmt.Sprintf("%s-%d", pipelineSlug, counter)
pipeline, resp, err := f.RestAPIClient.Pipelines.Get(ctx, f.Config.OrganizationSlug(), newSlug)
if err != nil {
if resp != nil && resp.StatusCode == 404 {
return newSlug, nil // Found an available slug
}
return "", fmt.Errorf("failed to validate pipeline name")
}

// If a pipeline slug exists but with the same name, return a 422 error
if pipeline.Name == pipelineName {
return "", fmt.Errorf("a pipeline with the name '%s' already exists", pipelineName)
}

counter++
// Safety check to prevent infinite loops
if counter > 1000 {
return "", fmt.Errorf("unable to find available slug after 1000 attempts")
}
}
}

func getClusterUrl(orgSlug, clusterID string) string {
if clusterID == "" {
return ""
}
return fmt.Sprintf("https://api.buildkite.com/v2/organizations/%s/clusters/%s", orgSlug, clusterID)
}

func getCreatedByDetails(ctx context.Context, f *factory.Factory) *buildkite.User {
user, _, err := f.RestAPIClient.User.CurrentUser(ctx)
if err != nil {
return nil
}
return &user
}