Repository: buildkite/cli Branch: main Commit: 02d27513e1cd Files: 272 Total size: 1.4 MB Directory structure: gitextract_4e0fgwb8/ ├── .buildkite/ │ ├── hooks/ │ │ └── pre-command │ ├── pipeline.release.yml │ ├── pipeline.yml │ ├── release.sh │ ├── tag.sh │ └── upload-packages.sh ├── .dockerignore ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── pull_request_template.md ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── .graphqlrc.yml ├── AGENT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── cmd/ │ ├── agent/ │ │ ├── agent_test.go │ │ ├── install.go │ │ ├── list.go │ │ ├── list_test.go │ │ ├── pause.go │ │ ├── pause_test.go │ │ ├── resume.go │ │ ├── resume_test.go │ │ ├── run.go │ │ ├── stop.go │ │ ├── stop_test.go │ │ ├── util.go │ │ ├── view.go │ │ └── view_test.go │ ├── api/ │ │ ├── api.go │ │ └── api_test.go │ ├── artifacts/ │ │ ├── download.go │ │ └── list.go │ ├── auth/ │ │ ├── login.go │ │ ├── login_test.go │ │ ├── logout.go │ │ ├── status.go │ │ ├── switch.go │ │ ├── switch_test.go │ │ └── token.go │ ├── build/ │ │ ├── cancel.go │ │ ├── create.go │ │ ├── download.go │ │ ├── list.go │ │ ├── list_test.go │ │ ├── rebuild.go │ │ ├── view.go │ │ ├── view_test.go │ │ └── watch.go │ ├── cluster/ │ │ ├── cluster_test.go │ │ ├── create.go │ │ ├── delete.go │ │ ├── list.go │ │ ├── update.go │ │ └── view.go │ ├── config/ │ │ ├── config.go │ │ ├── config_test.go │ │ ├── get.go │ │ ├── list.go │ │ ├── set.go │ │ └── unset.go │ ├── configure/ │ │ ├── configure.go │ │ ├── configure_case_test.go │ │ └── configure_test.go │ ├── generate/ │ │ └── generate.go │ ├── init/ │ │ ├── init.go │ │ └── init_test.go │ ├── job/ │ │ ├── cancel.go │ │ ├── cancel_test.go │ │ ├── graphql/ │ │ │ ├── cancel.graphql │ │ │ ├── jobs.graphql │ │ │ ├── retry.graphql │ │ │ └── unblock.graphql │ │ ├── list.go │ │ ├── list_test.go │ │ ├── log.go │ │ ├── reprioritize.go │ │ ├── retry.go │ │ ├── unblock.go │ │ └── unblock_test.go │ ├── maintainer/ │ │ ├── create.go │ │ ├── delete.go │ │ ├── list.go │ │ └── maintainer_test.go │ ├── organization/ │ │ └── list.go │ ├── pipeline/ │ │ ├── convert.go │ │ ├── convert_test.go │ │ ├── copy.go │ │ ├── create.go │ │ ├── create_test.go │ │ ├── graphql/ │ │ │ └── create_webhook.graphql │ │ ├── list.go │ │ ├── validate.go │ │ ├── validate_test.go │ │ └── view.go │ ├── pkg/ │ │ ├── push.go │ │ └── push_test.go │ ├── preflight/ │ │ ├── cleanup_cmd.go │ │ ├── cleanup_cmd_test.go │ │ ├── event.go │ │ ├── event_test.go │ │ ├── job_presenter.go │ │ ├── job_presenter_test.go │ │ ├── preflight.go │ │ ├── preflight_test.go │ │ ├── render.go │ │ ├── render_test.go │ │ ├── result.go │ │ ├── result_test.go │ │ ├── test_presenter.go │ │ ├── test_presenter_test.go │ │ ├── tty.go │ │ └── tty_test.go │ ├── queue/ │ │ ├── create.go │ │ ├── delete.go │ │ ├── list.go │ │ ├── pause.go │ │ ├── queue_test.go │ │ ├── resume.go │ │ ├── update.go │ │ └── view.go │ ├── secret/ │ │ ├── create.go │ │ ├── delete.go │ │ ├── get.go │ │ ├── list.go │ │ ├── secret_test.go │ │ └── update.go │ ├── skill/ │ │ ├── skill.go │ │ └── skill_test.go │ ├── use/ │ │ └── use.go │ ├── user/ │ │ └── invite.go │ ├── version/ │ │ ├── update_check.go │ │ ├── update_check_test.go │ │ └── version.go │ └── whoami/ │ └── whoami.go ├── docs/ │ └── shell-prompt-integration.md ├── fixtures/ │ ├── build.json │ └── config/ │ ├── local.basic.yaml │ └── user.basic.yaml ├── genqlient.yaml ├── go.mod ├── go.sum ├── internal/ │ ├── agent/ │ │ ├── download.go │ │ ├── download_test.go │ │ ├── platform.go │ │ ├── platform_test.go │ │ ├── token.go │ │ └── token_test.go │ ├── annotation/ │ │ ├── annotation.go │ │ └── list.go │ ├── artifact/ │ │ ├── artifact.go │ │ └── view.go │ ├── build/ │ │ ├── build.go │ │ ├── resolver/ │ │ │ ├── cli.go │ │ │ ├── cli_test.go │ │ │ ├── options/ │ │ │ │ ├── options.go │ │ │ │ └── options_test.go │ │ │ ├── resolver.go │ │ │ ├── url.go │ │ │ └── with_options.go │ │ ├── state/ │ │ │ ├── state.go │ │ │ └── state_test.go │ │ ├── view/ │ │ │ ├── shared/ │ │ │ │ └── summary.go │ │ │ ├── view.go │ │ │ └── view_test.go │ │ └── watch/ │ │ ├── job.go │ │ ├── test_tracker.go │ │ ├── test_tracker_test.go │ │ ├── tracker.go │ │ ├── tracker_test.go │ │ ├── watch.go │ │ └── watch_test.go │ ├── cli/ │ │ └── context.go │ ├── cluster/ │ │ ├── list_queues.graphql │ │ ├── query.go │ │ └── view.go │ ├── config/ │ │ ├── config.go │ │ └── config_test.go │ ├── emoji/ │ │ ├── emoji.go │ │ └── emoji_test.go │ ├── errors/ │ │ ├── README.md │ │ ├── api.go │ │ ├── api_test.go │ │ ├── errors.go │ │ ├── errors_test.go │ │ ├── handler.go │ │ └── handler_test.go │ ├── graphql/ │ │ └── generated.go │ ├── http/ │ │ ├── README.md │ │ ├── client.go │ │ ├── client_test.go │ │ ├── ratelimit.go │ │ ├── ratelimit_test.go │ │ ├── refresh_transport.go │ │ └── refresh_transport_test.go │ ├── io/ │ │ ├── confirm.go │ │ ├── input.go │ │ ├── pager.go │ │ ├── pager_test.go │ │ ├── progress.go │ │ ├── progress_test.go │ │ ├── prompt.go │ │ ├── prompt_test.go │ │ ├── readline.go │ │ ├── spinner.go │ │ ├── spinner_test.go │ │ └── terminal.go │ ├── job/ │ │ └── view.go │ ├── organization/ │ │ └── organization.graphql │ ├── pipeline/ │ │ ├── pipeline.go │ │ └── resolver/ │ │ ├── cli.go │ │ ├── cli_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── flag.go │ │ ├── flag_test.go │ │ ├── picker.go │ │ ├── picker_test.go │ │ ├── repository.go │ │ ├── repository_test.go │ │ ├── resolver.go │ │ └── resolver_test.go │ ├── preflight/ │ │ ├── branch_build.go │ │ ├── branch_build_test.go │ │ ├── cleanup.go │ │ ├── cleanup_test.go │ │ ├── exit_policy.go │ │ ├── git.go │ │ ├── run_summary.go │ │ ├── run_summary_test.go │ │ ├── snapshot.go │ │ └── snapshot_test.go │ ├── secret/ │ │ └── view.go │ ├── user/ │ │ └── user.graphql │ ├── util/ │ │ └── util.go │ └── validation/ │ ├── errors.go │ ├── rule.go │ ├── validator.go │ └── validator_test.go ├── lefthook.yml ├── main.go ├── main_test.go ├── mise.toml ├── pkg/ │ ├── analytics/ │ │ ├── analytics.go │ │ └── logger.go │ ├── cmd/ │ │ ├── factory/ │ │ │ ├── factory.go │ │ │ └── factory_test.go │ │ └── validation/ │ │ ├── config.go │ │ └── config_test.go │ ├── keyring/ │ │ ├── keyring.go │ │ └── keyring_test.go │ ├── oauth/ │ │ ├── oauth.go │ │ ├── oauth_test.go │ │ └── refresh_test.go │ └── output/ │ ├── color.go │ ├── flags.go │ ├── flags_test.go │ ├── output.go │ ├── output_test.go │ ├── table.go │ ├── table_test.go │ ├── value.go │ ├── viewable.go │ └── viewable_test.go ├── renovate.json └── schema.graphql ================================================ FILE CONTENTS ================================================ ================================================ FILE: .buildkite/hooks/pre-command ================================================ #!/usr/bin/env bash set -euo pipefail checkout_path="${BUILDKITE_BUILD_CHECKOUT_PATH:-$(pwd)}" cache_root="${checkout_path}/.buildkite/cache-volume" mkdir -p \ "${cache_root}/go/build" \ "${cache_root}/go/pkg/mod" \ "${cache_root}/golangci-lint" export GOCACHE="${cache_root}/go/build" export GOMODCACHE="${cache_root}/go/pkg/mod" export GOLANGCI_LINT_CACHE="${cache_root}/golangci-lint" ================================================ FILE: .buildkite/pipeline.release.yml ================================================ agents: queue: hosted cache: ".buildkite/cache-volume" steps: - label: ":terminal: build ({{matrix}})" key: build matrix: - "darwin" - "linux" - "windows" artifact_paths: - dist/**/* secrets: - MISE_GITHUB_TOKEN - POSTHOG_API_KEY - OAUTH_CLIENT_ID command: 'GOOS="{{matrix}}" .buildkite/release.sh release --clean --split' plugins: - mise#v1.1.2: ~ - label: ":rocket: :package: upload packages ({{matrix}})" key: upload_packages depends_on: build matrix: - "deb" - "rpm" command: '.buildkite/upload-packages.sh "{{matrix}}"' plugins: - artifacts#v1.9.4: download: - dist/linux/* - label: ":rocket: :github: release" key: release depends_on: ["build", "upload_packages"] artifact_paths: - dist/**/* env: AWS_REGION: us-east-1 secrets: - MISE_GITHUB_TOKEN - POSTHOG_API_KEY - OAUTH_CLIENT_ID plugins: - aws-assume-role-with-web-identity#v1.6.0: role-arn: arn:aws:iam::445615400570:role/pipeline-buildkite-buildkite-cli-release session-tags: - organization_slug - organization_id - pipeline_slug - aws-ssm#v1.1.0: parameters: GITHUB_TOKEN: /pipelines/buildkite/buildkite-cli-release/github-token DOCKERHUB_USER: /pipelines/buildkite/buildkite-cli-release/dockerhub-user DOCKERHUB_PASSWORD: /pipelines/buildkite/buildkite-cli-release/dockerhub-password - artifacts#v1.9.4: download: - dist/**/* - mise#v1.1.2: ~ command: '.buildkite/release.sh continue --merge' ================================================ FILE: .buildkite/pipeline.yml ================================================ agents: queue: hosted cache: ".buildkite/cache-volume" steps: - name: ":golangci-lint: lint" key: lint command: 'golangci-lint run --verbose --timeout 3m' secrets: - MISE_GITHUB_TOKEN plugins: - mise#v1.1.2: ~ - name: ":go: test" key: test artifact_paths: - cover-tree.svg secrets: - MISE_GITHUB_TOKEN commands: # Hosted agents inject org/token env that breaks config-precedence tests, # so clear those variables in the command shell right before go test. - unset BUILDKITE_ORGANIZATION_SLUG BUILDKITE_API_TOKEN - go test -coverprofile cover.out ./... - go-cover-treemap -coverprofile cover.out > cover-tree.svg - echo '
Coverage tree mapTest coverage tree map
' | buildkite-agent annotate --style "info" plugins: - mise#v1.1.2: ~ - label: ":terminal: build ({{matrix}})" key: build depends_on: ["lint", "test"] branches: - main matrix: - "darwin" - "linux" - "windows" artifact_paths: - dist/**/* secrets: - MISE_GITHUB_TOKEN - POSTHOG_API_KEY - OAUTH_CLIENT_ID command: 'GOOS="{{matrix}}" .buildkite/release.sh release --clean --snapshot --split' plugins: - mise#v1.1.2: ~ - input: ":package: Create a release?" key: release_unblock depends_on: ["lint", "test", "build"] prompt: "Select the release type" branches: - main allowed_teams: - "support" - "deploy" blocked_state: "passed" fields: - key: release-type select: "Release Type" required: true options: - label: "Patch (v3.x.X)" value: "patch" - label: "Minor (v3.X.0)" value: "minor" - label: "Major (vX.0.0) - Manual only" value: "major" # this tags the commit with the input from the previous block step and pushes it to github # that will trigger the buildkite-cli-release pipeline off the tag which will create a release in github - label: ":rocket: Pushing a tag to release" command: ".buildkite/tag.sh" depends_on: release_unblock branches: - main env: AWS_REGION: us-east-1 plugins: - aws-assume-role-with-web-identity#v1.6.0: role-arn: arn:aws:iam::445615400570:role/pipeline-buildkite-buildkite-cli session-tags: - organization_slug - organization_id - pipeline_slug - aws-ssm#v1.1.0: parameters: GITHUB_TOKEN: /pipelines/buildkite/buildkite-cli/github-token GITHUB_USER: /pipelines/buildkite/buildkite-cli/github-user ================================================ FILE: .buildkite/release.sh ================================================ #!/usr/bin/env bash # # This script is used to build a release of the CLI and publish it to multiple registries on Buildkite # # NOTE: do not exit on non-zero returns codes set -uo pipefail export GORELEASER_KEY=$(buildkite-agent secret get goreleaser_key) if [[ $? -ne 0 ]]; then echo "Failed to retrieve GoReleaser Pro key" exit 1 fi # check if DOCKERHUB_USER and DOCKERHUB_PASSWORD are set if not skip docker login if [[ -z "${DOCKERHUB_USER:-}" || -z "${DOCKERHUB_PASSWORD:-}" ]]; then echo "Skipping Docker login as DOCKERHUB_USER or DOCKERHUB_PASSWORD is not set" else echo "--- :key: :docker: Login to Docker Hub using ko" echo "${DOCKERHUB_PASSWORD}" | ko login index.docker.io --username "${DOCKERHUB_USER}" --password-stdin if [[ $? -ne 0 ]]; then echo "Docker login failed" exit 1 fi fi if ! goreleaser "$@"; then echo "Failed to build a release" exit 1 fi ================================================ FILE: .buildkite/tag.sh ================================================ #!/usr/bin/env bash # # This script calculates the next semantic version and pushes a tag # set -euo pipefail RELEASE_TYPE="$(buildkite-agent meta-data get "release-type")" if [[ "${RELEASE_TYPE}" == "major" ]]; then echo "🚨 Major releases require manual tagging to prevent accidents." echo "Please run: git tag vX.0.0 && git push origin vX.0.0" exit 1 fi # Get latest tag matching v*.*.* pattern LATEST_TAG=$(git describe --tags --match "v[0-9]*" --abbrev=0 2>/dev/null) || { echo "Error: No existing version tags found. Cannot calculate next version." exit 1 } echo "Latest tag: ${LATEST_TAG}" # Parse version (strip 'v' prefix and any pre-release suffix) VERSION="${LATEST_TAG#v}" IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION%%-*}" # Calculate new version case "${RELEASE_TYPE}" in minor) TAG="v${MAJOR}.$((MINOR + 1)).0" ;; patch) TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;; *) echo "Error: Unknown release type: ${RELEASE_TYPE}" exit 1 ;; esac echo "New tag: ${TAG}" if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then echo "Error: Tag ${TAG} already exists at origin" exit 1 fi echo "${TAG} does not exist at origin. Proceeding... 🚀" echo "--- Downloading gh" curl -sL https://github.com/cli/cli/releases/download/v2.57.0/gh_2.57.0_linux_amd64.tar.gz | tar xz echo "--- Logging in to gh" gh_2.57.0_linux_amd64/bin/gh auth setup-git echo "+++ Tagging ${BUILDKITE_COMMIT} with ${TAG}" git tag "${TAG}" git push origin "${TAG}" ================================================ FILE: .buildkite/upload-packages.sh ================================================ #!/bin/env bash # # This script is used to upload packages to Buildkite registries # set -uo pipefail if [[ -z "${1}" ]]; then echo "Must pass in the package type: deb, rpm" exit 1 fi PACKAGE=${1} ORGANIZATION=${2:-buildkite} REGISTRY=${3:-cli-$PACKAGE} audience() { ORG=$1 REGISTRY=$2 echo "https://packages.buildkite.com/${ORG}/${REGISTRY}" } upload_url() { ORG=$1 REGISTRY=$2 echo "https://api.buildkite.com/v2/packages/organizations/${ORG}/registries/${REGISTRY}/packages" } AUDIENCE=$(audience $ORGANIZATION $REGISTRY) # grab a token for pushing packages to buildkite with an expiry of 3 mins echo "--- Fetching OIDC token for $AUDIENCE" TOKEN=$(buildkite-agent oidc request-token --audience "$AUDIENCE" --lifetime 180) if [[ $? -ne 0 ]]; then echo "Failed to retrieve OIDC token" exit 1 fi for FILE in dist/linux/*.${PACKAGE}; do echo "--- Pushing $FILE" if [[ $PACKAGE = "apk" ]]; then curl -s -X POST $(upload_url $ORGANIZATION $REGISTRY) \ -H "Authorization: Bearer ${TOKEN}" \ -F "package[distro_version_id]=alpine/v3" \ -F "package[package_file]=@${FILE}" \ --fail-with-body else curl -s -X POST $(upload_url $ORGANIZATION $REGISTRY) \ -H "Authorization: Bearer ${TOKEN}" \ -F "file=@${FILE}" \ --fail-with-body fi if [[ $? -ne 0 ]]; then echo "Failed to push package $FILE" exit 1 fi done ================================================ FILE: .dockerignore ================================================ # Git files .git .github .gitignore # Documentation *.md docs/ images/ LICENSE.md CHANGELOG.md CONTRIBUTING.md AGENT.md README.md # Build artifacts dist/ build-logs-* # Test files *_test.go fixtures/ internal/*/resolver/*_test.go # IDE and editor files .vscode/ .idea/ *.swp *.swo *~ .DS_Store # CI/CD files .buildkite/ buildkite.yaml .bk.yaml # Config files not needed for build .golangci.yaml .graphqlrc.yml genqlient.yaml # Dependencies (these will be downloaded during build) vendor/ ================================================ FILE: .github/CODEOWNERS ================================================ * @buildkite/support @buildkite/engineering ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐛 Bug Report description: File a bug report for the Buildkite CLI title: "[Bug]: " labels: ["bug", "triage"] projects: ["buildkite/cli"] assignees: - buildkite/technical-services body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: input id: contact attributes: label: Contact Details description: How can we get in touch with you if we need more info? placeholder: ex. email@example.com validations: required: false - type: textarea id: what-happened attributes: label: What happened? description: Also tell us, what did you expect to happen? placeholder: Tell us what you see! value: "A bug happened!" validations: required: true - type: input id: version attributes: label: Version description: What version of our software are you running? validations: required: true - type: dropdown id: browsers attributes: label: What environment are you seeing the problem on? multiple: true options: - CI - Local Development - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Buildkite Community Forum url: https://forum.buildkite.community/ about: Discuss issues and requests in the forum. - name: Contact Buildkite Support url: https://buildkite.com/about/contact/ about: Get in contact with Buildkite's support team. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 💡 Feature Request description: Suggest an idea for this project. title: "💡 feat: " labels: [Enhancement] body: - type: textarea attributes: label: Is your feature request related to a problem? description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]. validations: required: true - type: textarea attributes: label: Describe the solution you'd like. description: A clear and concise description of what you want to happen. validations: required: true - type: textarea attributes: label: Describe alternatives you've considered. description: A clear and concise description of any alternative solutions or features you've considered. validations: required: false - type: textarea attributes: label: Additional context description: Add any other context or screenshots about the feature request here. validations: required: false ================================================ FILE: .github/pull_request_template.md ================================================ ### Description <!-- - What problem are you trying to solve, and how are you solving it? - What alternatives did you consider? --> ### Changes <!-- List of what the PR changes. If the PR changes the CLI arguments, consider adding the output of the various levels of `bk <subcomand> --help`. Can skip if changes are simple or clear from the commit messages. --> ### Testing - [ ] Tests have run locally (with `go test ./...`) - [ ] Code is formatted (with `go fmt ./...`) ### Disclosures / Credits <!-- If you used AI in any way to produce this PR (beyond typo fixes or small amounts of tab-autocompletion), please describe the extent of the contribution here, and the tools used. Feel free to claim credit for work _not_ done by an AI here too, or to give credit to others who helped in any meaningful way. Examples: - "Claude Code wrote the unit tests, then I implemented the rest of the change" - "I consulted ChatGPT on potential approaches, then wrote the implementation myself" - "I used Gemini to write the code and Midjourney to produce the diagrams" - "Special thanks to the Wikipedia page on ANSI escape codes" - "I did not use AI tools at all" --> ================================================ FILE: .gitignore ================================================ *.DS_STORE dist/ buildkite.yaml .bk.yaml build-logs-* mise.local.toml .mise.local.toml ================================================ FILE: .golangci.yaml ================================================ version: "2" linters: enable: - nolintlint - tparallel exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - errcheck path: _test.go paths: - third_party$ - builtin$ - examples$ issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gofmt - goimports exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: .goreleaser.yaml ================================================ project_name: bk version: 2 release: name_template: Buildkite CLI {{.Version}} draft: true replace_existing_draft: true prerelease: auto make_latest: false mode: replace changelog: use: github brews: - name: bk@3 ids: - macos-archive - linux-archive directory: Formula homepage: https://github.com/buildkite/cli description: Work with Buildkite from the command-line license: MIT skip_upload: false test: system "#{bin}/bk version" repository: owner: buildkite name: homebrew-buildkite branch: master builds: - id: macos goos: [darwin] goarch: [amd64, arm64] binary: bk main: . ldflags: - -s -w -X github.com/buildkite/cli/v3/cmd/version.Version={{.Version}} -X github.com/buildkite/cli/v3/pkg/cmd/pipeline.MigrationEndpoint=https://m4vrh5pvtd.execute-api.us-east-1.amazonaws.com/production/migrate -X github.com/buildkite/cli/v3/pkg/analytics.apiKey={{.Env.POSTHOG_API_KEY}} -X github.com/buildkite/cli/v3/pkg/oauth.DefaultClientID={{.Env.OAUTH_CLIENT_ID}} - id: linux goos: [linux] goarch: [amd64, arm64] env: - CGO_ENABLED=0 binary: bk main: . ldflags: - -s -w -X github.com/buildkite/cli/v3/cmd/version.Version={{.Version}} -X github.com/buildkite/cli/v3/pkg/cmd/pipeline.MigrationEndpoint=https://m4vrh5pvtd.execute-api.us-east-1.amazonaws.com/production/migrate -X github.com/buildkite/cli/v3/pkg/analytics.apiKey={{.Env.POSTHOG_API_KEY}} -X github.com/buildkite/cli/v3/pkg/oauth.DefaultClientID={{.Env.OAUTH_CLIENT_ID}} - id: windows goos: [windows] goarch: [amd64, arm64] binary: bk main: . ldflags: - -s -w -X github.com/buildkite/cli/v3/cmd/version.Version={{.Version}} -X github.com/buildkite/cli/v3/pkg/cmd/pipeline.MigrationEndpoint=https://m4vrh5pvtd.execute-api.us-east-1.amazonaws.com/production/migrate -X github.com/buildkite/cli/v3/pkg/analytics.apiKey={{.Env.POSTHOG_API_KEY}} -X github.com/buildkite/cli/v3/pkg/oauth.DefaultClientID={{.Env.OAUTH_CLIENT_ID}} kos: - repositories: - docker.io/buildkite/cli build: linux main: . creation_time: "{{.CommitTimestamp}}" base_image: 'cgr.dev/chainguard/static:latest' tags: - '{{.Version}}' - latest labels: org.opencontainers.image.authors: Buildkite Inc. https://buildkite.com org.opencontainers.image.source: https://github.com/buildkite/cli org.opencontainers.image.created: "{{.Date}}" org.opencontainers.image.title: "{{.ProjectName}}" org.opencontainers.image.revision: "{{.FullCommit}}" org.opencontainers.image.version: "{{.Version}}" bare: true preserve_import_paths: false disable: '{{ and (isEnvSet "GOOS") (ne .Env.GOOS "linux") }}' platforms: - linux/amd64 - linux/arm64 archives: - id: macos-archive builds: [macos] name_template: "bk_{{ .Version }}_macOS_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" wrap_in_directory: true format: zip files: - LICENSE.md - README.md - id: linux-archive builds: [linux] name_template: "bk_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" wrap_in_directory: true format: tar.gz files: - LICENSE.md - README.md - id: windows-archive builds: [windows] name_template: "bk_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" wrap_in_directory: false format: zip files: - LICENSE.md - README.md nfpms: - builds: - linux vendor: Buildkite homepage: https://buildkite.com maintainer: Buildkite <support@buildkite.com> description: A command line interface for Buildkite. license: MIT formats: - apk - deb - rpm provides: - bk # vim: set ts=2 sw=2 tw=0 fo=cnqoj ================================================ FILE: .graphqlrc.yml ================================================ schema: schema.graphql ================================================ FILE: AGENT.md ================================================ This project is the Buildkite CLI (`bk`) ## Commands - Bootstrap: `mise install` - Hooks: `mise run hooks` - Format: `mise run format` - Test: `mise run test` - Lint: `mise run lint` - Generate: `mise run generate` (required after GraphQL changes) - Run: `go run main.go` ## Environment - `BUILDKITE_GRAPHQL_TOKEN` required for development ## Project Structure - Main binary: `main.go` - GraphQL schema: `schema.graphql` - CLI commands: `pkg/cmd/` ## Notes - `mise.toml` pins the local Go/tool versions - CI: https://buildkite.com/buildkite/buildkite-cli - Always format after changing code ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing We welcome contributions from the community to make Buildkite CLI, `bk`, project even better. ## Getting Started To get started with contributing, please follow these steps: 1. Fork the repository. 2. Create a feature branch with a nice name (`git checkout -b cli-new-feature`) for your changes. 3. Install [mise](https://mise.jdx.dev/) and run `mise install`. 4. Install the local git hooks with `mise run hooks`. 5. Write your code. 6. Run the local checks before opening a pull request. * Format the code with `mise run format`. * Lint with `mise run lint`. * Make sure the tests pass with `mise run test`. * Run `mise run generate` after GraphQL changes. If you need to refresh `schema.graphql`, set `BUILDKITE_GRAPHQL_TOKEN` first. 7. Commit your changes and push them to your forked repository. 8. Submit a pull request with a detailed description of your changes and links to any relevant issues. The team maintaining this codebase will review your PR and start a CI build for it. For security reasons, we don't automatically run CI against forked repos, and a human will review your PR prior to its CI running. ## Testing There is a continuous integration pipeline on Buildkite: https://buildkite.com/buildkite/buildkite-cli ## Releasing Builds on `main` include a block step to "Create a release". The step takes a tag name, then takes care of tagging the built commit. New tags trigger the release pipeline: https://buildkite.com/buildkite/buildkite-cli-release This will prepare a new draft release on GitHub: https://github.com/buildkite/cli/releases To release, edit the draft and _Publish release_. ## Reporting Issues If you encounter any issues or have suggestions for improvements, please open an issue on the GitHub repository. Provide as much detail as possible, including steps to reproduce the issue. ## Contact If we're really dragging our feet on reviewing a PR, please feel free to ping us through GitHub or Slack, or get in touch with [support@buildkite.com](mailto:support@buildkite.com), and they can bug us to get things done :) Happy contributing! ================================================ FILE: Dockerfile ================================================ FROM golang:1.26-alpine AS base RUN apk add --no-cache git ca-certificates WORKDIR /base COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o bk . FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /cli COPY --from=base /base/bk . ENV PATH="/cli:${PATH}" ENTRYPOINT ["bk"] ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2014-2023 Buildkite Pty Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # bk - The Buildkite CLI [![Latest Release](https://img.shields.io/github/v/release/buildkite/cli?include_prereleases&sort=semver&display_name=release&logo=buildkite)](https://github.com/buildkite/cli/releases) A command line interface for [Buildkite](https://buildkite.com/). Full documentation is available at [buildkite.com/docs/platform/cli](https://buildkite.com/docs/platform/cli). ## Quick Start ### Install ```sh brew tap buildkite/buildkite && brew install buildkite/buildkite/bk ``` Or download a binary from the [releases page](https://github.com/buildkite/cli/releases). ### Authenticate ```sh bk auth login ``` ## Feedback We'd love to hear any feedback and questions you might have. Please [file an issue on GitHub](https://github.com/buildkite/cli/issues) and let us know! ## Development This repository uses [mise](https://mise.jdx.dev/) to pin Go and the main local development tools. ```bash git clone git@github.com:buildkite/cli.git cd cli/ mise install mise run build mise run install mise run install:global mise run hooks mise run format mise run lint mise run test mise run generate go run main.go --help ``` `mise.toml` pins the shared toolchain, including the release helpers used in CI. The module itself remains compatible with Go `1.25.0` as declared in `go.mod`. ================================================ FILE: cmd/agent/agent_test.go ================================================ package agent import ( "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/spf13/afero" ) func TestParseAgentArg(t *testing.T) { t.Parallel() testcases := map[string]struct { url, org, agent string }{ "slug": { url: "buildkite/abcd", org: "buildkite", agent: "abcd", }, "id": { url: "abcd", org: "testing", agent: "abcd", }, "url": { url: "https://buildkite.com/organizations/buildkite/agents/018a4a65-bfdb-4841-831a-ff7c1ddbad99", org: "buildkite", agent: "018a4a65-bfdb-4841-831a-ff7c1ddbad99", }, "clustered url": { url: "https://buildkite.com/organizations/buildkite/clusters/0b7c9944-10ba-434d-9dbb-b332431252de/queues/3d039cf8-9862-4cb0-82cd-fc5c497a265a/agents/018c3d31-1b4a-454a-87f6-190b206e3759", org: "buildkite", agent: "018c3d31-1b4a-454a-87f6-190b206e3759", }, } for name, testcase := range testcases { testcase := testcase t.Run(name, func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) org, agent := parseAgentArg(testcase.url, conf) if org != testcase.org { t.Error("parsed organization slug did not match expected") } if agent != testcase.agent { t.Error("parsed agent ID did not match expected") } }) } } ================================================ FILE: cmd/agent/install.go ================================================ package agent import ( "context" "fmt" "os" "path/filepath" "runtime" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" bkAgent "github.com/buildkite/cli/v3/internal/agent" ) var ( userArch = runtime.GOARCH userOS = runtime.GOOS ) // InstallCmd allows users to define which agent version they want to install // We will take care of OS/arch in the command itself type InstallCmd struct { Version string `help:"Specify an agent version to install" default:"latest"` Dest string `help:"Destination directory for the binary" type:"path"` ClusterUUID string `help:"Cluster UUID to create the agent token on (default: the \"Default\" cluster)" name:"cluster-uuid" optional:""` NoToken bool `help:"Skip creating an agent token and config file" name:"no-token"` ConfigPath string `help:"Path to write the agent config file" type:"path"` } func (i *InstallCmd) Help() string { return `Install the buildkite-agent binary locally. By default, this also creates an agent token on the Default cluster and writes a minimal config file so the agent is ready to start. Examples: # Install the latest version of the agent $ bk agent install # Install a specific version $ bk agent install --version "3.112.0" # Install to a custom location $ bk agent install --dest ~/.local/bin # Install without creating a token/config $ bk agent install --no-token ` } func (i *InstallCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { dest := i.Dest if dest == "" { dest = bkAgent.DefaultBinDir(userOS) } // Check for existing installations in PATH if existing := bkAgent.FindExisting(userOS); existing != nil { destBinary := filepath.Join(dest, bkAgent.BinaryName(userOS)) if existing.Path != destBinary { fmt.Printf("Warning: existing buildkite-agent found at %s", existing.Path) if existing.Version != "" { fmt.Printf(" (%s)", existing.Version) } fmt.Println() fmt.Printf(" The new install at %s may be shadowed in your PATH.\n", destBinary) fmt.Println() } } if err := os.MkdirAll(dest, 0o755); err != nil { return fmt.Errorf("creating destination directory: %w", err) } version := i.Version if version == "latest" { resolved, err := bkAgent.ResolveLatestVersion() if err != nil { return fmt.Errorf("resolving latest version: %w", err) } version = resolved } version = strings.TrimPrefix(version, "v") downloadURL := bkAgent.BuildDownloadURL(version, userOS, userArch) fmt.Printf("Downloading buildkite-agent v%s for %s/%s...\n", version, userOS, userArch) tmpFile, err := bkAgent.DownloadToTemp(downloadURL) if err != nil { return fmt.Errorf("downloading agent: %w", err) } defer os.Remove(tmpFile) // Verify the download checksum fmt.Println("Verifying checksum...") sumsURL := bkAgent.BuildSHA256SumsURL(version) archiveFilename := filepath.Base(downloadURL) expectedHash, err := bkAgent.FetchExpectedSHA256(sumsURL, archiveFilename) if err != nil { return fmt.Errorf("fetching checksum: %w", err) } if err := bkAgent.VerifySHA256(tmpFile, expectedHash); err != nil { return fmt.Errorf("checksum verification failed: %w", err) } if err := bkAgent.ExtractBinary(tmpFile, dest, userOS); err != nil { return fmt.Errorf("extracting agent: %w", err) } binaryName := bkAgent.BinaryName(userOS) fmt.Printf("Installed buildkite-agent to %s\n", filepath.Join(dest, binaryName)) if !i.NoToken { if err := i.createTokenAndConfig(globals); err != nil { return err } } return nil } func (i *InstallCmd) createTokenAndConfig(globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return fmt.Errorf("initializing API client: %w", err) } ctx := context.Background() org := f.Config.OrganizationSlug() clusterID, err := bkAgent.FindCluster(ctx, f, org, i.ClusterUUID) if err != nil { return fmt.Errorf("finding default cluster: %w", err) } fmt.Println("Creating agent token...") token, err := bkAgent.CreateAgentToken(ctx, f, org, clusterID, "Token created by bk agent install") if err != nil { return fmt.Errorf("creating agent token: %w", err) } configPath := i.ConfigPath if configPath == "" { configPath = bkAgent.DefaultConfigPath(userOS) } buildPath := bkAgent.DefaultBuildPath(userOS) if err := bkAgent.WriteAgentConfig(configPath, token, buildPath, nil); err != nil { return fmt.Errorf("writing agent config: %w", err) } fmt.Printf("Agent config written to %s\n", configPath) return nil } ================================================ FILE: cmd/agent/list.go ================================================ package agent import ( "context" "fmt" "os" "slices" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) const ( stateRunning = "running" stateIdle = "idle" statePaused = "paused" ) var validStates = []string{stateRunning, stateIdle, statePaused} type ListCmd struct { Name string `help:"Filter agents by their name"` Version string `help:"Filter agents by their version"` Hostname string `help:"Filter agents by their hostname"` State string `help:"Filter agents by state (running, idle, paused)"` Tags []string `help:"Filter agents by tags"` PerPage int `help:"Number of agents per page" default:"30"` Limit int `help:"Maximum number of agents to return" default:"100"` output.OutputFlags } func (c *ListCmd) Help() string { return `By default, shows up to 100 agents. Use filters to narrow results, or increase the number of agents displayed with --limit. Examples: # List all agents $ bk agent list # List agents with JSON output $ bk agent list --output json # List only running agents (currently executing jobs) $ bk agent list --state running # List only idle agents (connected but not running jobs) $ bk agent list --state idle # List only paused agents $ bk agent list --state paused # Filter agents by hostname $ bk agent list --hostname my-server-01 # Combine state and hostname filters $ bk agent list --state idle --hostname my-server-01 # Filter agents by tags $ bk agent list --tags queue=default # Filter agents by multiple tags (all must match) $ bk agent list --tags queue=default --tags os=linux # Multiple filters with output format $ bk agent list --state running --version 3.107.2 --output json` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() if err := validateState(c.State); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) agents := []buildkite.Agent{} page := 1 hasMore := false var previousFirstAgentID string for len(agents) < c.Limit { opts := buildkite.AgentListOptions{ Name: c.Name, Hostname: c.Hostname, Version: c.Version, ListOptions: buildkite.ListOptions{ Page: page, PerPage: c.PerPage, }, } pageAgents, _, err := f.RestAPIClient.Agents.List(ctx, f.Config.OrganizationSlug(), &opts) if err != nil { return err } if len(pageAgents) == 0 { break } if page > 1 && len(pageAgents) > 0 && pageAgents[0].ID == previousFirstAgentID { return fmt.Errorf("API returned duplicate page content at page %d, stopping pagination to prevent infinite loop", page) } if len(pageAgents) > 0 { previousFirstAgentID = pageAgents[0].ID } filtered := filterAgents(pageAgents, c.State, c.Tags) agents = append(agents, filtered...) // If this was a full page, there might be more results // We'll check after breaking from the loop if we hit the limit with a full page if len(pageAgents) < c.PerPage { break } // Check if we've hit the limit before fetching more if len(agents) >= c.Limit { // We hit the limit with a full page, so there are likely more results hasMore = true break } page++ } totalFetched := len(agents) if len(agents) > c.Limit { agents = agents[:c.Limit] } if format != output.FormatText { return output.Write(os.Stdout, agents, format) } if len(agents) == 0 { fmt.Println("No agents found") return nil } headers := []string{"State", "Name", "Version", "Queue", "Hostname"} rows := make([][]string, len(agents)) for i, agent := range agents { queue := extractQueue(agent.Metadata) state := displayState(agent) rows[i] = []string{ state, agent.Name, agent.Version, queue, agent.Hostname, } } columnStyles := map[string]string{ "state": "bold", "name": "bold", "hostname": "dim", "version": "italic", "queue": "italic", } table := output.Table(headers, rows, columnStyles) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() totalDisplay := fmt.Sprintf("%d", totalFetched) if hasMore { totalDisplay = fmt.Sprintf("%d+", totalFetched) } fmt.Fprintf(writer, "Showing %d of %s agents in %s\n\n", len(agents), totalDisplay, f.Config.OrganizationSlug()) fmt.Fprint(writer, table) return nil } func validateState(state string) error { if state == "" { return nil } normalized := strings.ToLower(state) if slices.Contains(validStates, normalized) { return nil } return fmt.Errorf("invalid state %q: must be one of %s, %s, or %s", state, stateRunning, stateIdle, statePaused) } func filterAgents(agents []buildkite.Agent, state string, tags []string) []buildkite.Agent { filtered := make([]buildkite.Agent, 0, len(agents)) for _, a := range agents { if matchesState(a, state) && matchesTags(a, tags) { filtered = append(filtered, a) } } return filtered } func matchesState(a buildkite.Agent, state string) bool { if state == "" { return true } normalized := strings.ToLower(state) switch normalized { case stateRunning: return a.Job != nil case stateIdle: return a.Job == nil && (a.Paused == nil || !*a.Paused) case statePaused: return a.Paused != nil && *a.Paused default: return false } } func matchesTags(a buildkite.Agent, tags []string) bool { if len(tags) == 0 { return true } for _, tag := range tags { if !hasTag(a.Metadata, tag) { return false } } return true } func hasTag(metadata []string, tag string) bool { return slices.Contains(metadata, tag) } func extractQueue(metadata []string) string { for _, m := range metadata { if after, ok := strings.CutPrefix(m, "queue="); ok { return after } } return "default" } func displayState(a buildkite.Agent) string { if a.Job != nil { return stateRunning } if a.Paused != nil && *a.Paused { return statePaused } return stateIdle } ================================================ FILE: cmd/agent/list_test.go ================================================ package agent import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) func testFilterAgents(agents []buildkite.Agent, state string, tags []string) []buildkite.Agent { return filterAgents(agents, state, tags) } func TestCmdAgentList(t *testing.T) { t.Parallel() t.Run("fetches agents through API", func(t *testing.T) { t.Parallel() paused := false agents := []buildkite.Agent{ { ID: "123", Name: "my-agent", ConnectedState: "connected", Version: "3.50.0", Hostname: "host1", Metadata: []string{"queue=default"}, Paused: &paused, }, { ID: "456", Name: "another-agent", ConnectedState: "idle", Version: "3.51.0", Hostname: "host2", Metadata: []string{"queue=deploy", "os=linux"}, Paused: &paused, }, } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page := r.URL.Query().Get("page") if page == "" || page == "1" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(agents) } else { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]buildkite.Agent{}) } })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() fetchedAgents, _, err := client.Agents.List(ctx, "test-org", &buildkite.AgentListOptions{ ListOptions: buildkite.ListOptions{Page: 1, PerPage: 30}, }) if err != nil { t.Fatal(err) } if len(fetchedAgents) != 2 { t.Fatalf("expected 2 agents, got %d", len(fetchedAgents)) } if fetchedAgents[0].Name != "my-agent" { t.Errorf("expected first agent name 'my-agent', got %q", fetchedAgents[0].Name) } if fetchedAgents[1].Hostname != "host2" { t.Errorf("expected second agent hostname 'host2', got %q", fetchedAgents[1].Hostname) } queue := extractQueue(fetchedAgents[0].Metadata) if queue != "default" { t.Errorf("expected queue 'default', got %q", queue) } deployQueue := extractQueue(fetchedAgents[1].Metadata) if deployQueue != "deploy" { t.Errorf("expected queue 'deploy', got %q", deployQueue) } }) t.Run("renders table output", func(t *testing.T) { t.Parallel() paused := false agents := []buildkite.Agent{ { ID: "agent-1", Name: "test-agent", ConnectedState: "connected", Job: &buildkite.Job{ID: "job-1"}, Version: "3.50.0", Hostname: "test-host", Metadata: []string{"queue=default"}, Paused: &paused, }, } headers := []string{"State", "Name", "Version", "Queue", "Hostname"} rows := make([][]string, len(agents)) for i, agent := range agents { queue := extractQueue(agent.Metadata) state := displayState(agent) rows[i] = []string{ state, agent.Name, agent.Version, queue, agent.Hostname, } } columnStyles := map[string]string{ "state": "bold", "name": "bold", "hostname": "dim", "version": "italic", "queue": "italic", } table := output.Table(headers, rows, columnStyles) if !strings.Contains(table, "STATE") { t.Error("expected table to contain header 'STATE'") } if !strings.Contains(table, "test-agent") { t.Error("expected table to contain agent name") } if !strings.Contains(table, "running") { t.Error("expected table to contain semantic state 'running'") } if !strings.Contains(table, "3.50.0") { t.Error("expected table to contain version") } if !strings.Contains(table, "default") { t.Error("expected table to contain queue") } if !strings.Contains(table, "test-host") { t.Error("expected table to contain hostname") } }) t.Run("empty result returns empty array", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]buildkite.Agent{}) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() agents, _, err := client.Agents.List(ctx, "test-org", &buildkite.AgentListOptions{ ListOptions: buildkite.ListOptions{Page: 1, PerPage: 30}, }) if err != nil { t.Fatal(err) } if len(agents) != 0 { t.Errorf("expected 0 agents, got %d", len(agents)) } }) } func TestAgentListStateFilter(t *testing.T) { t.Parallel() paused := true notPaused := false agents := []buildkite.Agent{ {ID: "1", Name: "running-agent", Job: &buildkite.Job{ID: "job-1"}}, {ID: "2", Name: "idle-agent"}, {ID: "3", Name: "paused-agent", Paused: &paused}, {ID: "4", Name: "idle-not-paused", Paused: ¬Paused}, } tests := []struct { state string want []string // agent IDs }{ {"running", []string{"1"}}, {"RUNNING", []string{"1"}}, {"idle", []string{"2", "4"}}, {"paused", []string{"3"}}, {"", []string{"1", "2", "3", "4"}}, } for _, tt := range tests { t.Run(tt.state, func(t *testing.T) { t.Parallel() result := testFilterAgents(agents, tt.state, nil) if len(result) != len(tt.want) { t.Errorf("got %d agents, want %d", len(result), len(tt.want)) } for i, id := range tt.want { if i >= len(result) || result[i].ID != id { t.Errorf("agent %d: got ID %q, want %q", i, result[i].ID, id) } } }) } } func TestAgentListInvalidState(t *testing.T) { t.Parallel() err := validateState("invalid") if err == nil { t.Fatal("expected error for invalid state, got nil") } if !strings.Contains(err.Error(), "invalid state") { t.Errorf("expected error to mention 'invalid state', got: %v", err) } } func TestDisplayState(t *testing.T) { t.Parallel() paused := true notPaused := false tests := []struct { name string agent buildkite.Agent want string }{ { name: "running when job present", agent: buildkite.Agent{Job: &buildkite.Job{ID: "job-1"}}, want: stateRunning, }, { name: "paused when paused flag", agent: buildkite.Agent{Paused: &paused}, want: statePaused, }, { name: "idle default", agent: buildkite.Agent{Paused: ¬Paused}, want: stateIdle, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got := displayState(tt.agent) if got != tt.want { t.Fatalf("displayState() = %q, want %q", got, tt.want) } }) } } func TestAgentListTagsFilter(t *testing.T) { t.Parallel() agents := []buildkite.Agent{ {ID: "1", Name: "default-linux", Metadata: []string{"queue=default", "os=linux"}}, {ID: "2", Name: "deploy-macos", Metadata: []string{"queue=deploy", "os=macos"}}, {ID: "3", Name: "default-macos", Metadata: []string{"queue=default", "os=macos"}}, {ID: "4", Name: "no-metadata"}, } tests := []struct { name string tags []string want []string }{ {"single tag", []string{"queue=default"}, []string{"1", "3"}}, {"multiple tags AND", []string{"queue=default", "os=linux"}, []string{"1"}}, {"no match", []string{"queue=nonexistent"}, []string{}}, {"no tags filter", []string{}, []string{"1", "2", "3", "4"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := testFilterAgents(agents, "", tt.tags) if len(result) != len(tt.want) { t.Errorf("got %d agents, want %d", len(result), len(tt.want)) } for i, id := range tt.want { if i >= len(result) || result[i].ID != id { t.Errorf("agent %d: got ID %q, want %q", i, result[i].ID, id) } } }) } } func TestAgentListPagination(t *testing.T) { t.Parallel() t.Run("stops on partial page", func(t *testing.T) { t.Parallel() // Mock server that returns 30 agents on page 1, 15 on page 2 callCount := 0 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ page := r.URL.Query().Get("page") w.Header().Set("Content-Type", "application/json") switch page { case "", "1": agents := make([]buildkite.Agent, 30) for i := range agents { agents[i] = buildkite.Agent{ID: fmt.Sprintf("page1-agent-%d", i), Name: "agent"} } json.NewEncoder(w).Encode(agents) case "2": agents := make([]buildkite.Agent, 15) for i := range agents { agents[i] = buildkite.Agent{ID: fmt.Sprintf("page2-agent-%d", i), Name: "agent"} } json.NewEncoder(w).Encode(agents) default: json.NewEncoder(w).Encode([]buildkite.Agent{}) } })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } // Simulate pagination loop var agents []buildkite.Agent page := 1 limit := 100 perPage := 30 var previousFirstAgentID string for len(agents) < limit { opts := &buildkite.AgentListOptions{ ListOptions: buildkite.ListOptions{ Page: page, PerPage: perPage, }, } pageAgents, _, err := client.Agents.List(context.Background(), "test-org", opts) if err != nil { t.Fatal(err) } if len(pageAgents) == 0 { break } if page > 1 && len(pageAgents) > 0 && pageAgents[0].ID == previousFirstAgentID { t.Fatal("detected duplicate page") } if len(pageAgents) > 0 { previousFirstAgentID = pageAgents[0].ID } agents = append(agents, pageAgents...) // Natural pagination end if len(pageAgents) < perPage { break } page++ } // Should have fetched 45 agents total (30 + 15) if len(agents) != 45 { t.Errorf("expected 45 agents, got %d", len(agents)) } // Should have made exactly 2 API calls (page 1 and page 2) if callCount != 2 { t.Errorf("expected 2 API calls, got %d", callCount) } }) t.Run("detects duplicate pages", func(t *testing.T) { t.Parallel() // Mock server that returns same page twice s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Always return same agents regardless of page agents := []buildkite.Agent{ {ID: "agent-1", Name: "test"}, {ID: "agent-2", Name: "test"}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(agents) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } // Simulate pagination loop var agents []buildkite.Agent page := 1 limit := 100 perPage := 30 var previousFirstAgentID string duplicateDetected := false for len(agents) < limit && page < 5 { opts := &buildkite.AgentListOptions{ ListOptions: buildkite.ListOptions{ Page: page, PerPage: perPage, }, } pageAgents, _, err := client.Agents.List(context.Background(), "test-org", opts) if err != nil { t.Fatal(err) } if len(pageAgents) == 0 { break } // Detect duplicate if page > 1 && len(pageAgents) > 0 && pageAgents[0].ID == previousFirstAgentID { duplicateDetected = true break } if len(pageAgents) > 0 { previousFirstAgentID = pageAgents[0].ID } agents = append(agents, pageAgents...) page++ } if !duplicateDetected { t.Error("expected duplicate page detection to trigger") } }) t.Run("continues on full pages with different content", func(t *testing.T) { t.Parallel() // Mock server that returns different full pages s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page := r.URL.Query().Get("page") w.Header().Set("Content-Type", "application/json") agents := make([]buildkite.Agent, 30) prefix := "a" switch page { case "2": prefix = "b" case "3": prefix = "c" } for i := range agents { agents[i] = buildkite.Agent{ ID: fmt.Sprintf("%s-agent-%d", prefix, i), Name: "agent", } } if page == "3" { // Make page 3 partial to end pagination agents = agents[:10] } json.NewEncoder(w).Encode(agents) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } // Simulate pagination loop var agents []buildkite.Agent page := 1 limit := 100 perPage := 30 var previousFirstAgentID string for len(agents) < limit { opts := &buildkite.AgentListOptions{ ListOptions: buildkite.ListOptions{ Page: page, PerPage: perPage, }, } pageAgents, _, err := client.Agents.List(context.Background(), "test-org", opts) if err != nil { t.Fatal(err) } if len(pageAgents) == 0 { break } if page > 1 && len(pageAgents) > 0 && pageAgents[0].ID == previousFirstAgentID { t.Fatal("unexpected duplicate page") } if len(pageAgents) > 0 { previousFirstAgentID = pageAgents[0].ID } agents = append(agents, pageAgents...) if len(pageAgents) < perPage { break } page++ } // Should have fetched 70 agents (30 + 30 + 10) if len(agents) != 70 { t.Errorf("expected 70 agents, got %d", len(agents)) } }) } ================================================ FILE: cmd/agent/pause.go ================================================ package agent import ( "context" "fmt" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" ) type PauseCmd struct { AgentID string `arg:"" help:"Agent ID to pause"` Note string `help:"A descriptive note to record why the agent is paused"` TimeoutInMinutes int `help:"Timeout after which the agent is automatically resumed, in minutes" default:"5"` } func (c *PauseCmd) Help() string { return `When an agent is paused, it will stop accepting new jobs but will continue running any jobs it has already started. You can optionally provide a note explaining why the agent is being paused and set a timeout for automatic resumption. The timeout must be between 1 and 1440 minutes (24 hours). If no timeout is specified, the agent will pause for 5 minutes by default. Examples: # Pause an agent for 5 minutes (default) $ bk agent pause 0198d108-a532-4a62-9bd7-b2e744bf5c45 # Pause an agent with a note $ bk agent pause 0198d108-a532-4a62-9bd7-b2e744bf5c45 --note "Maintenance scheduled" # Pause an agent with a note and 60 minute timeout $ bk agent pause 0198d108-a532-4a62-9bd7-b2e744bf5c45 --note "too many llamas" --timeout-in-minutes 60 # Pause for a short time (15 minutes) during deployment $ bk agent pause 0198d108-a532-4a62-9bd7-b2e744bf5c45 --note "Deploy in progress" --timeout-in-minutes 15` } func (c *PauseCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() if c.TimeoutInMinutes <= 0 { return fmt.Errorf("timeout-in-minutes must be 1 or more") } if c.TimeoutInMinutes > 1440 { return fmt.Errorf("timeout-in-minutes cannot exceed 1440 minutes (1 day)") } var pauseOpts *buildkite.AgentPauseOptions if c.Note != "" || c.TimeoutInMinutes > 0 { pauseOpts = &buildkite.AgentPauseOptions{ Note: c.Note, TimeoutInMinutes: c.TimeoutInMinutes, } } _, err = f.RestAPIClient.Agents.Pause(ctx, f.Config.OrganizationSlug(), c.AgentID, pauseOpts) if err != nil { return fmt.Errorf("failed to pause agent: %w", err) } message := fmt.Sprintf("Agent %s paused successfully", c.AgentID) if c.Note != "" { message += fmt.Sprintf(" with note: %s", c.Note) } if c.TimeoutInMinutes > 0 { message += fmt.Sprintf(" (auto-resume in %d minutes)", c.TimeoutInMinutes) } fmt.Printf("%s\n", message) return nil } ================================================ FILE: cmd/agent/pause_test.go ================================================ package agent import ( "testing" ) func TestPauseCmdValidation(t *testing.T) { t.Parallel() tests := []struct { name string timeout int wantErr bool errMsg string }{ {"valid timeout", 60, false, ""}, {"minimum valid timeout", 1, false, ""}, {"maximum valid timeout", 1440, false, ""}, {"zero timeout invalid", 0, true, "timeout-in-minutes must be 1 or more"}, {"negative timeout invalid", -1, true, "timeout-in-minutes must be 1 or more"}, {"excessive timeout invalid", 1441, true, "timeout-in-minutes cannot exceed 1440 minutes (1 day)"}, {"very large timeout invalid", 10000, true, "timeout-in-minutes cannot exceed 1440 minutes (1 day)"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() cmd := &PauseCmd{ TimeoutInMinutes: tt.timeout, } var err error if cmd.TimeoutInMinutes <= 0 { err = errValidation("timeout-in-minutes must be 1 or more") } else if cmd.TimeoutInMinutes > 1440 { err = errValidation("timeout-in-minutes cannot exceed 1440 minutes (1 day)") } if tt.wantErr { if err == nil { t.Errorf("expected error but got none") } else if err.Error() != tt.errMsg { t.Errorf("expected error %q, got %q", tt.errMsg, err.Error()) } } else { if err != nil { t.Errorf("unexpected error: %v", err) } } }) } } type validationError string func (e validationError) Error() string { return string(e) } func errValidation(msg string) error { return validationError(msg) } ================================================ FILE: cmd/agent/resume.go ================================================ package agent import ( "context" "fmt" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type ResumeCmd struct { AgentID string `arg:"" help:"Agent ID to resume"` } func (c *ResumeCmd) Help() string { return `Resume a paused Buildkite agent. When an agent is resumed, it will start accepting new jobs again. Examples: # Resume an agent $ bk agent resume 0198d108-a532-4a62-9bd7-b2e744bf5c45` } func (c *ResumeCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() _, err = f.RestAPIClient.Agents.Resume(ctx, f.Config.OrganizationSlug(), c.AgentID) if err != nil { return fmt.Errorf("failed to resume agent: %w", err) } fmt.Printf("Agent %s resumed successfully\n", c.AgentID) return nil } ================================================ FILE: cmd/agent/resume_test.go ================================================ package agent import ( "testing" ) func TestResumeCmdStructure(t *testing.T) { t.Parallel() cmd := &ResumeCmd{ AgentID: "test-agent-123", } if cmd.AgentID != "test-agent-123" { t.Errorf("expected AgentID to be %q, got %q", "test-agent-123", cmd.AgentID) } } func TestResumeCmdHelp(t *testing.T) { t.Parallel() cmd := &ResumeCmd{} help := cmd.Help() if help == "" { t.Error("Help() should return non-empty string") } if len(help) < 10 { t.Errorf("Help text seems too short: %q", help) } } ================================================ FILE: cmd/agent/run.go ================================================ package agent import ( "context" "fmt" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" bkAgent "github.com/buildkite/cli/v3/internal/agent" ) // RunCmd spins up an ephemeral buildkite-agent attached to a cluster. type RunCmd struct { Version string `help:"Specify an agent version to run" default:"latest"` ClusterUUID string `help:"Cluster UUID to create the agent token on (default: the \"Default\" cluster)" name:"cluster-uuid" optional:""` Queue string `help:"Queue for the agent to listen on" default:"default"` } func (r *RunCmd) Help() string { return `Run an ephemeral buildkite-agent locally. Downloads the agent binary, creates a cluster token, and starts the agent. All temporary files are cleaned up when the agent is stopped with Ctrl+C. Examples: # Run the latest agent on the Default cluster $ bk agent run # Run a specific version $ bk agent run --version "3.112.0" # Run on a specific cluster $ bk agent run --cluster-uuid "01234567-89ab-cdef-0123-456789abcdef" # Run on a specific queue $ bk agent run --queue "deploy" ` } func (r *RunCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { // Track temp directory for cleanup tmpDir, err := os.MkdirTemp("", "bk-agent-run-*") if err != nil { return fmt.Errorf("creating temp directory: %w", err) } defer func() { fmt.Println("Cleaning up temporary files...") os.RemoveAll(tmpDir) }() targetOS := runtime.GOOS targetArch := runtime.GOARCH version := r.Version if version == "latest" { resolved, err := bkAgent.ResolveLatestVersion() if err != nil { return fmt.Errorf("resolving latest version: %w", err) } version = resolved } version = strings.TrimPrefix(version, "v") downloadURL := bkAgent.BuildDownloadURL(version, targetOS, targetArch) fmt.Printf("Downloading buildkite-agent v%s for %s/%s...\n", version, targetOS, targetArch) tmpFile, err := bkAgent.DownloadToTemp(downloadURL) if err != nil { return fmt.Errorf("downloading agent: %w", err) } defer os.Remove(tmpFile) fmt.Println("Verifying checksum...") sumsURL := bkAgent.BuildSHA256SumsURL(version) archiveFilename := filepath.Base(downloadURL) expectedHash, err := bkAgent.FetchExpectedSHA256(sumsURL, archiveFilename) if err != nil { return fmt.Errorf("fetching checksum: %w", err) } if err := bkAgent.VerifySHA256(tmpFile, expectedHash); err != nil { return fmt.Errorf("checksum verification failed: %w", err) } if err := bkAgent.ExtractBinary(tmpFile, tmpDir, targetOS); err != nil { return fmt.Errorf("extracting agent: %w", err) } binaryPath := filepath.Join(tmpDir, bkAgent.BinaryName(targetOS)) // Create API client and provision a cluster token f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return fmt.Errorf("initializing API client: %w", err) } ctx := context.Background() org := f.Config.OrganizationSlug() clusterID, err := bkAgent.FindCluster(ctx, f, org, r.ClusterUUID) if err != nil { return fmt.Errorf("finding cluster: %w", err) } fmt.Println("Creating agent token...") token, err := bkAgent.CreateAgentToken(ctx, f, org, clusterID, "Ephemeral token created by bk agent run") if err != nil { return fmt.Errorf("creating agent token: %w", err) } // Write a temporary config file configPath := filepath.Join(tmpDir, "buildkite-agent.cfg") buildPath := filepath.Join(tmpDir, "builds") var tags []string if r.Queue != "" { tags = append(tags, "queue="+r.Queue) } if err := bkAgent.WriteAgentConfig(configPath, token, buildPath, tags); err != nil { return fmt.Errorf("writing agent config: %w", err) } // Catch signals so we wait for the agent to shut down gracefully sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) fmt.Printf("Starting buildkite-agent v%s...\n", version) cmd := exec.Command(binaryPath, "start", "--config", configPath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin if err := cmd.Start(); err != nil { return fmt.Errorf("starting agent: %w", err) } // Wait for the agent to exit in the background errCh := make(chan error, 1) go func() { errCh <- cmd.Wait() }() select { case <-sigCh: fmt.Println("\nShutting down agent...") // The agent already received the signal (same process group) // and will finish any running job before exiting. <-errCh fmt.Println("Agent stopped.") return nil case err := <-errCh: if err != nil { return fmt.Errorf("agent exited with error: %w", err) } return nil } } ================================================ FILE: cmd/agent/stop.go ================================================ package agent import ( "bufio" "context" "errors" "fmt" "os" "os/signal" "strings" "sync" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/mattn/go-isatty" ) type StopCmd struct { Agents []string `arg:"" optional:"" help:"Agent IDs to stop"` Force bool `help:"Force stop the agent. Terminating any jobs in progress"` Limit int64 `help:"Limit parallel API requests" short:"l" default:"5"` } func (c *StopCmd) Help() string { return `Instruct one or more agents to stop accepting new build jobs and shut itself down. Agents can be supplied as positional arguments or from STDIN, one per line. If the "ORGANIZATION_SLUG/" portion of the "ORGANIZATION_SLUG/UUID" agent argument is omitted, it uses the currently selected organization. The --force flag applies to all agents that are stopped. Examples: # Stop a single agent $ bk agent stop 0198d108-a532-4a62-9bd7-b2e744bf5c45 # Stop multiple agents $ bk agent stop agent-1 agent-2 agent-3 # Force stop an agent $ bk agent stop 0198d108-a532-4a62-9bd7-b2e744bf5c45 --force # Stop agents from STDIN $ cat agent-ids.txt | bk agent stop` } func (c *StopCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) defer signal.Stop(sigCh) go func() { select { case <-sigCh: cancel() case <-ctx.Done(): return } }() limit := max(c.Limit, 1) var agentIDs []string // this command accepts either input from stdin or positional arguments (not both) in that order // so we need to check if stdin has data for us to read and read that, otherwise use positional args and if // there are none, then we need to error // if stdin has data available, use that if bkIO.HasDataAvailable(os.Stdin) { scanner := bufio.NewScanner(os.Stdin) scanner.Split(bufio.ScanLines) for scanner.Scan() { id := scanner.Text() if strings.TrimSpace(id) != "" { agentIDs = append(agentIDs, id) } } if scanner.Err() != nil { return scanner.Err() } } else if len(c.Agents) > 0 { for _, id := range c.Agents { if strings.TrimSpace(id) != "" { agentIDs = append(agentIDs, id) } } } else { return errors.New("must supply agents to stop") } if len(agentIDs) == 0 { return errors.New("must supply agents to stop") } writer := os.Stdout isTTY := isatty.IsTerminal(writer.Fd()) total := len(agentIDs) label := "Stopping agents" if total == 1 { label = "Stopping agent" } workerCount := int(min(limit, int64(total))) work := make(chan string, workerCount) updates := make(chan stopResult, workerCount) var wg sync.WaitGroup for i := 0; i < workerCount; i++ { wg.Add(1) go func() { defer wg.Done() for agentID := range work { if ctx.Err() != nil { updates <- stopResult{id: agentID, err: ctx.Err()} continue } updates <- stopAgent(ctx, agentID, f, c.Force) } }() } go func() { for _, id := range agentIDs { select { case <-ctx.Done(): close(work) return case work <- id: } } close(work) }() go func() { wg.Wait() close(updates) }() succeeded := 0 failed := 0 completed := 0 var errorDetails []string if !f.Quiet { line := bkIO.ProgressLine(label, completed, total, succeeded, failed, 24) if isTTY { fmt.Fprint(writer, line) } else { fmt.Fprintln(writer, line) } } for update := range updates { completed++ if update.err != nil { failed++ errorDetails = append(errorDetails, fmt.Sprintf("FAILED %s: %v", update.id, update.err)) } else { succeeded++ } if !f.Quiet { line := bkIO.ProgressLine(label, completed, total, succeeded, failed, 24) if isTTY { fmt.Fprintf(writer, "\r%s", line) } else { fmt.Fprintln(writer, line) } } } if !f.Quiet && isTTY { fmt.Fprintln(writer) } summaryWriter := writer if failed > 0 { summaryWriter = os.Stderr } if len(errorDetails) > 0 { fmt.Fprintln(summaryWriter) for _, detail := range errorDetails { fmt.Fprintln(summaryWriter, detail) } } if !f.Quiet { agentLabel := pluralize("agent", total) failedLabel := pluralize("agent", failed) if failed > 0 { fmt.Fprintf(summaryWriter, "\nStopped %d of %d %s (%d %s failed)\n", succeeded, total, agentLabel, failed, failedLabel) } else { fmt.Fprintf(summaryWriter, "\nSuccessfully stopped %d of %d %s\n", succeeded, total, agentLabel) } } if failed > 0 { return fmt.Errorf("failed to stop %d of %d %s (see above for details)", failed, total, pluralize("agent", total)) } return nil } type stopResult struct { id string err error } func pluralize(word string, count int) string { if count == 1 { return word } return word + "s" } func stopAgent(ctx context.Context, id string, f *factory.Factory, force bool) stopResult { org, agentID := parseAgentArg(id, f.Config) _, err := f.RestAPIClient.Agents.Stop(ctx, org, agentID, force) return stopResult{id: id, err: err} } ================================================ FILE: cmd/agent/stop_test.go ================================================ package agent import ( "fmt" "strings" "testing" "github.com/buildkite/cli/v3/internal/config" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/spf13/afero" ) func TestStopCmdStructure(t *testing.T) { t.Parallel() cmd := &StopCmd{ Agents: []string{"agent-1", "agent-2"}, Limit: 5, Force: true, } if len(cmd.Agents) != 2 { t.Errorf("expected 2 agents, got %d", len(cmd.Agents)) } if cmd.Limit != 5 { t.Errorf("expected Limit to be 5, got %d", cmd.Limit) } if !cmd.Force { t.Error("expected Force to be true") } } func TestStopCmdHelp(t *testing.T) { t.Parallel() cmd := &StopCmd{} help := cmd.Help() if help == "" { t.Error("Help() should return non-empty string") } if !strings.Contains(strings.ToLower(help), "agent") { t.Error("Help text should mention agents") } } func TestStopAgentErrorCollection(t *testing.T) { t.Parallel() t.Run("parses agent arg correctly", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("default-org", false) tests := []struct { name string input string expectedOrg string expectedID string }{ { name: "agent ID only", input: "agent-123", expectedOrg: "default-org", expectedID: "agent-123", }, { name: "org/agent format", input: "custom-org/agent-456", expectedOrg: "custom-org", expectedID: "agent-456", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { org, id := parseAgentArg(tt.input, conf) if org != tt.expectedOrg { t.Errorf("expected org %q, got %q", tt.expectedOrg, org) } if id != tt.expectedID { t.Errorf("expected id %q, got %q", tt.expectedID, id) } }) } }) } func TestStopAgentBulkOperationErrorHandling(t *testing.T) { t.Parallel() // This test verifies the error collection logic without running the full command t.Run("error details format", func(t *testing.T) { t.Parallel() errorDetails := []string{} // Simulate collecting errors updates := []stopResult{ {id: "agent-1", err: nil}, {id: "agent-2", err: fmt.Errorf("connection timeout")}, {id: "agent-3", err: nil}, {id: "agent-4", err: fmt.Errorf("not found")}, } for _, update := range updates { if update.err != nil { errorDetails = append(errorDetails, fmt.Sprintf("FAILED %s: %v", update.id, update.err)) } } if len(errorDetails) != 2 { t.Errorf("expected 2 error details, got %d", len(errorDetails)) } if !strings.Contains(errorDetails[0], "agent-2") { t.Error("expected first error to mention agent-2") } if !strings.Contains(errorDetails[1], "agent-4") { t.Error("expected second error to mention agent-4") } if !strings.Contains(errorDetails[0], "connection timeout") { t.Error("expected first error to include 'connection timeout'") } if !strings.Contains(errorDetails[1], "not found") { t.Error("expected second error to include 'not found'") } }) t.Run("progress tracking", func(t *testing.T) { t.Parallel() total := 10 succeeded := 0 failed := 0 completed := 0 updates := []stopResult{ {id: "agent-1", err: nil}, {id: "agent-2", err: nil}, {id: "agent-3", err: fmt.Errorf("timeout")}, {id: "agent-4", err: nil}, {id: "agent-5", err: fmt.Errorf("not found")}, } for _, update := range updates { completed++ if update.err != nil { failed++ } else { succeeded++ } } if completed != 5 { t.Errorf("expected completed=5, got %d", completed) } if succeeded != 3 { t.Errorf("expected succeeded=3, got %d", succeeded) } if failed != 2 { t.Errorf("expected failed=2, got %d", failed) } expectedPercent := (completed * 100) / total if expectedPercent != 50 { t.Errorf("expected 50%% progress, got %d%%", expectedPercent) } }) } func TestStopProgressOutput(t *testing.T) { t.Parallel() t.Run("progress line format", func(t *testing.T) { t.Parallel() line := bkIO.ProgressLine("Stopping agents", 5, 10, 3, 2, 6) if !strings.Contains(line, "Stopping agents") { t.Error("expected line to contain 'Stopping agents'") } if !strings.Contains(line, "50%") { t.Error("expected line to contain percentage") } if !strings.Contains(line, "5/10") { t.Error("expected line to contain completed/total") } if !strings.Contains(line, "succeeded:3") { t.Error("expected line to contain success count") } if !strings.Contains(line, "failed:2") { t.Error("expected line to contain fail count") } if !strings.Contains(line, "[") || !strings.Contains(line, "]") { t.Error("expected line to contain progress bar brackets") } }) } func TestPluralize(t *testing.T) { t.Parallel() tests := []struct { count int want string }{ {count: 1, want: "agent"}, {count: 0, want: "agents"}, {count: 2, want: "agents"}, } for _, tt := range tests { tt := tt t.Run(fmt.Sprintf("count_%d", tt.count), func(t *testing.T) { t.Parallel() if got := pluralize("agent", tt.count); got != tt.want { t.Fatalf("pluralize() = %q, want %q", got, tt.want) } }) } } ================================================ FILE: cmd/agent/util.go ================================================ package agent import ( "net/url" "strings" "github.com/buildkite/cli/v3/internal/config" ) func parseAgentArg(agent string, conf *config.Config) (string, string) { var org, id string agentIsURL := strings.Contains(agent, ":") agentIsSlug := !agentIsURL && strings.Contains(agent, "/") if agentIsURL { url, err := url.Parse(agent) if err != nil { return "", "" } part := strings.Split(url.Path, "/") if part[3] == "agents" { org, id = part[2], part[4] } else { org, id = part[2], part[len(part)-1] } } else { if agentIsSlug { part := strings.Split(agent, "/") org, id = part[0], part[1] } else { org = conf.OrganizationSlug() id = agent } } return org, id } ================================================ FILE: cmd/agent/view.go ================================================ package agent import ( "context" "fmt" "os" "strings" "time" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/pkg/browser" ) type ViewCmd struct { Agent string `arg:"" help:"Agent ID to view"` Web bool `help:"Open agent in a browser" short:"w"` output.OutputFlags } func (c *ViewCmd) Help() string { return `If the "ORGANIZATION_SLUG/" portion of the "ORGANIZATION_SLUG/UUID" agent argument is omitted, it uses the currently selected organization. Examples: # View an agent $ bk agent view 0198d108-a532-4a62-9bd7-b2e744bf5c45 # View an agent with organization slug $ bk agent view my-org/0198d108-a532-4a62-9bd7-b2e744bf5c45 # Open agent in browser $ bk agent view 0198d108-a532-4a62-9bd7-b2e744bf5c45 --web # View agent as JSON $ bk agent view 0198d108-a532-4a62-9bd7-b2e744bf5c45 --output json` } func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) org, id := parseAgentArg(c.Agent, f.Config) if c.Web { url := fmt.Sprintf("https://buildkite.com/organizations/%s/agents/%s", org, id) fmt.Printf("Opening %s in your browser\n", url) return browser.OpenURL(url) } var agentData buildkite.Agent if err = bkIO.SpinWhile(f, "Loading agent", func() error { var apiErr error agentData, _, apiErr = f.RestAPIClient.Agents.Get(ctx, org, id) return apiErr }); err != nil { return err } if format != output.FormatText { return output.Write(os.Stdout, agentData, format) } metadata, queue := parseMetadata(agentData.Metadata) if metadata == "" { metadata = "~" } connected := "-" if agentData.CreatedAt != nil { connected = agentData.CreatedAt.Format(time.RFC3339) } headers := []string{"Property", "Value"} rows := [][]string{ {"ID", agentData.ID}, {"Name", agentData.Name}, {"State", agentData.ConnectedState}, {"Queue", queue}, {"Version", agentData.Version}, {"Hostname", agentData.Hostname}, {"User Agent", agentData.UserAgent}, {"IP Address", agentData.IPAddress}, {"Connected", connected}, {"Metadata", metadata}, } table := output.Table(headers, rows, map[string]string{ "property": "bold", "value": "dim", }) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "Agent %s (%s)\n\n", agentData.Name, agentData.ID) fmt.Fprint(writer, table) return nil } func parseMetadata(metadataList []string) (string, string) { var metadataTags []string var queue string if len(metadataList) == 1 { if queueValue := parseQueue(metadataList[0]); queueValue != "" { return "~", queueValue } return metadataList[0], "default" } for _, v := range metadataList { if queueValue := parseQueue(v); queueValue != "" { queue = queueValue } else { metadataTags = append(metadataTags, v) } } if queue == "" { queue = "default" } metadata := strings.Join(metadataTags, ", ") return metadata, queue } func parseQueue(metadata string) string { parts := strings.Split(metadata, "=") if len(parts) > 1 && parts[0] == "queue" { return parts[1] } return "" } ================================================ FILE: cmd/agent/view_test.go ================================================ package agent import "testing" func TestParseMetadata(t *testing.T) { cases := []struct { name string input []string metadata string queue string }{ { name: "single queue entry", input: []string{"queue=production"}, metadata: "~", queue: "production", }, { name: "single non-queue entry", input: []string{"os=linux"}, metadata: "os=linux", queue: "default", }, { name: "multiple entries with queue", input: []string{"queue=deploy", "os=linux", "region=us"}, metadata: "os=linux, region=us", queue: "deploy", }, { name: "no entries", input: nil, metadata: "", queue: "default", }, { name: "multiple entries without queue", input: []string{"os=linux", "region=us"}, metadata: "os=linux, region=us", queue: "default", }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { metadata, queue := parseMetadata(tc.input) if metadata != tc.metadata { t.Fatalf("metadata mismatch: got %q want %q", metadata, tc.metadata) } if queue != tc.queue { t.Fatalf("queue mismatch: got %q want %q", queue, tc.queue) } }) } } ================================================ FILE: cmd/api/api.go ================================================ package api import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "strings" "time" "github.com/Khan/genqlient/graphql" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" httpClient "github.com/buildkite/cli/v3/internal/http" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/vektah/gqlparser/v2/ast" "github.com/vektah/gqlparser/v2/parser" ) type ApiCmd struct { Endpoint string `arg:"" optional:"" help:"API endpoint to call"` Method string `help:"HTTP method to use" short:"X"` Headers []string `help:"Headers to include in the request" short:"H"` Data string `help:"Data to send in the request body" short:"d"` Analytics bool `help:"Use the Test Analytics endpoint"` File string `help:"File containing GraphQL query" short:"f"` Verbose bool `help:"Enable verbose output (currently only provides information about rate limit exceeded retries)"` } func (c *ApiCmd) Help() string { return ` Interact with either the REST or GraphQL Buildkite APIs. Examples: # To get a build $ bk api /pipelines/example-pipeline/builds/420 # To create a pipeline $ bk api --method POST /pipelines --data ' { "name": "My Cool Pipeline", "repository": "git@github.com:acme-inc/my-pipeline.git", "configuration": "steps:\n - command: env" } ' # To update a cluster $ bk api --method PUT /clusters/CLUSTER_UUID --data ' { "name": "My Updated Cluster", } ' # To get all test suites $ bk api --analytics /suites # Run GraphQL query from file $ bk api --file get_build.graphql ` } // buildFullEndpoint constructs the full API endpoint path with organization prefix func buildFullEndpoint(endpoint, orgSlug string, isAnalytics bool) string { // Default to root if empty if endpoint == "" { endpoint = "/" } // Ensure endpoint starts with a leading slash if !strings.HasPrefix(endpoint, "/") { endpoint = "/" + endpoint } var endpointPrefix string if isAnalytics { endpointPrefix = fmt.Sprintf("v2/analytics/organizations/%s", orgSlug) } else { endpointPrefix = fmt.Sprintf("v2/organizations/%s", orgSlug) } return endpointPrefix + endpoint } func (c *ApiCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } // Determine HTTP method: default to GET, but use POST if data is provided and method not explicitly set method := c.Method if method == "" { if c.Data != "" { method = "POST" } else { method = "GET" } } // Handle GraphQL file queries if c.File != "" { return c.handleGraphQLQuery(context.Background(), f) } fullEndpoint := buildFullEndpoint(c.Endpoint, f.Config.OrganizationSlug(), c.Analytics) // Create an HTTP client with rate-limit retry via the shared transport. rl := httpClient.NewRateLimitTransport(nil) rl.MaxRetryDelay = 60 * time.Second rl.OnRateLimit = func(attempt int, delay time.Duration) { if c.Verbose { fmt.Fprintf(os.Stderr, "WARNING: Rate limit exceeded, retrying in %v @ %q (attempt %d)\n", delay, time.Now().Add(delay).Format(time.RFC3339), attempt) } } client := httpClient.NewClient( f.Config.APIToken(), httpClient.WithBaseURL(f.RestAPIClient.BaseURL.String()), httpClient.WithHTTPClient(&http.Client{Transport: rl}), ) // Process custom headers customHeaders := make(map[string]string) for _, header := range c.Headers { parts := strings.SplitN(header, ":", 2) if len(parts) == 2 { customHeaders[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } } var requestData any if c.Data != "" { // Try to parse as JSON first if err := json.Unmarshal([]byte(c.Data), &requestData); err != nil { // If not JSON, use raw string requestData = c.Data } } var response any switch method { case "GET": err = client.Get(context.Background(), fullEndpoint, &response) case "POST": err = client.Post(context.Background(), fullEndpoint, requestData, &response) case "PUT": err = client.Put(context.Background(), fullEndpoint, requestData, &response) default: // For other methods, use the Do method directly err = client.Do(context.Background(), method, fullEndpoint, requestData, &response) } if err != nil { return fmt.Errorf("error making request: %w", err) } // Format and print the response var prettyJSON bytes.Buffer responseBytes, err := json.Marshal(response) if err != nil { return fmt.Errorf("error marshaling response: %w", err) } err = json.Indent(&prettyJSON, responseBytes, "", " ") if err != nil { return fmt.Errorf("error formatting JSON response: %w", err) } fmt.Println(prettyJSON.String()) return nil } func (c *ApiCmd) handleGraphQLQuery(ctx context.Context, f *factory.Factory) error { // Read the GraphQL query from file queryBytes, err := os.ReadFile(c.File) if err != nil { return fmt.Errorf("error reading GraphQL query file %s: %w", c.File, err) } // Validate and parse GraphQL query query := strings.TrimSpace(string(queryBytes)) if query == "" { return fmt.Errorf("GraphQL query file %s is empty", c.File) } doc, err := parser.ParseQuery(&ast.Source{Input: query}) if err != nil { return fmt.Errorf("invalid GraphQL query: %w", err) } // Validate that we have at least one operation if len(doc.Operations) == 0 { return fmt.Errorf("GraphQL query must contain at least one operation (query, mutation, or subscription)") } // Extract and validate operation name (Buildkite GraphQL API requires named operations) opName := doc.Operations[0].Name if opName == "" { return fmt.Errorf("GraphQL operation must have a name when using file input. Please add a name after the operation type, e.g., 'query MyQuery { ... }'") } // Create GraphQL request using the existing client infrastructure req := &graphql.Request{ OpName: opName, Query: query, } // Use a generic response type for raw queries resp := &graphql.Response{Data: new(interface{})} // Use the existing GraphQL client if err = f.GraphQLClient.MakeRequest(ctx, req, resp); err != nil { return fmt.Errorf("error making GraphQL request: %w", err) } // Format and print the response responseBytes, err := json.Marshal(resp) if err != nil { return fmt.Errorf("error marshaling response: %w", err) } var prettyJSON bytes.Buffer if err = json.Indent(&prettyJSON, responseBytes, "", " "); err != nil { return fmt.Errorf("error formatting JSON response: %w", err) } fmt.Println(prettyJSON.String()) return nil } ================================================ FILE: cmd/api/api_test.go ================================================ package api import ( "testing" ) func TestBuildFullEndpoint(t *testing.T) { t.Parallel() testcases := map[string]struct { endpoint string orgSlug string isAnalytics bool wantEndpoint string }{ "endpoint with leading slash": { endpoint: "/pipelines/dummy/builds/5085", orgSlug: "test-org", isAnalytics: false, wantEndpoint: "v2/organizations/test-org/pipelines/dummy/builds/5085", }, "endpoint without leading slash": { endpoint: "pipelines/dummy/builds/5085", orgSlug: "test-org", isAnalytics: false, wantEndpoint: "v2/organizations/test-org/pipelines/dummy/builds/5085", }, "empty endpoint": { endpoint: "", orgSlug: "test-org", isAnalytics: false, wantEndpoint: "v2/organizations/test-org/", }, "root endpoint": { endpoint: "/", orgSlug: "test-org", isAnalytics: false, wantEndpoint: "v2/organizations/test-org/", }, "analytics endpoint with leading slash": { endpoint: "/suites", orgSlug: "test-org", isAnalytics: true, wantEndpoint: "v2/analytics/organizations/test-org/suites", }, "analytics endpoint without leading slash": { endpoint: "suites", orgSlug: "test-org", isAnalytics: true, wantEndpoint: "v2/analytics/organizations/test-org/suites", }, "pipeline endpoint without leading slash": { endpoint: "pipelines", orgSlug: "acme-inc", isAnalytics: false, wantEndpoint: "v2/organizations/acme-inc/pipelines", }, } for name, tc := range testcases { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() got := buildFullEndpoint(tc.endpoint, tc.orgSlug, tc.isAnalytics) if got != tc.wantEndpoint { t.Errorf("buildFullEndpoint(%q, %q, %v) = %q, want %q", tc.endpoint, tc.orgSlug, tc.isAnalytics, got, tc.wantEndpoint) } }) } } ================================================ FILE: cmd/artifacts/download.go ================================================ package artifacts import ( "context" "fmt" "os" "path/filepath" "strconv" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" bkErrors "github.com/buildkite/cli/v3/internal/errors" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" ) type DownloadCmd struct { ArtifactID string `arg:"" optional:"" help:"Artifact ID to download. If omitted, all artifacts are downloaded. Use 'bk artifacts list' to find IDs."` BuildNumber string `help:"Build number containing the artifact. If omitted, the most recent build on the current branch will be used." short:"b" name:"build"` Pipeline string `help:"The pipeline containing the artifact. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"The job UUID containing the artifact." short:"j" name:"job-uuid"` } func (c *DownloadCmd) Help() string { return ` Use this command to download artifacts from a build. If no artifact ID is provided, all artifacts for the build (or job) will be downloaded. Artifact IDs can be found using "bk artifacts list". Examples: # Download all artifacts from the most recent build on the current branch $ bk artifacts download # Download all artifacts from a specific build $ bk artifacts download --build 429 # Download all artifacts from a specific job $ bk artifacts download --build 429 --job-uuid 0193903e-ecd9-4c51-9156-0738da987e87 # Download a specific artifact $ bk artifacts download 0191727d-b5ce-4576-b37d-477ae0ca830c --build 429 # Specify the pipeline explicitly $ bk artifacts download --build 429 -p monolith ` } func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) optionsResolver := options.AggregateResolver{ options.ResolveBranchFromFlag(""), options.ResolveBranchFromRepository(f.GitRepository), } var buildResolvers []buildResolver.BuildResolverFn if c.BuildNumber != "" { buildResolvers = append(buildResolvers, buildResolver.ResolveFromPositionalArgument([]string{c.BuildNumber}, 0, pipelineRes.Resolve, f.Config)) } buildResolvers = append(buildResolvers, buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...)) buildRes := buildResolver.NewAggregateResolver(buildResolvers...) ctx := context.Background() bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { return bkErrors.NewResourceNotFoundError(nil, "no build found") } build := strconv.Itoa(bld.BuildNumber) if c.ArtifactID != "" { return c.downloadOne(ctx, f, bld.Organization, bld.Pipeline, build) } return c.downloadAll(ctx, f, bld.Organization, bld.Pipeline, build) } func (c *DownloadCmd) downloadOne(ctx context.Context, f *factory.Factory, org, pipeline, build string) error { var filename string if err := bkIO.SpinWhile(f, "Downloading artifact", func() error { artifact, findErr := findArtifact(ctx, f, org, pipeline, build, c.ArtifactID, c.JobUUID) if findErr != nil { return findErr } var dlErr error filename, dlErr = downloadArtifact(ctx, f, artifact) return dlErr }); err != nil { return err } fmt.Printf("Downloaded: %s\n", filename) return nil } func (c *DownloadCmd) downloadAll(ctx context.Context, f *factory.Factory, org, pipeline, build string) error { var artifacts []buildkite.Artifact if err := bkIO.SpinWhile(f, "Loading artifacts", func() error { var err error artifacts, err = listArtifacts(ctx, f, org, pipeline, build, c.JobUUID) return err }); err != nil { return err } if len(artifacts) == 0 { fmt.Println("No artifacts found.") return nil } directory := fmt.Sprintf("artifacts-build-%s", build) if err := os.MkdirAll(directory, os.ModePerm); err != nil { return err } for i := range artifacts { a := &artifacts[i] destPath := filepath.Join(directory, filepath.FromSlash(a.Path)) if err := downloadToFile(ctx, f, a.DownloadURL, destPath); err != nil { return err } fmt.Printf("Downloaded: %s\n", a.Path) } fmt.Printf("Downloaded %d artifacts to: %s\n", len(artifacts), directory) return nil } func findArtifact(ctx context.Context, f *factory.Factory, org, pipeline, build, artifactID, jobUUID string) (*buildkite.Artifact, error) { if jobUUID != "" { artifact, _, err := f.RestAPIClient.Artifacts.Get(ctx, org, pipeline, build, jobUUID, artifactID) if err != nil { return nil, err } return &artifact, nil } artifacts, err := listArtifacts(ctx, f, org, pipeline, build, "") if err != nil { return nil, err } for i := range artifacts { if artifacts[i].ID == artifactID { return &artifacts[i], nil } } return nil, bkErrors.NewResourceNotFoundError(nil, fmt.Sprintf("no artifact found with ID %s in build #%s", artifactID, build)) } // listArtifacts fetches all artifacts for a build or job, paginating through all results. func listArtifacts(ctx context.Context, f *factory.Factory, org, pipeline, build, jobUUID string) ([]buildkite.Artifact, error) { var all []buildkite.Artifact opts := &buildkite.ArtifactListOptions{ ListOptions: buildkite.ListOptions{PerPage: 100}, } for { var artifacts []buildkite.Artifact var resp *buildkite.Response var err error if jobUUID != "" { artifacts, resp, err = f.RestAPIClient.Artifacts.ListByJob(ctx, org, pipeline, build, jobUUID, opts) } else { artifacts, resp, err = f.RestAPIClient.Artifacts.ListByBuild(ctx, org, pipeline, build, opts) } if err != nil { return nil, err } all = append(all, artifacts...) if resp.NextPage == 0 { break } opts.Page = resp.NextPage } return all, nil } func downloadArtifact(ctx context.Context, f *factory.Factory, artifact *buildkite.Artifact) (string, error) { destPath := filepath.FromSlash(artifact.Path) if err := downloadToFile(ctx, f, artifact.DownloadURL, destPath); err != nil { return "", err } return destPath, nil } func downloadToFile(ctx context.Context, f *factory.Factory, url, destPath string) error { if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { return err } out, err := os.Create(destPath) if err != nil { return err } defer out.Close() _, err = f.RestAPIClient.Artifacts.DownloadArtifactByURL(ctx, url, out) return err } ================================================ FILE: cmd/artifacts/list.go ================================================ package artifacts import ( "context" "fmt" "io" "os" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/artifact" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ListCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to list artifacts for"` Pipeline string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"List artifacts for a specific job on the given build." short:"j" name:"job-uuid"` output.OutputFlags } func (c *ListCmd) Help() string { return ` List artifacts for a build or a job in a build. You can pass an optional build number. If omitted, the most recent build on the current branch will be resolved. Examples: # By default, artifacts of the most recent build for the current branch is shown $ bk artifacts list # To list artifacts of a specific build $ bk artifacts list 429 # To list artifacts of a specific job in a build $ bk artifacts list 429 --job-uuid 0193903e-ecd9-4c51-9156-0738da987e87 # If not inside a repository or to use a specific pipeline, pass -p $ bk artifacts list 429 -p monolith ` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) var args []string if c.BuildNumber != "" { args = []string{c.BuildNumber} } // Resolve a pipeline based on how bk build resolves the pipeline pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) // We resolve a build an optional argument or positional argument optionsResolver := options.AggregateResolver{ options.ResolveBranchFromFlag(""), options.ResolveBranchFromRepository(f.GitRepository), } buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), ) ctx := context.Background() bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { return output.WriteTextOrStructured(os.Stdout, format, []buildkite.Artifact{}, "No build found.") } var buildArtifacts []buildkite.Artifact if err = bkIO.SpinWhile(f, "Loading artifacts information", func() error { buildArtifacts, err = listArtifacts(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobUUID) return err }); err != nil { return err } if format != output.FormatText { return output.Write(os.Stdout, buildArtifacts, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() if len(buildArtifacts) == 0 { fmt.Fprintln(writer, "No artifacts found.") return nil } buildURL := fmt.Sprintf("https://buildkite.com/organizations/%s/pipelines/%s/builds/%d", bld.Organization, bld.Pipeline, bld.BuildNumber) if c.JobUUID != "" { jobURL := fmt.Sprintf("%s/jobs/%s", buildURL, c.JobUUID) fmt.Fprintf(writer, "Showing %d artifacts for %s/%s build #%d (job %s): %s\n\n", len(buildArtifacts), bld.Organization, bld.Pipeline, bld.BuildNumber, c.JobUUID, jobURL) } else { fmt.Fprintf(writer, "Showing %d artifacts for %s/%s build #%d: %s\n\n", len(buildArtifacts), bld.Organization, bld.Pipeline, bld.BuildNumber, buildURL) } return displayArtifacts(buildArtifacts, writer, buildURL) } func displayArtifacts(artifacts []buildkite.Artifact, writer io.Writer, baseBuildURL string) error { headers := []string{"ID", "Path", "Size", "URL"} var rows [][]string for _, a := range artifacts { url := "-" if a.JobID != "" { url = fmt.Sprintf("%s/jobs/%s/artifacts/%s", baseBuildURL, a.JobID, a.ID) } else if a.URL != "" { url = a.URL } rows = append(rows, []string{ a.ID, a.Path, artifact.FormatBytes(a.FileSize), url, }) } table := output.Table(headers, rows, map[string]string{ "id": "dim", "path": "bold", "size": "dim", "url": "dim", }) fmt.Fprint(writer, table) return nil } ================================================ FILE: cmd/auth/login.go ================================================ package auth import ( "context" "errors" "fmt" "os" "strings" "time" "github.com/alecthomas/kong" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/keyring" "github.com/buildkite/cli/v3/pkg/oauth" "github.com/google/uuid" "github.com/pkg/browser" ) type LoginCmd struct { Scopes string `help:"OAuth scopes to request" default:""` Org string `help:"Organization slug or UUID to request access for" optional:""` Token string `help:"API token to store (non-OAuth login, requires --org)" optional:""` } func organizationIdentifier(org string) (orgSlug, orgUUID string) { parsedUUID, err := uuid.Parse(org) if err == nil && strings.EqualFold(parsedUUID.String(), org) { return "", org } return org, "" } func (c *LoginCmd) Help() string { return ` Authenticate with Buildkite using OAuth instead of manually creating an API token. By default, the CLI requests all available scopes and Buildkite grants only those your account has permission for. Use --scopes to request a specific subset instead. Scope groups can be used as shorthand for common permission sets: read_only All read_* scopes (read-only access) Groups can be mixed with individual scopes: --scopes "read_only write_builds" Examples: # Login with full permissions (inherits your account's scopes) $ bk auth login # Login to a specific organization $ bk auth login --org my-org # Login non-interactively with an API token $ bk auth login --org my-org --token my-token # Login with read-only access $ bk auth login --scopes read_only # Login with read-only plus write access to builds $ bk auth login --scopes "read_only write_builds" # Login with specific scopes $ bk auth login --scopes "read_user read_organizations read_clusters write_clusters" ` } // LoginWithToken stores a token for an organization in the system keychain. // When the keychain is unavailable (e.g. BUILDKITE_NO_KEYRING=1 is set), it // still registers the org and selects it in config so that commands resolve the // org correctly; the caller is expected to supply the token via BUILDKITE_API_TOKEN. func LoginWithToken(f *factory.Factory, org, token string) error { if org == "" { return errors.New("--org is required when --token is provided") } if token == "" { return errors.New("--token cannot be empty") } kr := keyring.New() if kr.IsAvailable() { if err := kr.Set(org, token); err != nil { return fmt.Errorf("failed to store token in keychain: %w", err) } fmt.Println("Token stored securely in system keychain.") } else { fmt.Println("Keychain unavailable; token not stored. Use BUILDKITE_API_TOKEN to supply your token at runtime.") } if err := f.Config.EnsureOrganization(org); err != nil { return fmt.Errorf("failed to register organization in config: %w", err) } if err := f.Config.SelectOrganization(org, f.GitRepository != nil); err != nil { return fmt.Errorf("failed to select organization: %w", err) } return nil } func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } if c.Token != "" { if err := LoginWithToken(f, c.Org, c.Token); err != nil { return err } fmt.Printf("\nSuccessfully authenticated with organization %q\n", c.Org) return nil } // Resolve scope groups (e.g., "read_only" → individual read_* scopes). // When --scopes is empty, no scope parameter is sent and the token // inherits the user's full Buildkite permissions. resolvedScopes := oauth.ResolveScopes(c.Scopes) orgSlug, orgUUID := organizationIdentifier(c.Org) // Create OAuth flow cfg := &oauth.Config{ // Host default handled via NewFlow, omitted to allow usage of BUILDKITE_HOST ClientID: oauth.DefaultClientID, OrgSlug: orgSlug, OrgUUID: orgUUID, Scopes: resolvedScopes, } flow, err := oauth.NewFlow(cfg) if err != nil { return fmt.Errorf("failed to initialize OAuth flow: %w", err) } defer flow.Close() // Get authorization URL authURL := flow.AuthorizationURL() fmt.Println("Opening browser for authentication...") fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", authURL) // Open browser if err := browser.OpenURL(authURL); err != nil { fmt.Printf("Could not open browser automatically: %v\n", err) } // Wait for callback with timeout ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() fmt.Println("Waiting for authentication...") result, err := flow.WaitForCallback(ctx) if err != nil { return fmt.Errorf("authentication failed: %w", err) } // Exchange code for token fmt.Println("Exchanging authorization code for token...") tokenResp, err := flow.ExchangeCode(ctx, result.Code) if err != nil { return fmt.Errorf("token exchange failed: %w", err) } // Resolve org from the API using the new token client, err := buildkite.NewOpts( buildkite.WithTokenAuth(tokenResp.AccessToken), buildkite.WithBaseURL(f.Config.RESTAPIEndpoint()), ) if err != nil { return fmt.Errorf("failed to create API client: %w", err) } orgs, _, err := client.Organizations.List(ctx, nil) if err != nil { return fmt.Errorf("failed to list organizations: %w", err) } if len(orgs) == 0 { return fmt.Errorf("no organizations found for this token") } org := orgs[0] if err := LoginWithToken(f, org.Slug, tokenResp.AccessToken); err != nil { return err } // Store refresh token if the server issued one if tokenResp.RefreshToken != "" { kr := keyring.New() if kr.IsAvailable() { if err := kr.SetRefreshToken(org.Slug, tokenResp.RefreshToken); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to store refresh token: %v\n", err) } } } fmt.Printf("\n✅ Successfully authenticated with organization %q\n", org.Slug) fmt.Printf(" Scopes: %s\n", tokenResp.Scope) if tokenResp.RefreshToken != "" { fmt.Printf(" Token expires in: %s (will refresh automatically)\n", formatDuration(tokenResp.ExpiresIn)) } return nil } func formatDuration(seconds int) string { if seconds <= 0 { return "unknown" } d := time.Duration(seconds) * time.Second if d >= time.Hour { hours := int(d.Hours()) if hours == 1 { return "1 hour" } return fmt.Sprintf("%d hours", hours) } return fmt.Sprintf("%d minutes", int(d.Minutes())) } ================================================ FILE: cmd/auth/login_test.go ================================================ package auth import "testing" func TestOrganizationIdentifier(t *testing.T) { t.Parallel() tests := []struct { name string org string wantSlug string wantUUID string }{ { name: "slug", org: "buildkite", wantSlug: "buildkite", wantUUID: "", }, { name: "uuid", org: "018f2f7e-7e99-7d77-b4d3-a95cb01805f4", wantSlug: "", wantUUID: "018f2f7e-7e99-7d77-b4d3-a95cb01805f4", }, { name: "uppercase uuid", org: "018F2F7E-7E99-7D77-B4D3-A95CB01805F4", wantSlug: "", wantUUID: "018F2F7E-7E99-7D77-B4D3-A95CB01805F4", }, { name: "uuid-like slug without hyphens", org: "018f2f7e7e997d77b4d3a95cb01805f4", wantSlug: "018f2f7e7e997d77b4d3a95cb01805f4", wantUUID: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() gotSlug, gotUUID := organizationIdentifier(tt.org) if gotSlug != tt.wantSlug || gotUUID != tt.wantUUID { t.Fatalf("organizationIdentifier(%q) = (%q, %q), want (%q, %q)", tt.org, gotSlug, gotUUID, tt.wantSlug, tt.wantUUID) } }) } } ================================================ FILE: cmd/auth/logout.go ================================================ package auth import ( "fmt" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/keyring" ) type LogoutCmd struct { All bool `help:"Log out of all organizations" xor:"target"` Org string `help:"Organization slug (defaults to currently selected organization)" optional:"" xor:"target"` } func (c *LogoutCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } if c.All { return c.logoutAll(f) } return c.logoutOrg(f) } func (c *LogoutCmd) logoutAll(f *factory.Factory) error { orgs := f.Config.ConfiguredOrganizations() kr := keyring.New() if kr.IsAvailable() { for _, org := range orgs { if err := kr.Delete(org); err != nil { fmt.Printf("Warning: could not remove token from keychain for %q: %v\n", org, err) } _ = kr.DeleteRefreshToken(org) } } if err := f.Config.ClearAllOrganizations(); err != nil { return fmt.Errorf("failed to clear organizations from config: %w", err) } fmt.Printf("Logged out of all %d organizations\n", len(orgs)) return nil } func (c *LogoutCmd) logoutOrg(f *factory.Factory) error { org := c.Org if org == "" { org = f.Config.OrganizationSlug() } if org == "" { return fmt.Errorf("no organization specified and none currently selected") } kr := keyring.New() if kr.IsAvailable() { if err := kr.Delete(org); err != nil { fmt.Printf("Warning: could not remove token from keychain: %v\n", err) } else { fmt.Println("Token removed from system keychain.") } _ = kr.DeleteRefreshToken(org) } fmt.Printf("Logged out of organization %q\n", org) return nil } ================================================ FILE: cmd/auth/status.go ================================================ // Package auth handles commands related to authentication via the CLI package auth import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" "github.com/buildkite/go-buildkite/v4" ) type StatusOutput struct { OrganizationSlug string `json:"organization_slug"` Token buildkite.AccessToken `json:"token"` } func (w StatusOutput) TextOutput() string { b := strings.Builder{} fmt.Fprintf(&b, "Current organization: %s\n", w.OrganizationSlug) b.WriteRune('\n') fmt.Fprintf(&b, "API Token UUID: %s\n", w.Token.UUID) fmt.Fprintf(&b, "API Token Description: %s\n", w.Token.Description) fmt.Fprintf(&b, "API Token Scopes: %v\n", w.Token.Scopes) b.WriteRune('\n') fmt.Fprintf(&b, "API Token user name: %s\n", w.Token.User.Name) fmt.Fprintf(&b, "API Token user email: %s\n", w.Token.User.Email) return b.String() } type StatusCmd struct { output.OutputFlags } func (c *StatusCmd) Help() string { return ` It returns information on the current session. Examples: # List the current token session $ bk auth status ` } func (c *StatusCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } if validationErr := validation.ValidateConfiguration(f.Config, kongCtx.Command()); validationErr != nil { return validationErr } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() orgSlug := f.Config.OrganizationSlug() if orgSlug == "" { orgSlug = "<None>" } token, _, err := f.RestAPIClient.AccessTokens.Get(ctx) if err != nil { return fmt.Errorf("failed to get access token: %w", err) } w := StatusOutput{ OrganizationSlug: orgSlug, Token: token, } err = output.Write(os.Stdout, w, format) if err != nil { return fmt.Errorf("failed to write output: %w", err) } return nil } ================================================ FILE: cmd/auth/switch.go ================================================ package auth import ( "fmt" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type SwitchCmd struct { OrganizationSlug string `arg:"" optional:"" help:"Organization slug to switch"` } func (c *SwitchCmd) Help() string { return `Select a configured organization. Examples: # Switch the 'my-cool-org' configuration $ bk auth switch my-cool-org # Interactively select an organization $ bk auth switch ` } func (c *SwitchCmd) Run(globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.NoInput = globals.DisableInput() var org *string if c.OrganizationSlug != "" { org = &c.OrganizationSlug } return switchRun(org, f.Config, f.GitRepository != nil, f.NoInput) } func switchRun(org *string, conf *config.Config, inGitRepo bool, noInput bool) error { var selected string // prompt to choose from configured orgs if one is not already selected if org == nil { var err error selected, err = io.PromptForOne("organization", conf.ConfiguredOrganizations(), noInput) if err != nil { return err } } else { selected = *org } // if already selected, do nothing if conf.OrganizationSlug() == selected { fmt.Printf("Using configuration for `%s`\n", selected) return nil } // if the selected org exists, switch it if conf.HasConfiguredOrganization(selected) { fmt.Printf("Using configuration for `%s`\n", selected) return conf.SelectOrganization(selected, inGitRepo) } // if the selected org doesnt exist, recommend configuring it and error out return fmt.Errorf("no configuration found for `%s`. run `bk auth login` to add it", selected) } ================================================ FILE: cmd/auth/switch_test.go ================================================ package auth import ( "os" "path/filepath" "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/spf13/afero" ) func setEnv(t *testing.T, key, value string) { original, had := os.LookupEnv(key) if err := os.Setenv(key, value); err != nil { t.Fatalf("failed to set env %s: %v", key, err) } t.Cleanup(func() { var restoreErr error if had { restoreErr = os.Setenv(key, original) } else { restoreErr = os.Unsetenv(key) } if restoreErr != nil { t.Fatalf("failed to restore env %s: %v", key, restoreErr) } }) } func TestCmdSwitch(t *testing.T) { t.Parallel() t.Run("switches already selected org", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) selected := "testing" err := switchRun(&selected, conf, true, false) if err != nil { t.Error("expected no error") } if conf.OrganizationSlug() != "testing" { t.Error("expected no change in organization") } }) t.Run("switches existing org", func(t *testing.T) { t.Parallel() // add some configurations fs := afero.NewMemMapFs() conf := config.New(fs, nil) conf.SelectOrganization("testing", true) conf.EnsureOrganization("testing") conf.EnsureOrganization("default") // now get a new empty config conf = config.New(fs, nil) selected := "testing" err := switchRun(&selected, conf, true, false) if err != nil { t.Errorf("expected no error: %s", err) } if conf.OrganizationSlug() != "testing" { t.Error("expected no change in organization") } }) t.Run("errors if missing org", func(t *testing.T) { t.Parallel() selected := "testing" conf := config.New(afero.NewMemMapFs(), nil) err := switchRun(&selected, conf, true, false) if err == nil { t.Error("expected an error") } }) t.Run("reads organization from user's config file", func(t *testing.T) { home := t.TempDir() setEnv(t, "HOME", home) xdgConfig := filepath.Join(home, ".config") setEnv(t, "XDG_CONFIG_HOME", xdgConfig) setEnv(t, "BUILDKITE_API_TOKEN", "") setEnv(t, "BUILDKITE_ORGANIZATION_SLUG", "") if err := os.MkdirAll(xdgConfig, 0o755); err != nil { t.Fatalf("failed to create config dir: %v", err) } switchrConfigPath := filepath.Join(xdgConfig, "bk.yaml") content := []byte("selected_org: testing\norganizations:\n testing:\n api_token: token-123\n") if err := os.WriteFile(switchrConfigPath, content, 0o644); err != nil { t.Fatalf("failed to write switchr config: %v", err) } conf := config.New(afero.NewOsFs(), nil) if got := conf.OrganizationSlug(); got != "testing" { t.Fatalf("expected organization from file, got %q", got) } if got := conf.APIToken(); got != "token-123" { t.Fatalf("expected token from file, got %q", got) } selected := "testing" if err := switchRun(&selected, conf, false, true); err != nil { t.Fatalf("expected switchRun to succeed: %v", err) } }) t.Run("preserves organization name case", func(t *testing.T) { t.Parallel() testCases := []struct { name string orgName string }{ {"mixed case", "gridX"}, {"uppercase", "ACME"}, {"lowercase", "buildkite"}, {"camelCase", "myOrg"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() conf := config.New(fs, nil) // Configure organization with specific case if err := conf.EnsureOrganization(tc.orgName); err != nil { t.Fatalf("EnsureOrganization failed: %v", err) } if err := conf.SelectOrganization(tc.orgName, false); err != nil { t.Fatalf("SelectOrganization failed: %v", err) } // Switch the organization if err := switchRun(&tc.orgName, conf, false, true); err != nil { t.Fatalf("switchRun failed: %v", err) } // Verify case is preserved gotOrg := conf.OrganizationSlug() if gotOrg != tc.orgName { t.Errorf("expected organization %q, got %q - case was not preserved", tc.orgName, gotOrg) } }) } }) } ================================================ FILE: cmd/auth/token.go ================================================ package auth import ( "fmt" "os" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type TokenCmd struct{} func (c *TokenCmd) Help() string { return ` Prints the stored API token for the currently selected organization to stdout. The token is retrieved from the system keychain (or the BUILDKITE_API_TOKEN environment variable if set). This is useful for passing the token to other tools, for example: Examples: # Print the current token $ bk auth token # Use the token in a curl request $ curl -H "Authorization: Bearer $(bk auth token)" https://api.buildkite.com/v2/user ` } func (c *TokenCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } token := f.Config.APIToken() if token == "" { return fmt.Errorf("no token found; run `bk auth login` to authenticate") } fmt.Fprintln(os.Stdout, token) return nil } ================================================ FILE: cmd/build/cancel.go ================================================ package build import ( "context" "fmt" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" ) type CancelCmd struct { BuildNumber string `arg:"" help:"Build number to cancel"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Web bool `help:"Open the build in a web browser after it has been cancelled." short:"w"` } func (c *CancelCmd) Help() string { return ` Examples: # Cancel a build by number $ bk build cancel 123 --pipeline my-pipeline # Cancel a build and open in browser $ bk build cancel 123 -pipeline my-pipeline --web` } func (c *CancelCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) args := []string{c.BuildNumber} buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), ) bld, err := buildRes.Resolve(ctx) if err != nil { return err } confirmed, err := bkIO.Confirm(f, fmt.Sprintf("Cancel build #%d on %s", bld.BuildNumber, bld.Pipeline)) if err != nil { return err } if !confirmed { return nil } return cancelBuild(ctx, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.Web, f) } func cancelBuild(ctx context.Context, org string, pipeline string, buildId string, web bool, f *factory.Factory) error { var build buildkite.Build if err := bkIO.SpinWhile(f, fmt.Sprintf("Cancelling build #%s from pipeline %s", buildId, pipeline), func() error { var apiErr error build, apiErr = f.RestAPIClient.Builds.Cancel(ctx, org, pipeline, buildId) return apiErr }); err != nil { return err } fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build canceled: %s", build.WebURL))) return util.OpenInWebBrowser(web, build.WebURL) } ================================================ FILE: cmd/build/create.go ================================================ package build import ( "bufio" "context" "fmt" "os" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkErrors "github.com/buildkite/cli/v3/internal/errors" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" ) type CreateCmd struct { Message string `help:"Description of the build. If left blank, the commit message will be used once the build starts." short:"m"` Commit string `help:"The commit to build." short:"c" default:"HEAD"` Branch string `help:"The branch to build. Defaults to the default branch of the pipeline." short:"b"` Author string `help:"Author of the build. Supports: \"Name <email>\", \"email@domain.com\", \"Full Name\", or \"username\"" short:"a"` Web bool `help:"Open the build in a web browser after it has been created." short:"w"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Env []string `help:"Set environment variables for the build (KEY=VALUE)" short:"e" sep:"none"` Metadata []string `help:"Set metadata for the build (KEY=VALUE)" short:"M" sep:"none"` IgnoreBranchFilters bool `help:"Ignore branch filters for the pipeline" short:"i"` EnvFile string `help:"Set the environment variables for the build via an environment file" short:"f"` } func (c *CreateCmd) Help() string { return `The web URL to the build will be printed to stdout. Examples: # Create a new build $ bk build create # Create a new build with environment variables set $ bk build create -e "FOO=BAR" -e "BAR=BAZ" # Create a new build with metadata $ bk build create -M "key=value" -M "foo=bar"` } func (c *CreateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { // Initialize factory f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return bkErrors.NewInternalError(err, "failed to initialize CLI", "This is likely a bug", "Report to Buildkite") } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() resolvers := resolver.NewAggregateResolver( resolver.ResolveFromFlag(c.Pipeline, f.Config), resolver.ResolveFromConfig(f.Config, resolver.PickOneWithFactory(f)), resolver.ResolveFromRepository(f, resolver.CachedPicker(f.Config, resolver.PickOneWithFactory(f))), ) resolvedPipeline, err := resolvers.Resolve(ctx) if err != nil { return err // Already wrapped by resolver } if resolvedPipeline == nil { return bkErrors.NewResourceNotFoundError( nil, "could not resolve a pipeline", "Specify a pipeline with --pipeline (-p)", "Run 'bk pipeline list' to see available pipelines", ) } confirmed, err := bkIO.Confirm(f, fmt.Sprintf("Create new build on %s?", resolvedPipeline.Name)) if err != nil { return bkErrors.NewUserAbortedError(err, "confirmation canceled") } if !confirmed { fmt.Println("Build creation canceled") return nil } // Process environment variables envMap := make(map[string]string) for _, e := range c.Env { key, value, _ := strings.Cut(e, "=") envMap[key] = value } // Process metadata variables metaDataMap := make(map[string]string) for _, m := range c.Metadata { key, value, _ := strings.Cut(m, "=") metaDataMap[key] = value } // Process environment file if specified if c.EnvFile != "" { file, err := os.Open(c.EnvFile) if err != nil { return bkErrors.NewValidationError( err, fmt.Sprintf("could not open environment file: %s", c.EnvFile), "Check that the file exists and is readable", ) } defer file.Close() content := bufio.NewScanner(file) for content.Scan() { key, value, _ := strings.Cut(content.Text(), "=") envMap[key] = value } if err := content.Err(); err != nil { return bkErrors.NewValidationError( err, "error reading environment file", "Ensure the file contains valid environment variables in KEY=VALUE format", ) } } return createBuild(ctx, resolvedPipeline.Org, resolvedPipeline.Name, f, c.Message, c.Commit, c.Branch, c.Web, envMap, metaDataMap, c.IgnoreBranchFilters, c.Author) } func parseAuthor(author string) buildkite.Author { if author == "" { return buildkite.Author{} } // Check for Git-style format: "Name <email@domain.com>" if strings.Contains(author, "<") && strings.Contains(author, ">") { parts := strings.Split(author, "<") if len(parts) == 2 { name := strings.TrimSpace(parts[0]) email := strings.TrimSpace(strings.Trim(parts[1], ">")) if name != "" && email != "" { return buildkite.Author{Name: name, Email: email} } } } // Check for email-only format if strings.Contains(author, "@") && strings.Contains(author, ".") && !strings.Contains(author, " ") { return buildkite.Author{Email: author} } // Check for name format (contains spaces but no email) if strings.Contains(author, " ") { return buildkite.Author{Name: author} } // Default to username return buildkite.Author{Username: author} } func createBuild(ctx context.Context, org string, pipeline string, f *factory.Factory, message string, commit string, branch string, web bool, env map[string]string, metaData map[string]string, ignoreBranchFilters bool, author string) error { var build buildkite.Build if err := bkIO.SpinWhile(f, fmt.Sprintf("Starting new build for %s", pipeline), func() error { branch = strings.TrimSpace(branch) if len(branch) == 0 { p, _, err := f.RestAPIClient.Pipelines.Get(ctx, org, pipeline) if err != nil { return bkErrors.WrapAPIError(err, "fetching pipeline information") } // Check if the pipeline has a default branch set if p.DefaultBranch == "" { return bkErrors.NewValidationError( nil, fmt.Sprintf("No default branch set for pipeline %s", pipeline), "Please specify a branch using --branch (-b)", "Set a default branch in your pipeline settings on Buildkite", ) } branch = p.DefaultBranch } newBuild := buildkite.CreateBuild{ Message: message, Commit: commit, Branch: branch, Author: parseAuthor(author), Env: env, MetaData: metaData, IgnorePipelineBranchFilters: ignoreBranchFilters, } var err error build, _, err = f.RestAPIClient.Builds.Create(ctx, org, pipeline, newBuild) if err != nil { return bkErrors.WrapAPIError(err, "creating build") } return nil }); err != nil { return err } if build.WebURL == "" { return bkErrors.NewAPIError( nil, "build was created but no URL was returned", "This may be due to an API version mismatch", ) } fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build created: %s", build.WebURL))) if err := util.OpenInWebBrowser(web, build.WebURL); err != nil { return bkErrors.NewInternalError(err, "failed to open web browser") } return nil } func renderResult(result string) string { return result } ================================================ FILE: cmd/build/download.go ================================================ package build import ( "context" "fmt" "os" "path/filepath" "sync" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/build" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type DownloadCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to download (omit for most recent build)"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Branch string `help:"Filter builds to this branch." short:"b"` User string `help:"Filter builds to this user. You can use name or email." short:"u" xor:"userfilter"` Mine bool `help:"Filter builds to only my user." short:"m" xor:"userfilter"` } func (c *DownloadCmd) Help() string { return ` Examples: # Download build 123 $ bk build download 123 --pipeline my-pipeline # Download most recent build $ bk build download --pipeline my-pipeline # Download most recent build on a branch $ bk build download -b main --pipeline my-pipeline # Download most recent build by a user $ bk build download --pipeline my-pipeline -u alice@hello.com # Download most recent build by yourself $ bk build download --pipeline my-pipeline --mine` } func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() // we find the pipeline based on the following rules: // 1. an explicit flag is passed // 2. a configured pipeline for this directory // 3. find pipelines matching the current repository from the API pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) // we resolve a build based on the following rules: // 1. an optional argument // 2. resolve from API using some context // a. filter by branch if --branch or use current repo // b. filter by user if --user or --mine given optionsResolver := options.AggregateResolver{ options.ResolveBranchFromFlag(c.Branch), options.ResolveBranchFromRepository(f.GitRepository), }.WithResolverWhen( c.User != "", options.ResolveUserFromFlag(c.User), ).WithResolverWhen( c.Mine || c.User == "", options.ResolveCurrentUser(ctx, f), ) args := []string{} if c.BuildNumber != "" { args = []string{c.BuildNumber} } buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), ) bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { fmt.Println("No build found.") return nil } var dir string if err = bkIO.SpinWhile(f, "Downloading build resources", func() error { dir, err = download(ctx, bld, f) return err }); err != nil { return err } fmt.Printf("Downloaded build to: %s\n", dir) return nil } func download(ctx context.Context, build *build.Build, f *factory.Factory) (string, error) { var wg sync.WaitGroup var mu sync.Mutex b, _, err := f.RestAPIClient.Builds.Get(ctx, build.Organization, build.Pipeline, fmt.Sprint(build.BuildNumber), nil) if err != nil { return "", err } directory := fmt.Sprintf("build-%s", b.ID) err = os.MkdirAll(directory, os.ModePerm) if err != nil { return "", err } for _, job := range b.Jobs { // only script (command) jobs will have logs if job.Type != "script" { continue } go func() { defer wg.Done() wg.Add(1) log, _, apiErr := f.RestAPIClient.Jobs.GetJobLog(ctx, build.Organization, build.Pipeline, b.ID, job.ID) if err != nil { mu.Lock() err = apiErr mu.Unlock() return } fileErr := os.WriteFile(filepath.Join(directory, job.ID), []byte(log.Content), 0o644) if fileErr != nil { mu.Lock() err = fileErr mu.Unlock() } }() } artifacts, _, err := f.RestAPIClient.Artifacts.ListByBuild(ctx, build.Organization, build.Pipeline, fmt.Sprint(build.BuildNumber), nil) if err != nil { return "", err } for _, artifact := range artifacts { go func() { defer wg.Done() wg.Add(1) out, fileErr := os.Create(filepath.Join(directory, fmt.Sprintf("artifact-%s-%s", artifact.ID, artifact.Filename))) if err != nil { err = fileErr } _, apiErr := f.RestAPIClient.Artifacts.DownloadArtifactByURL(ctx, artifact.DownloadURL, out) if err != nil { err = apiErr } }() } wg.Wait() if err != nil { return "", err } return directory, nil } ================================================ FILE: cmd/build/list.go ================================================ package build import ( "context" "encoding/base64" "fmt" "io" "net/mail" "os" "strings" "time" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/graphql" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) const ( maxBuildLimit = 5000 pageSize = 100 ) type ListCmd struct { Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Since string `help:"Filter builds created since this time (e.g. 1h, 30m)"` Until string `help:"Filter builds created before this time (e.g. 1h, 30m)"` Duration string `help:"Filter by duration (e.g. >5m, <10m, 20m) - supports >, <, >=, <= operators"` State []string `help:"Filter by build state"` Branch []string `help:"Filter by branch name"` Creator string `help:"Filter by creator (email address or user ID)"` Commit string `help:"Filter by commit SHA"` Message string `help:"Filter by message content"` MetaData map[string]string `help:"Filter by build meta-data (key=value format, can be specified multiple times)"` Limit int `help:"Maximum number of builds to return" default:"50"` NoLimit bool `help:"Fetch all builds (overrides --limit)"` output.OutputFlags } func (c *ListCmd) Help() string { return `List builds with optional filtering. This command supports both server-side filtering (fast) and client-side filtering. Server-side filters are applied by the Buildkite API, while client-side filters are applied after fetching results and may require loading more builds. Client-side filters: --duration, --message Server-side filters: --pipeline, --since, --until, --state, --branch, --creator, --commit, --meta-data Builds can be filtered by their duration, message content, and other attributes. When filtering by duration, you can use operators like >, <, >=, and <= to specify your criteria. Supported duration units are seconds (s), minutes (m), and hours (h). Examples: # List recent builds (50 by default) $ bk build list # Get more builds (automatically paginates) $ bk build list --limit 500 # List builds from the last hour $ bk build list --since 1h # List failed builds $ bk build list --state failed # List builds on main branch $ bk build list --branch main # List builds by alice $ bk build list --creator alice@company.com # List builds that took longer than 20 minutes $ bk build list --duration ">20m" # List builds that finished in under 5 minutes $ bk build list --duration "<5m" # Combine filters: failed builds on main branch in the last 24 hours $ bk build list --state failed --branch main --since 24h # Find builds containing "deploy" in the message $ bk build list --message deploy # Filter builds by meta-data $ bk build list --meta-data env=production # Filter by multiple meta-data keys $ bk build list --meta-data env=production --meta-data deploy=true # Complex filtering: slow builds (>30m) that failed on feature branches $ bk build list --duration ">30m" --state failed --branch feature/` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() if !c.NoLimit { if c.Limit > maxBuildLimit { return fmt.Errorf("limit cannot exceed %d builds (requested: %d); if you need more, use --no-limit", maxBuildLimit, c.Limit) } } if c.Creator != "" && isValidEmail(c.Creator) { originalEmail := c.Creator if err = bkIO.SpinWhile(f, "Looking up user", func() error { c.Creator, err = resolveCreatorEmailToUserID(ctx, f, originalEmail) return err }); err != nil { return fmt.Errorf("failed to resolve creator email: %w", err) } if c.Creator == "" { return fmt.Errorf("failed to resolve creator email: no user found") } } listOpts, err := c.buildListOptions() if err != nil { return err } org := f.Config.OrganizationSlug() format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) if format == output.FormatText { writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() target := org if c.Pipeline != "" { target = fmt.Sprintf("%s/%s", org, c.Pipeline) } fmt.Fprintf(writer, "Showing builds for %s\n\n", target) builds, err := c.fetchBuilds(ctx, f, org, listOpts, format, writer) if err != nil { return fmt.Errorf("failed to list builds: %w", err) } if len(builds) == 0 { fmt.Fprintln(writer, "No builds found matching the specified criteria.") } return nil } builds, err := c.fetchBuilds(ctx, f, org, listOpts, format, nil) if err != nil { return fmt.Errorf("failed to list builds: %w", err) } if len(builds) == 0 { return output.Write(os.Stdout, []buildkite.Build{}, format) } return displayBuilds(builds, format, os.Stdout) } func (c *ListCmd) buildListOptions() (*buildkite.BuildsListOptions, error) { listOpts := &buildkite.BuildsListOptions{ ListOptions: buildkite.ListOptions{ PerPage: pageSize, }, } now := time.Now() if c.Since != "" { d, err := time.ParseDuration(c.Since) if err != nil { return nil, fmt.Errorf("invalid since duration '%s': %w", c.Since, err) } listOpts.CreatedFrom = now.Add(-d) } if c.Until != "" { d, err := time.ParseDuration(c.Until) if err != nil { return nil, fmt.Errorf("invalid until duration '%s': %w", c.Until, err) } listOpts.CreatedTo = now.Add(-d) } if len(c.State) > 0 { listOpts.State = make([]string, len(c.State)) for i, state := range c.State { listOpts.State[i] = strings.ToLower(state) } } listOpts.Branch = c.Branch listOpts.Creator = c.Creator listOpts.Commit = c.Commit if len(c.MetaData) > 0 { listOpts.MetaData = buildkite.MetaDataFilters{ MetaData: c.MetaData, } } return listOpts, nil } func (c *ListCmd) fetchBuilds(ctx context.Context, f *factory.Factory, org string, listOpts *buildkite.BuildsListOptions, format output.Format, writer io.Writer) ([]buildkite.Build, error) { var allBuilds []buildkite.Build // filtered builds added since last confirm (used when --no-limit) filteredSinceConfirm := 0 // raw (unfiltered) build counters so progress messaging makes sense when client-side filters are active rawTotalFetched := 0 rawSinceConfirm := 0 previousPageFirstBuildNumber := 0 printedAny := false for page := 1; ; page++ { if !c.NoLimit && len(allBuilds) >= c.Limit { break } listOpts.Page = page var builds []buildkite.Build var err error spinnerMsg := "Loading builds (" if c.Pipeline != "" { spinnerMsg += fmt.Sprintf("pipeline %s, ", c.Pipeline) } filtersActive := c.Duration != "" || c.Message != "" // Show matching (filtered) counts and raw counts independently if !c.NoLimit && c.Limit > 0 { spinnerMsg += fmt.Sprintf("%d/%d matching, %d raw fetched", len(allBuilds), c.Limit, rawTotalFetched) } else { spinnerMsg += fmt.Sprintf("%d matching, %d raw fetched", len(allBuilds), rawTotalFetched) } spinnerMsg += ")" if format == output.FormatText && rawSinceConfirm >= maxBuildLimit { prompt := fmt.Sprintf("Fetched %d more builds (%d total). Continue?", rawSinceConfirm, rawTotalFetched) if filtersActive { prompt = fmt.Sprintf( "Fetched %d raw builds (%d matching, %d matching total). Continue?", rawSinceConfirm, filteredSinceConfirm, len(allBuilds), ) } confirmed, err := bkIO.Confirm(f, prompt) if err != nil { return nil, err } if !confirmed { return allBuilds, nil } filteredSinceConfirm = 0 rawSinceConfirm = 0 } if err = bkIO.SpinWhile(f, spinnerMsg, func() error { if c.Pipeline != "" { builds, err = c.getBuildsByPipeline(ctx, f, org, listOpts) } else { builds, _, err = f.RestAPIClient.Builds.ListByOrg(ctx, org, listOpts) } return err }); err != nil { return nil, err } if len(builds) == 0 { break } // Track raw builds fetched before applying client-side filters rawCountThisPage := len(builds) rawTotalFetched += rawCountThisPage rawSinceConfirm += rawCountThisPage // Detect duplicate first build number between pages to prevent infinite loop if page > 1 && len(builds) > 0 { currentPageFirstBuildNumber := builds[0].Number if currentPageFirstBuildNumber == previousPageFirstBuildNumber { return nil, fmt.Errorf("API returned duplicate results, stopping to prevent infinite loop") } } if len(builds) > 0 { previousPageFirstBuildNumber = builds[0].Number } builds, err = c.applyClientSideFilters(builds) if err != nil { return nil, fmt.Errorf("failed to apply filters: %w", err) } // Decide which builds will actually be added (respect limit) var buildsToAdd []buildkite.Build addedThisPage := 0 if !c.NoLimit { remaining := c.Limit - len(allBuilds) if remaining <= 0 { break } if len(builds) > remaining { buildsToAdd = builds[:remaining] addedThisPage = remaining } else { buildsToAdd = builds addedThisPage = len(builds) } } else { buildsToAdd = builds addedThisPage = len(builds) } // Stream only the builds we are about to add; header only once we actually print something if format == output.FormatText && len(buildsToAdd) > 0 && writer != nil { _ = displayBuilds(buildsToAdd, format, writer) if !printedAny { fmt.Fprintln(writer) } printedAny = true } allBuilds = append(allBuilds, buildsToAdd...) filteredSinceConfirm += addedThisPage if rawCountThisPage < listOpts.PerPage { break } } return allBuilds, nil } func (c *ListCmd) getBuildsByPipeline(ctx context.Context, f *factory.Factory, org string, listOpts *buildkite.BuildsListOptions) ([]buildkite.Build, error) { pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), ) pipeline, err := pipelineRes.Resolve(ctx) if err != nil { return nil, err } builds, _, err := f.RestAPIClient.Builds.ListByPipeline(ctx, org, pipeline.Name, listOpts) return builds, err } func (c *ListCmd) applyClientSideFilters(builds []buildkite.Build) ([]buildkite.Build, error) { if c.Duration == "" && c.Message == "" { return builds, nil } var durationOp string var durationThreshold time.Duration if c.Duration != "" { durationOp = ">=" durationStr := c.Duration switch { case strings.HasPrefix(c.Duration, "<"): durationOp = "<" durationStr = c.Duration[1:] case strings.HasPrefix(c.Duration, ">"): durationOp = ">" durationStr = c.Duration[1:] } d, err := time.ParseDuration(durationStr) if err != nil { return nil, fmt.Errorf("invalid duration format: %w", err) } durationThreshold = d } var messageFilter string if c.Message != "" { messageFilter = strings.ToLower(c.Message) } var result []buildkite.Build for _, build := range builds { if c.Duration != "" { if build.StartedAt == nil { continue } var elapsed time.Duration if build.FinishedAt != nil { elapsed = build.FinishedAt.Sub(build.StartedAt.Time) } else { elapsed = time.Since(build.StartedAt.Time) } switch durationOp { case "<": if elapsed >= durationThreshold { continue } case ">": if elapsed <= durationThreshold { continue } default: if elapsed < durationThreshold { continue } } } if messageFilter != "" { if !strings.Contains(strings.ToLower(build.Message), messageFilter) { continue } } result = append(result, build) } return result, nil } func isValidEmail(s string) bool { _, err := mail.ParseAddress(s) return err == nil } func resolveCreatorEmailToUserID(ctx context.Context, f *factory.Factory, email string) (string, error) { org := f.Config.OrganizationSlug() resp, err := graphql.FindUserByEmail(ctx, f.GraphQLClient, org, email) if err != nil { return "", fmt.Errorf("failed to query user by email: %w", err) } if resp.Organization == nil || resp.Organization.Members == nil || len(resp.Organization.Members.Edges) == 0 { return "", fmt.Errorf("no user found with email: %s", email) } member := resp.Organization.Members.Edges[0].Node if member == nil { return "", fmt.Errorf("invalid user data for email: %s", email) } // Decode GraphQL ID and extract UUID decoded, err := base64.StdEncoding.DecodeString(member.User.Id) if err != nil { return "", fmt.Errorf("failed to decode user ID: %w", err) } if userUUID, found := strings.CutPrefix(string(decoded), "User---"); found { return userUUID, nil } return "", fmt.Errorf("unexpected user ID format") } func displayBuilds(builds []buildkite.Build, format output.Format, writer io.Writer) error { if format != output.FormatText { return output.Write(writer, builds, format) } const ( maxMessageLength = 22 truncatedLength = 19 timeFormat = "2006-01-02T15:04:05Z" ) var rows [][]string for _, build := range builds { message := build.Message if len(message) > maxMessageLength { message = message[:truncatedLength] + "..." } startedAt := "-" if build.StartedAt != nil { startedAt = build.StartedAt.Format(timeFormat) } finishedAt := "-" duration := "-" if build.FinishedAt != nil { finishedAt = build.FinishedAt.Format(timeFormat) if build.StartedAt != nil { dur := build.FinishedAt.Sub(build.StartedAt.Time) duration = formatDuration(dur) } } else if build.StartedAt != nil { dur := time.Since(build.StartedAt.Time) duration = formatDuration(dur) + " (running)" } rows = append(rows, []string{ fmt.Sprintf("%d", build.Number), build.State, message, startedAt, finishedAt, duration, build.WebURL, }) } headers := []string{"Number", "State", "Message", "Started (UTC)", "Finished (UTC)", "Duration", "URL"} table := output.Table(headers, rows, map[string]string{ "number": "bold", "state": "bold", "message": "italic", "started (utc)": "dim", "finished (utc)": "dim", "duration": "bold", "url": "dim", }) fmt.Fprint(writer, table) return nil } func formatDuration(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%.0fs", d.Seconds()) } if d < time.Hour { minutes := d / time.Minute seconds := (d % time.Minute) / time.Second return fmt.Sprintf("%dm%ds", minutes, seconds) } hours := d / time.Hour minutes := (d % time.Hour) / time.Minute return fmt.Sprintf("%dh%dm", hours, minutes) } ================================================ FILE: cmd/build/list_test.go ================================================ package build import ( "bytes" "strings" "testing" "time" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type buildListOptions struct { duration string message string } func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([]buildkite.Build, error) { cmd := &ListCmd{ Duration: opts.duration, Message: opts.message, } return cmd.applyClientSideFilters(builds) } func TestBuildListOptions_MetaData(t *testing.T) { cmd := &ListCmd{ MetaData: map[string]string{ "env": "production", "deploy": "true", }, } opts, err := cmd.buildListOptions() if err != nil { t.Fatalf("buildListOptions failed: %v", err) } if len(opts.MetaData.MetaData) != 2 { t.Errorf("Expected 2 meta-data filters, got %d", len(opts.MetaData.MetaData)) } if opts.MetaData.MetaData["env"] != "production" { t.Errorf("Expected env=production, got env=%s", opts.MetaData.MetaData["env"]) } if opts.MetaData.MetaData["deploy"] != "true" { t.Errorf("Expected deploy=true, got deploy=%s", opts.MetaData.MetaData["deploy"]) } } func TestBuildListOptions_EmptyMetaData(t *testing.T) { cmd := &ListCmd{} opts, err := cmd.buildListOptions() if err != nil { t.Fatalf("buildListOptions failed: %v", err) } if len(opts.MetaData.MetaData) != 0 { t.Errorf("Expected empty meta-data, got %d entries", len(opts.MetaData.MetaData)) } } func TestDisplayBuilds_EmptyJSON(t *testing.T) { var buf bytes.Buffer err := displayBuilds([]buildkite.Build{}, output.FormatJSON, &buf) if err != nil { t.Fatalf("displayBuilds failed: %v", err) } got := strings.TrimSpace(buf.String()) if got != "[]" { t.Errorf("Expected empty JSON array '[]', got %q", got) } } func TestDisplayBuilds_EmptyYAML(t *testing.T) { var buf bytes.Buffer err := displayBuilds([]buildkite.Build{}, output.FormatYAML, &buf) if err != nil { t.Fatalf("displayBuilds failed: %v", err) } got := strings.TrimSpace(buf.String()) if got != "[]" { t.Errorf("Expected empty YAML array '[]', got %q", got) } } func TestFilterBuilds(t *testing.T) { now := time.Now() builds := []buildkite.Build{ { Number: 1, Message: "Fast build", StartedAt: &buildkite.Timestamp{Time: now.Add(-5 * time.Minute)}, FinishedAt: &buildkite.Timestamp{Time: now.Add(-4 * time.Minute)}, // 1 minute }, { Number: 2, Message: "Long build", StartedAt: &buildkite.Timestamp{Time: now.Add(-30 * time.Minute)}, FinishedAt: &buildkite.Timestamp{Time: now.Add(-10 * time.Minute)}, // 20 minutes }, } opts := buildListOptions{duration: "10m"} filtered, err := applyClientSideFilters(builds, opts) if err != nil { t.Fatalf("applyClientSideFilters failed: %v", err) } if len(filtered) != 1 { t.Errorf("Expected 1 build >= 10m, got %d", len(filtered)) } opts = buildListOptions{message: "Fast"} filtered, err = applyClientSideFilters(builds, opts) if err != nil { t.Fatalf("applyClientSideFilters failed: %v", err) } if len(filtered) != 1 { t.Errorf("Expected 1 build with 'Fast', got %d", len(filtered)) } } ================================================ FILE: cmd/build/rebuild.go ================================================ package build import ( "context" "fmt" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" ) type RebuildCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to rebuild (omit for most recent build)"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Branch string `help:"Filter builds to this branch." short:"b"` User string `help:"Filter builds to this user. You can use name or email." short:"u" xor:"userfilter"` Mine bool `help:"Filter builds to only my user." short:"m" xor:"userfilter"` Web bool `help:"Open the build in a web browser after it has been created." short:"w"` } func (c *RebuildCmd) Help() string { return ` Examples: # Rebuild a specific build by number $ bk build rebuild 123 # Rebuild most recent build $ bk build rebuild # Rebuild and open in browser $ bk build rebuild 123 --web # Rebuild most recent build on a branch $ bk build rebuild -b main # Rebuild most recent build by a user $ bk build rebuild -u alice # Rebuild most recent build by yourself $ bk build rebuild --mine` } func (c *RebuildCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() // we find the pipeline based on the following rules: // 1. an explicit flag is passed // 2. a configured pipeline for this directory // 3. find pipelines matching the current repository from the API pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) // we resolve a build based on the following rules: // 1. an optional argument // 2. resolve from API using some context // a. filter by branch if --branch or use current repo // b. filter by user if --user or --mine given optionsResolver := options.AggregateResolver{ options.ResolveBranchFromFlag(c.Branch), options.ResolveBranchFromRepository(f.GitRepository), }.WithResolverWhen( c.User != "", options.ResolveUserFromFlag(c.User), ).WithResolverWhen( c.Mine || c.User == "", options.ResolveCurrentUser(ctx, f), ) args := []string{} if c.BuildNumber != "" { args = []string{c.BuildNumber} } buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), ) bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { fmt.Println("No build found.") return nil } return rebuild(ctx, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.Web, f) } func rebuild(ctx context.Context, org string, pipeline string, buildId string, web bool, f *factory.Factory) error { var build buildkite.Build if err := bkIO.SpinWhile(f, fmt.Sprintf("Rerunning build #%s for pipeline %s", buildId, pipeline), func() error { var apiErr error build, apiErr = f.RestAPIClient.Builds.Rebuild(ctx, org, pipeline, buildId) return apiErr }); err != nil { return err } fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build created: %s", build.WebURL))) return util.OpenInWebBrowser(web, build.WebURL) } ================================================ FILE: cmd/build/view.go ================================================ package build import ( "context" "fmt" "os" "sync" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/build/view" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/pkg/browser" ) type ViewCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to view (omit for most recent build)"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Branch string `help:"Filter builds to this branch." short:"b"` User string `help:"Filter builds to this user. You can use name or email." short:"u" xor:"userfilter"` Mine bool `help:"Filter builds to only my user." xor:"userfilter"` JobStates []string `help:"Filter jobs by state. Valid states: running, scheduled, passed, failed, canceled, skipped, not_run, broken." short:"s" sep:","` Web bool `help:"Open the build in a web browser." short:"w"` output.OutputFlags } func (c *ViewCmd) Help() string { return `You can pass an optional build number to view. If omitted, the most recent build on the current branch will be resolved. Examples: # By default, the most recent build for the current branch is shown $ bk build view # If not inside a repository or to use a specific pipeline, pass -p $ bk build view -p monolith # To view a specific build $ bk build view 429 # Add -w to any command to open the build in your web browser instead $ bk build view -w 429 # To view the most recent build on feature-x branch $ bk build view -b feature-y # You can filter by a user name or id $ bk build view -u "alice" # A shortcut to view your builds is --mine $ bk build view --mine # Filter to only show failed and broken jobs $ bk build view -s failed,broken # You can combine most of these flags # To view most recent build by greg on the deploy-pipeline $ bk build view -p deploy-pipeline -u "greg"` } func (c *ViewCmd) buildGetOptions() *buildkite.BuildGetOptions { if len(c.JobStates) > 0 { return &buildkite.BuildGetOptions{JobStates: c.JobStates} } return nil } func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) var opts view.ViewOptions opts.Pipeline = c.Pipeline opts.Web = c.Web // Resolve pipeline first pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(opts.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) // Resolve build options optionsResolver := options.AggregateResolver{ options.ResolveBranchFromFlag(c.Branch), options.ResolveBranchFromRepository(f.GitRepository), }.WithResolverWhen( c.User != "", options.ResolveUserFromFlag(c.User), ).WithResolverWhen( c.Mine || c.User == "", options.ResolveCurrentUser(ctx, f), ) // Resolve build args := []string{} if c.BuildNumber != "" { args = []string{c.BuildNumber} } buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), ) bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { return output.WriteTextOrStructured(os.Stdout, format, nil, "No build found.") } opts.Organization = bld.Organization opts.Pipeline = bld.Pipeline opts.BuildNumber = bld.BuildNumber if err := opts.Validate(); err != nil { return err } if opts.Web { buildURL := fmt.Sprintf("https://buildkite.com/%s/%s/builds/%d", opts.Organization, opts.Pipeline, opts.BuildNumber) fmt.Printf("Opening %s in your browser\n", buildURL) return browser.OpenURL(buildURL) } var build buildkite.Build var artifacts []buildkite.Artifact var annotations []buildkite.Annotation var wg sync.WaitGroup var mu sync.Mutex if err = bkIO.SpinWhile(f, "Loading build information", func() error { var fetchErr error wg.Add(3) go func() { defer wg.Done() var apiErr error build, _, apiErr = f.RestAPIClient.Builds.Get( ctx, opts.Organization, opts.Pipeline, fmt.Sprint(opts.BuildNumber), c.buildGetOptions(), ) if apiErr != nil { mu.Lock() fetchErr = apiErr mu.Unlock() } }() go func() { defer wg.Done() var apiErr error artifacts, _, apiErr = f.RestAPIClient.Artifacts.ListByBuild( ctx, opts.Organization, opts.Pipeline, fmt.Sprint(opts.BuildNumber), nil, ) if apiErr != nil { mu.Lock() fetchErr = apiErr mu.Unlock() } }() go func() { defer wg.Done() var apiErr error annotations, _, apiErr = f.RestAPIClient.Annotations.ListByBuild( ctx, opts.Organization, opts.Pipeline, fmt.Sprint(opts.BuildNumber), nil, ) if apiErr != nil { mu.Lock() fetchErr = apiErr mu.Unlock() } }() wg.Wait() return fetchErr }); err != nil { return err } // Create a combined view for JSON/YAML output type BuildOutput struct { buildkite.Build Artifacts []buildkite.Artifact `json:"artifacts,omitempty"` Annotations []buildkite.Annotation `json:"annotations,omitempty"` } buildOutput := output.Viewable[BuildOutput]{ Data: BuildOutput{ Build: build, Artifacts: artifacts, Annotations: annotations, }, Render: func(b BuildOutput) string { return view.NewBuildView(&b.Build, b.Artifacts, b.Annotations, opts.Organization, opts.Pipeline).Render() }, } if format == output.FormatText { writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() _, err := fmt.Fprint(writer, buildOutput.TextOutput()) return err } return output.Write(os.Stdout, buildOutput, format) } ================================================ FILE: cmd/build/view_test.go ================================================ package build import ( "testing" ) func TestViewCmd_BuildGetOptions_WithJobStates(t *testing.T) { cmd := &ViewCmd{ JobStates: []string{"failed", "broken"}, } opts := cmd.buildGetOptions() if opts == nil { t.Fatal("Expected non-nil BuildGetOptions") return } if len(opts.JobStates) != 2 { t.Fatalf("Expected 2 job states, got %d", len(opts.JobStates)) } if opts.JobStates[0] != "failed" { t.Errorf("Expected first state to be 'failed', got %q", opts.JobStates[0]) } if opts.JobStates[1] != "broken" { t.Errorf("Expected second state to be 'broken', got %q", opts.JobStates[1]) } } func TestViewCmd_BuildGetOptions_Empty(t *testing.T) { cmd := &ViewCmd{} opts := cmd.buildGetOptions() if opts != nil { t.Errorf("Expected nil BuildGetOptions when no job states, got %+v", opts) } } func TestViewCmd_BuildGetOptions_SingleState(t *testing.T) { cmd := &ViewCmd{ JobStates: []string{"running"}, } opts := cmd.buildGetOptions() if opts == nil { t.Fatal("Expected non-nil BuildGetOptions") return } if len(opts.JobStates) != 1 { t.Fatalf("Expected 1 job state, got %d", len(opts.JobStates)) } if opts.JobStates[0] != "running" { t.Errorf("Expected state to be 'running', got %q", opts.JobStates[0]) } } ================================================ FILE: cmd/build/watch.go ================================================ package build import ( "context" "errors" "fmt" "os" "time" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/build/view/shared" "github.com/buildkite/cli/v3/internal/build/watch" "github.com/buildkite/cli/v3/internal/cli" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/internal/validation" "github.com/buildkite/cli/v3/pkg/cmd/factory" pkgValidation "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/mattn/go-isatty" ) type WatchCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to watch (omit for most recent build)"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Branch string `help:"The branch to watch builds for." short:"b"` Interval int `help:"Polling interval in seconds" default:"1"` } func (c *WatchCmd) Help() string { return ` Examples: # Watch the most recent build for the current branch $ bk build watch --pipeline my-pipeline # Watch a specific build $ bk build watch 429 --pipeline my-pipeline # Watch the most recent build on a specific branch $ bk build watch -b feature-x --pipeline my-pipeline # Watch a build on a specific pipeline $ bk build watch --pipeline my-pipeline # Set a custom polling interval (in seconds) $ bk build watch --interval 5 --pipeline my-pipeline` } func (c *WatchCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := pkgValidation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } tty := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) // Validate command options v := validation.New() v.AddRule("Interval", validation.MinValue(1)) if c.Pipeline != "" { v.AddRule("Pipeline", validation.Slug) } if err := v.Validate(map[string]interface{}{ "Pipeline": c.Pipeline, "Interval": c.Interval, }); err != nil { return err } ctx := context.Background() pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) optionsResolver := options.AggregateResolver{ options.ResolveBranchFromFlag(c.Branch), options.ResolveBranchFromRepository(f.GitRepository), } args := []string{} if c.BuildNumber != "" { args = []string{c.BuildNumber} } buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), ) bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { return fmt.Errorf("no running builds found") } fmt.Printf("Watching build %d on %s/%s\n", bld.BuildNumber, bld.Organization, bld.Pipeline) interval := time.Duration(c.Interval) * time.Second _, err = watch.WatchBuild(ctx, f.RestAPIClient, bld.Organization, bld.Pipeline, bld.BuildNumber, interval, func(b buildkite.Build) error { summary := shared.BuildSummaryWithJobs(&b, bld.Organization, bld.Pipeline) if tty { fmt.Print("\033[H\033[2J") fmt.Printf("%s\n", summary) } else { fmt.Printf("[%s] %s\n", time.Now().Format(time.RFC3339), summary) } return nil }) if errors.Is(err, context.Canceled) { return nil } return err } ================================================ FILE: cmd/cluster/cluster_test.go ================================================ package cluster import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestListClusters(t *testing.T) { t.Parallel() t.Run("fetches clusters through API", func(t *testing.T) { t.Parallel() clusters := []buildkite.Cluster{ { ID: "cluster-1", Name: "Production", Description: "Production cluster", }, { ID: "cluster-2", Name: "Staging", Description: "Staging cluster", }, } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters") { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(clusters) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.Clusters.List(context.Background(), "test-org", nil) if err != nil { t.Fatal(err) } if len(result) != 2 { t.Fatalf("expected 2 clusters, got %d", len(result)) } if result[0].Name != "Production" { t.Errorf("expected name 'Production', got %q", result[0].Name) } if result[1].ID != "cluster-2" { t.Errorf("expected ID 'cluster-2', got %q", result[1].ID) } }) t.Run("empty result returns empty slice", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]buildkite.Cluster{}) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.Clusters.List(context.Background(), "test-org", nil) if err != nil { t.Fatal(err) } if len(result) != 0 { t.Errorf("expected 0 clusters, got %d", len(result)) } }) } func TestGetCluster(t *testing.T) { t.Parallel() cluster := buildkite.Cluster{ ID: "cluster-1", Name: "Production", Description: "Production cluster", Color: "#FF0000", Emoji: ":rocket:", } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters/cluster-1") { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(cluster) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.Clusters.Get(context.Background(), "test-org", "cluster-1") if err != nil { t.Fatal(err) } if result.Name != "Production" { t.Errorf("expected name 'Production', got %q", result.Name) } if result.Color != "#FF0000" { t.Errorf("expected color '#FF0000', got %q", result.Color) } } func TestCreateCluster(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters") { t.Errorf("unexpected path: %s", r.URL.Path) } var input buildkite.ClusterCreate if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Fatal(err) } if input.Name != "New Cluster" { t.Errorf("expected name 'New Cluster', got %q", input.Name) } if input.Description != "A brand new cluster" { t.Errorf("expected description 'A brand new cluster', got %q", input.Description) } if input.Color != "#FF0000" { t.Errorf("expected color '#FF0000', got %q", input.Color) } if input.Emoji != ":rocket:" { t.Errorf("expected emoji ':rocket:', got %q", input.Emoji) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(buildkite.Cluster{ ID: "new-cluster-id", Name: input.Name, Description: input.Description, Color: input.Color, Emoji: input.Emoji, }) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.Clusters.Create(context.Background(), "test-org", buildkite.ClusterCreate{ Name: "New Cluster", Description: "A brand new cluster", Color: "#FF0000", Emoji: ":rocket:", }) if err != nil { t.Fatal(err) } if result.ID != "new-cluster-id" { t.Errorf("expected ID 'new-cluster-id', got %q", result.ID) } if result.Name != "New Cluster" { t.Errorf("expected name 'New Cluster', got %q", result.Name) } } func TestUpdateCluster(t *testing.T) { t.Parallel() t.Run("updates cluster metadata", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "PATCH" { t.Errorf("expected PATCH, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters/cluster-1") { t.Errorf("unexpected path: %s", r.URL.Path) } var input buildkite.ClusterUpdate if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Fatal(err) } if input.Name != "Updated Name" { t.Errorf("expected name 'Updated Name', got %q", input.Name) } if input.Description != "Updated description" { t.Errorf("expected description 'Updated description', got %q", input.Description) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Cluster{ ID: "cluster-1", Name: input.Name, Description: input.Description, }) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.Clusters.Update(context.Background(), "test-org", "cluster-1", buildkite.ClusterUpdate{ Name: "Updated Name", Description: "Updated description", }) if err != nil { t.Fatal(err) } if result.Name != "Updated Name" { t.Errorf("expected name 'Updated Name', got %q", result.Name) } if result.Description != "Updated description" { t.Errorf("expected description 'Updated description', got %q", result.Description) } }) t.Run("updates default queue", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "PATCH" { t.Errorf("expected PATCH, got %s", r.Method) } var input buildkite.ClusterUpdate if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Fatal(err) } if input.DefaultQueueID != "queue-123" { t.Errorf("expected default_queue_id 'queue-123', got %q", input.DefaultQueueID) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Cluster{ ID: "cluster-1", Name: "Production", DefaultQueueID: input.DefaultQueueID, }) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.Clusters.Update(context.Background(), "test-org", "cluster-1", buildkite.ClusterUpdate{ DefaultQueueID: "queue-123", }) if err != nil { t.Fatal(err) } if result.DefaultQueueID != "queue-123" { t.Errorf("expected default_queue_id 'queue-123', got %q", result.DefaultQueueID) } }) } func TestDeleteCluster(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { t.Errorf("expected DELETE, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters/cluster-to-delete") { t.Errorf("unexpected path: %s", r.URL.Path) } w.WriteHeader(http.StatusNoContent) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } _, err = client.Clusters.Delete(context.Background(), "test-org", "cluster-to-delete") if err != nil { t.Fatal(err) } } func TestUpdateCmdValidate(t *testing.T) { t.Parallel() tests := []struct { name string cmd UpdateCmd wantErr bool }{ { name: "no flags set", cmd: UpdateCmd{ClusterUUID: "cluster-1"}, wantErr: true, }, { name: "only name", cmd: UpdateCmd{ClusterUUID: "cluster-1", Name: "New Name"}, wantErr: false, }, { name: "only description", cmd: UpdateCmd{ClusterUUID: "cluster-1", Description: "New description"}, wantErr: false, }, { name: "only emoji", cmd: UpdateCmd{ClusterUUID: "cluster-1", Emoji: ":rocket:"}, wantErr: false, }, { name: "only color", cmd: UpdateCmd{ClusterUUID: "cluster-1", Color: "#FF0000"}, wantErr: false, }, { name: "only default-queue-id", cmd: UpdateCmd{ClusterUUID: "cluster-1", DefaultQueueID: "queue-123"}, wantErr: false, }, { name: "multiple fields", cmd: UpdateCmd{ClusterUUID: "cluster-1", Name: "New Name", Description: "New desc", Color: "#00FF00"}, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() err := tt.cmd.Validate() if (err != nil) != tt.wantErr { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestRenderClusterText(t *testing.T) { t.Parallel() ts := buildkite.Timestamp{} cluster := buildkite.Cluster{ ID: "cluster-123", GraphQLID: "graphql-123", Name: "Production", Description: "Production cluster", Color: "#FF0000", Emoji: ":rocket:", WebURL: "https://buildkite.com/orgs/test-org/clusters/cluster-123", CreatedBy: buildkite.ClusterCreator{ ID: "user-1", Name: "Test User", }, CreatedAt: &ts, } result := renderClusterText(cluster) expectedStrings := []string{ "Viewing Production", "cluster-123", "Production cluster", "#FF0000", ":rocket:", "Test User", } for _, expected := range expectedStrings { if !strings.Contains(result, expected) { t.Errorf("expected output to contain %q, got:\n%s", expected, result) } } } ================================================ FILE: cmd/cluster/create.go ================================================ package cluster import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type CreateCmd struct { Name string `help:"The name of the cluster" required:""` Description string `help:"A description of the cluster" optional:""` Emoji string `help:"An emoji for the cluster (e.g. :rocket:)" optional:""` Color string `help:"A color hex code for the cluster (e.g. #FF0000)" optional:""` output.OutputFlags } func (c *CreateCmd) Help() string { return ` Create a new cluster in the organization. Examples: # Create a cluster with just a name $ bk cluster create --name "My Cluster" # Create a cluster with all fields $ bk cluster create --name "My Cluster" --description "Runs production workloads" --emoji :rocket: --color "#FF0000" # Create a cluster and output as JSON $ bk cluster create --name "My Cluster" -o json ` } func (c *CreateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() input := buildkite.ClusterCreate{ Name: c.Name, Description: c.Description, Emoji: c.Emoji, Color: c.Color, } var cluster buildkite.Cluster if err = bkIO.SpinWhile(f, "Creating cluster", func() error { var apiErr error cluster, _, apiErr = f.RestAPIClient.Clusters.Create(ctx, f.Config.OrganizationSlug(), input) return apiErr }); err != nil { return fmt.Errorf("error creating cluster: %v", err) } clusterView := output.Viewable[buildkite.Cluster]{ Data: cluster, Render: renderClusterText, } if format != output.FormatText { return output.Write(os.Stdout, clusterView, format) } fmt.Fprintf(os.Stdout, "Cluster %s created successfully\n\n", cluster.Name) return output.Write(os.Stdout, clusterView, format) } ================================================ FILE: cmd/cluster/delete.go ================================================ package cluster import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type DeleteCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID to delete" name:"cluster-uuid"` } func (c *DeleteCmd) Help() string { return ` Delete a cluster from the organization. You will be prompted to confirm deletion unless --yes is set. Examples: # Delete a cluster (with confirmation prompt) $ bk cluster delete my-cluster-uuid # Delete a cluster without confirmation $ bk cluster delete my-cluster-uuid --yes ` } func (c *DeleteCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() confirmed, err := bkIO.Confirm(f, fmt.Sprintf("Are you sure you want to delete cluster %s?", c.ClusterUUID)) if err != nil { return err } if !confirmed { fmt.Fprintln(os.Stderr, "Deletion cancelled.") return nil } if err = bkIO.SpinWhile(f, "Deleting cluster", func() error { _, err = f.RestAPIClient.Clusters.Delete(ctx, f.Config.OrganizationSlug(), c.ClusterUUID) return err }); err != nil { return fmt.Errorf("error deleting cluster: %v", err) } fmt.Fprintln(os.Stderr, "Cluster deleted successfully.") return nil } ================================================ FILE: cmd/cluster/list.go ================================================ package cluster import ( "context" "errors" "fmt" "os" "os/signal" "sync" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/cluster" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ListCmd struct { output.OutputFlags } func (c *ListCmd) Help() string { return ` List the clusters for an organization. Examples: # List all clusters $ bk cluster list # List clusters in JSON format $ bk cluster list -o json ` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() clusters, err := listClusters(ctx, f) if err != nil { return err } if format != output.FormatText { return output.Write(os.Stdout, clusters, format) } summary := cluster.ClusterViewTable(clusters...) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "%v\n", summary) return nil } func listClusters(ctx context.Context, f *factory.Factory) ([]buildkite.Cluster, error) { var clusters []buildkite.Cluster var err error if err = bkIO.SpinWhile(f, "Loading clusters information", func() error { var apiErr error clusters, _, apiErr = f.RestAPIClient.Clusters.List(ctx, f.Config.OrganizationSlug(), nil) return apiErr }); err != nil { return nil, fmt.Errorf("error fetching cluster list: %v", err) } if len(clusters) < 1 { return nil, errors.New("no clusters found in organization") } clusterList := make([]buildkite.Cluster, len(clusters)) var wg sync.WaitGroup errChan := make(chan error, len(clusters)) for i, c := range clusters { wg.Add(1) go func(i int, c buildkite.Cluster) { defer wg.Done() clusterList[i] = buildkite.Cluster{ Color: c.Color, CreatedAt: c.CreatedAt, CreatedBy: c.CreatedBy, DefaultQueueID: c.DefaultQueueID, DefaultQueueURL: c.DefaultQueueURL, Description: c.Description, Emoji: c.Emoji, GraphQLID: c.GraphQLID, ID: c.ID, Name: c.Name, QueuesURL: c.QueuesURL, URL: c.URL, WebURL: c.WebURL, } }(i, c) } go func() { wg.Wait() close(errChan) }() for err := range errChan { if err != nil { return nil, err } } return clusterList, nil } ================================================ FILE: cmd/cluster/update.go ================================================ package cluster import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type UpdateCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID to update" name:"cluster-uuid"` Name string `help:"New name for the cluster" optional:""` Description string `help:"New description for the cluster" optional:""` Emoji string `help:"New emoji for the cluster (e.g. :rocket:)" optional:""` Color string `help:"New color hex code for the cluster (e.g. #FF0000)" optional:""` DefaultQueueID string `help:"UUID of the queue to set as the default" optional:"" name:"default-queue-id"` output.OutputFlags } func (c *UpdateCmd) Help() string { return ` Update a cluster's settings. At least one of --name, --description, --emoji, --color, or --default-queue-id must be provided. Examples: # Update a cluster's name $ bk cluster update my-cluster-uuid --name "New Name" # Update description and color $ bk cluster update my-cluster-uuid --description "Updated description" --color "#00FF00" # Set the default queue $ bk cluster update my-cluster-uuid --default-queue-id my-queue-uuid # Output the updated cluster as JSON $ bk cluster update my-cluster-uuid --name "New Name" -o json ` } func (c *UpdateCmd) Validate() error { if c.Name == "" && c.Description == "" && c.Emoji == "" && c.Color == "" && c.DefaultQueueID == "" { return fmt.Errorf("at least one of --name, --description, --emoji, --color, or --default-queue-id must be provided") } return nil } func (c *UpdateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() input := buildkite.ClusterUpdate{ Name: c.Name, Description: c.Description, Emoji: c.Emoji, Color: c.Color, DefaultQueueID: c.DefaultQueueID, } var cluster buildkite.Cluster if err = bkIO.SpinWhile(f, "Updating cluster", func() error { var apiErr error cluster, _, apiErr = f.RestAPIClient.Clusters.Update(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, input) return apiErr }); err != nil { return fmt.Errorf("error updating cluster: %v", err) } clusterView := output.Viewable[buildkite.Cluster]{ Data: cluster, Render: renderClusterText, } if format != output.FormatText { return output.Write(os.Stdout, clusterView, format) } fmt.Fprintf(os.Stdout, "Cluster %s updated successfully\n\n", cluster.Name) return output.Write(os.Stdout, clusterView, format) } ================================================ FILE: cmd/cluster/view.go ================================================ package cluster import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "time" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ViewCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID to view" name:"cluster-uuid"` output.OutputFlags } func (c *ViewCmd) Help() string { return ` It accepts cluster UUID. Examples: # View a cluster $ bk cluster view my-cluster-uuid # View cluster in JSON format $ bk cluster view my-cluster-uuid -o json ` } func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var cluster buildkite.Cluster if err = bkIO.SpinWhile(f, "Loading cluster information", func() error { var apiErr error cluster, _, apiErr = f.RestAPIClient.Clusters.Get(ctx, f.Config.OrganizationSlug(), c.ClusterUUID) return apiErr }); err != nil { return err } clusterView := output.Viewable[buildkite.Cluster]{ Data: cluster, Render: renderClusterText, } if format != output.FormatText { return output.Write(os.Stdout, clusterView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() return output.Write(writer, clusterView, format) } func renderClusterText(c buildkite.Cluster) string { rows := [][]string{ {"Description", output.ValueOrDash(c.Description)}, {"Color", output.ValueOrDash(c.Color)}, {"Emoji", output.ValueOrDash(c.Emoji)}, {"ID", output.ValueOrDash(c.ID)}, {"GraphQL ID", output.ValueOrDash(c.GraphQLID)}, {"Default Queue ID", output.ValueOrDash(c.DefaultQueueID)}, {"Web URL", output.ValueOrDash(c.WebURL)}, {"API URL", output.ValueOrDash(c.URL)}, {"Queues URL", output.ValueOrDash(c.QueuesURL)}, {"Queue URL", output.ValueOrDash(c.DefaultQueueURL)}, } if c.CreatedBy.ID != "" { rows = append( rows, []string{"Created By Name", output.ValueOrDash(c.CreatedBy.Name)}, []string{"Created By Email", output.ValueOrDash(c.CreatedBy.Email)}, []string{"Created By ID", output.ValueOrDash(c.CreatedBy.ID)}, ) } if c.CreatedAt != nil { rows = append(rows, []string{"Created At", c.CreatedAt.Format(time.RFC3339)}) } var sb strings.Builder fmt.Fprintf(&sb, "Viewing %s\n\n", output.ValueOrDash(c.Name)) table := output.Table( []string{"Field", "Value"}, rows, map[string]string{"field": "dim", "value": "italic"}, ) sb.WriteString(table) return sb.String() } ================================================ FILE: cmd/config/config.go ================================================ // Package config provides commands for managing CLI configuration package config import ( "fmt" "slices" "strconv" "github.com/buildkite/cli/v3/internal/config" ) // ConfigCmd is the root command for managing CLI configuration type ConfigCmd struct { List ListCmd `cmd:"" help:"List configuration values." aliases:"ls"` Get GetCmd `cmd:"" help:"Get a configuration value."` Set SetCmd `cmd:"" help:"Set a configuration value."` Unset UnsetCmd `cmd:"" help:"Remove a configuration value."` } func (c ConfigCmd) Help() string { return `Manage CLI configuration settings. Configuration is stored in two locations: User config: ~/.config/bk.yaml (global defaults) Local config: .bk.yaml (repo-specific overrides) Precedence: Environment variable > Local config > User config > Default Examples: $ bk config list # Show all config values $ bk config get output_format # Get a specific value $ bk config set output_format yaml # Set default output to YAML $ bk config set no_pager true --local # Disable pager for this repo $ bk config unset pager # Reset pager to default` } // ConfigKey represents a valid configuration key type ConfigKey string const ( KeySelectedOrg ConfigKey = "selected_org" KeyOutputFormat ConfigKey = "output_format" KeyNoPager ConfigKey = "no_pager" KeyQuiet ConfigKey = "quiet" KeyNoInput ConfigKey = "no_input" KeyPager ConfigKey = "pager" KeyTelemetry ConfigKey = "telemetry" KeyExperiments ConfigKey = "experiments" ) // AllKeys returns all valid configuration keys func AllKeys() []ConfigKey { return []ConfigKey{ KeySelectedOrg, KeyOutputFormat, KeyNoPager, KeyQuiet, KeyNoInput, KeyPager, KeyTelemetry, KeyExperiments, } } // ValidateKey checks if a key is valid func ValidateKey(key string) (ConfigKey, error) { k := ConfigKey(key) if slices.Contains(AllKeys(), k) { return k, nil } return "", fmt.Errorf("unknown config key: %s\nvalid keys: %v", key, AllKeys()) } // IsLocalOnly returns true if the key can only be set in user config func (k ConfigKey) IsLocalOnly() bool { return false } // IsUserOnly returns true if the key can only be set in user config func (k ConfigKey) IsUserOnly() bool { switch k { case KeyNoInput, KeyPager, KeyTelemetry, KeyExperiments: return true default: return false } } // IsBool returns true if the key is a boolean value func (k ConfigKey) IsBool() bool { switch k { case KeyNoPager, KeyQuiet, KeyNoInput, KeyTelemetry: return true default: return false } } // ValidValues returns valid values for enum keys, or nil if any value is valid func (k ConfigKey) ValidValues() []string { switch k { case KeyOutputFormat: return []string{"json", "yaml", "text"} case KeyNoPager, KeyQuiet, KeyNoInput, KeyTelemetry: return []string{"true", "false"} default: return nil } } // parseBoolOrDefault parses a boolean string, returning the default for empty strings func parseBoolOrDefault(value string, defaultVal bool) (bool, error) { if value == "" { return defaultVal, nil } return strconv.ParseBool(value) } func SetConfigValue(conf *config.Config, key ConfigKey, value string, local bool) error { switch key { case KeySelectedOrg: return conf.SelectOrganization(value, local) case KeyOutputFormat: return conf.SetOutputFormat(value, local) case KeyNoPager: v, err := parseBoolOrDefault(value, false) if err != nil { return fmt.Errorf("invalid boolean value %q: %w", value, err) } return conf.SetNoPager(v, local) case KeyQuiet: v, err := parseBoolOrDefault(value, false) if err != nil { return fmt.Errorf("invalid boolean value %q: %w", value, err) } return conf.SetQuiet(v, local) case KeyNoInput: v, err := parseBoolOrDefault(value, false) if err != nil { return fmt.Errorf("invalid boolean value %q: %w", value, err) } return conf.SetNoInput(v) case KeyPager: return conf.SetPager(value) case KeyTelemetry: v, err := parseBoolOrDefault(value, true) if err != nil { return fmt.Errorf("invalid boolean value %q: %w", value, err) } return conf.SetTelemetry(v) case KeyExperiments: return conf.SetExperiments(value) } return nil } ================================================ FILE: cmd/config/config_test.go ================================================ package config import ( "testing" ) func TestValidateKey(t *testing.T) { t.Parallel() t.Run("valid keys", func(t *testing.T) { t.Parallel() validKeys := []string{ "selected_org", "output_format", "no_pager", "quiet", "no_input", "pager", "experiments", } for _, key := range validKeys { t.Run(key, func(t *testing.T) { got, err := ValidateKey(key) if err != nil { t.Errorf("ValidateKey(%q) returned error: %v", key, err) } if string(got) != key { t.Errorf("ValidateKey(%q) = %q, want %q", key, got, key) } }) } }) t.Run("invalid key", func(t *testing.T) { t.Parallel() _, err := ValidateKey("invalid_key") if err == nil { t.Error("ValidateKey(\"invalid_key\") expected error, got nil") } }) } func TestConfigKeyIsBool(t *testing.T) { t.Parallel() tests := []struct { key ConfigKey isBool bool }{ {KeyNoPager, true}, {KeyQuiet, true}, {KeyNoInput, true}, {KeyOutputFormat, false}, {KeySelectedOrg, false}, {KeyPager, false}, } for _, tt := range tests { t.Run(string(tt.key), func(t *testing.T) { t.Parallel() if got := tt.key.IsBool(); got != tt.isBool { t.Errorf("%s.IsBool() = %v, want %v", tt.key, got, tt.isBool) } }) } } func TestConfigKeyIsUserOnly(t *testing.T) { t.Parallel() tests := []struct { key ConfigKey isUserOnly bool }{ {KeyNoInput, true}, {KeyPager, true}, {KeyNoPager, false}, {KeyQuiet, false}, {KeyOutputFormat, false}, {KeySelectedOrg, false}, } for _, tt := range tests { t.Run(string(tt.key), func(t *testing.T) { t.Parallel() if got := tt.key.IsUserOnly(); got != tt.isUserOnly { t.Errorf("%s.IsUserOnly() = %v, want %v", tt.key, got, tt.isUserOnly) } }) } } func TestConfigKeyValidValues(t *testing.T) { t.Parallel() t.Run("output_format has valid values", func(t *testing.T) { t.Parallel() values := KeyOutputFormat.ValidValues() if values == nil { t.Fatal("expected valid values for output_format") } expected := []string{"json", "yaml", "text"} if len(values) != len(expected) { t.Errorf("got %d values, want %d", len(values), len(expected)) } }) t.Run("boolean keys have true/false", func(t *testing.T) { t.Parallel() for _, key := range []ConfigKey{KeyNoPager, KeyQuiet, KeyNoInput} { values := key.ValidValues() if values == nil { t.Errorf("%s.ValidValues() = nil, want [true, false]", key) continue } if len(values) != 2 || values[0] != "true" || values[1] != "false" { t.Errorf("%s.ValidValues() = %v, want [true, false]", key, values) } } }) t.Run("pager has no valid values constraint", func(t *testing.T) { t.Parallel() if values := KeyPager.ValidValues(); values != nil { t.Errorf("KeyPager.ValidValues() = %v, want nil", values) } }) } ================================================ FILE: cmd/config/get.go ================================================ package config import ( "fmt" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type GetCmd struct { Key string `arg:"" help:"Configuration key to get"` } func (c *GetCmd) Help() string { return `Get a configuration value. Returns the effective value after applying precedence rules: Environment variable > Local config (.bk.yaml) > User config (~/.config/bk.yaml) > Default Valid keys: selected_org Organization slug to use output_format Default output format (json, yaml, text) no_pager Disable pager for text output (true, false) quiet Suppress progress output (true, false) no_input Disable interactive prompts (true, false) pager Custom pager command experiments Enabled experiment flags Examples: $ bk config get output_format $ bk config get pager` } func (c *GetCmd) Run() error { key, err := ValidateKey(c.Key) if err != nil { return err } f, err := factory.New() if err != nil { return err } conf := f.Config var value string switch key { case KeySelectedOrg: value = conf.OrganizationSlug() case KeyOutputFormat: value = conf.OutputFormat() case KeyNoPager: if conf.PagerDisabled() { value = "true" } else { value = "false" } case KeyQuiet: if conf.Quiet() { value = "true" } else { value = "false" } case KeyNoInput: if conf.NoInput() { value = "true" } else { value = "false" } case KeyPager: value = conf.Pager() case KeyExperiments: value = conf.Experiments() } if value != "" { fmt.Println(value) } return nil } ================================================ FILE: cmd/config/list.go ================================================ package config import ( "fmt" "os" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type ListCmd struct { Local bool `help:"Only show local configuration" xor:"scope"` Global bool `help:"Only show global (user) configuration" xor:"scope"` } func (c *ListCmd) Help() string { return `List all configuration values. Shows the effective configuration after applying precedence rules. Examples: $ bk config list $ bk config list --local $ bk config list --global` } func (c *ListCmd) Run() error { f, err := factory.New() if err != nil { return err } conf := f.Config inGitRepo := f.GitRepository != nil type configItem struct { key string value string source string } var items []configItem if !c.Local { // Show global/user config values if v := conf.OrganizationSlug(); v != "" && !c.Global { items = append(items, configItem{string(KeySelectedOrg), v, "effective"}) } if v := conf.OutputFormat(); v != "" { items = append(items, configItem{string(KeyOutputFormat), v, "effective"}) } if conf.PagerDisabled() { items = append(items, configItem{string(KeyNoPager), "true", "effective"}) } if conf.Quiet() { items = append(items, configItem{string(KeyQuiet), "true", "effective"}) } if conf.NoInput() { items = append(items, configItem{string(KeyNoInput), "true", "effective"}) } if v := conf.Pager(); v != "" && v != "less -R" { items = append(items, configItem{string(KeyPager), v, "effective"}) } if v := conf.Experiments(); v != "" { items = append(items, configItem{string(KeyExperiments), v, "effective"}) } } if c.Local && !inGitRepo { fmt.Fprintln(os.Stderr, "warning: not in a git repository, no local config available") return nil } if len(items) == 0 { fmt.Println("No configuration values set.") return nil } for _, item := range items { fmt.Printf("%s=%s\n", item.key, item.value) } return nil } ================================================ FILE: cmd/config/set.go ================================================ package config import ( "fmt" "slices" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type SetCmd struct { Key string `arg:"" help:"Configuration key to set"` Value string `arg:"" help:"Value to set"` Local bool `help:"Save to local (.bk.yaml) instead of user config"` } func (c *SetCmd) Help() string { return `Set a configuration value. Valid keys: selected_org Organization slug to use output_format Default output format (json, yaml, text) no_pager Disable pager for text output (true, false) quiet Suppress progress output (true, false) no_input Disable interactive prompts (true, false) [user config only] pager Custom pager command [user config only] telemetry Enable anonymous usage telemetry (true, false) [user config only] Examples: # Set default output format to YAML $ bk config set output_format yaml # Disable pager globally $ bk config set no_pager true # Set repo-specific output format $ bk config set output_format text --local # Set a custom pager $ bk config set pager "less -RS"` } func (c *SetCmd) Run() error { key, err := ValidateKey(c.Key) if err != nil { return err } // Validate the value if validValues := key.ValidValues(); validValues != nil { if !slices.Contains(validValues, c.Value) { return fmt.Errorf("invalid value %q for %s\nvalid values: %v", c.Value, key, validValues) } } // Check if key can be set locally if c.Local && key.IsUserOnly() { return fmt.Errorf("%s can only be set in user config (not --local)", key) } f, err := factory.New() if err != nil { return err } return SetConfigValue(f.Config, key, c.Value, c.Local) } ================================================ FILE: cmd/config/unset.go ================================================ package config import ( "fmt" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type UnsetCmd struct { Key string `arg:"" help:"Configuration key to unset"` Local bool `help:"Unset from local (.bk.yaml) instead of user config"` } func (c *UnsetCmd) Help() string { return `Remove a configuration value, reverting to default. Examples: # Reset output format to default (json) $ bk config unset output_format # Remove repo-specific setting $ bk config unset output_format --local # Reset pager to default (less -R) $ bk config unset pager` } func (c *UnsetCmd) Run() error { key, err := ValidateKey(c.Key) if err != nil { return err } // Check if key can be unset locally if c.Local && key.IsUserOnly() { return fmt.Errorf("%s can only be unset from user config (not --local)", key) } f, err := factory.New() if err != nil { return err } return SetConfigValue(f.Config, key, "", c.Local) } ================================================ FILE: cmd/configure/configure.go ================================================ package configure import ( "bufio" "errors" "fmt" "os" "strings" "github.com/alecthomas/kong" bkAuth "github.com/buildkite/cli/v3/cmd/auth" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type ConfigureCmd struct { Org string `help:"Organization slug" optional:""` Token string `help:"API token" optional:""` Force bool `help:"Force setting a new token" optional:""` Default ConfigureDefaultCmd `cmd:"" optional:"" help:"Configure Buildkite API token" hidden:"" default:"1"` Add ConfigureAddCmd `cmd:"" optional:"" help:"Add configuration for a new organization"` } type ConfigureDefaultCmd struct{} type ConfigureAddCmd struct{} func (c *ConfigureAddCmd) Help() string { return ` Examples: # Interactively configure a new organization $ bk configure add # Configure a new organization non-interactively $ bk configure add --org my-org --token my-token ` } func (c *ConfigureCmd) Help() string { return ` Examples: # Configure Buildkite API token $ bk configure --org my-org --token my-token # Force setting a new token $ bk configure --force --org my-org --token my-token ` } func (c *ConfigureCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } if kongCtx.Command() == "configure default" { targetOrg := c.Org if targetOrg == "" { targetOrg = f.Config.OrganizationSlug() } if !c.Force && targetOrg != "" && f.Config.APITokenForOrg(targetOrg) != "" { return fmt.Errorf("API token already configured for organization %q. Use --force to overwrite", targetOrg) } } // If flags are provided, use them directly if c.Org != "" && c.Token != "" { return ConfigureWithCredentials(f, c.Org, c.Token) } return ConfigureRun(f, c.Org) } func ConfigureWithCredentials(f *factory.Factory, org, token string) error { return bkAuth.LoginWithToken(f, org, token) } func ConfigureRun(f *factory.Factory, org string) error { if org == "" { // Get organization slug inputOrg, err := promptForInput("Organization slug: ", false) if err != nil { return err } if inputOrg == "" { return errors.New("organization slug cannot be empty") } org = inputOrg } // Check if token already exists for this organization. // Use resolved token lookup so keychain-backed entries are detected. existingToken := getTokenForOrg(f, org) if existingToken != "" { fmt.Printf("Using existing API token for organization: %s\n", org) return f.Config.SelectOrganization(org, f.GitRepository != nil) } // Get API token with password input (no echo) token, err := promptForInput("API Token: ", true) if err != nil { return err } if token == "" { return errors.New("API token cannot be empty") } fmt.Println("API token set for organization:", org) return ConfigureWithCredentials(f, org, token) } // getTokenForOrg retrieves the resolved token for a specific organization. func getTokenForOrg(f *factory.Factory, org string) string { return f.Config.APITokenForOrg(org) } // promptForInput handles terminal input with optional password masking func promptForInput(prompt string, isPassword bool) (string, error) { fmt.Print(prompt) if isPassword { return io.ReadPassword() } else { // Use standard input for regular text reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { return "", err } // Trim whitespace and newlines return strings.TrimSpace(input), nil } } ================================================ FILE: cmd/configure/configure_case_test.go ================================================ package configure import ( "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/keyring" "github.com/spf13/afero" ) func TestConfigurePreservesOrganizationCase(t *testing.T) { testCases := []struct { name string orgInput string expectedOrg string }{ { name: "preserves mixed case organization name", orgInput: "gridX", expectedOrg: "gridX", }, { name: "preserves uppercase organization name", orgInput: "ACME", expectedOrg: "ACME", }, { name: "preserves lowercase organization name", orgInput: "buildkite", expectedOrg: "buildkite", }, { name: "preserves camelCase organization name", orgInput: "myOrg", expectedOrg: "myOrg", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { keyring.MockForTesting() fs := afero.NewMemMapFs() conf := config.New(fs, nil) f := &factory.Factory{Config: conf} token := "bk_test_token_12345" err := ConfigureWithCredentials(f, tc.orgInput, token) if err != nil { t.Fatalf("ConfigureWithCredentials failed: %v", err) } gotOrg := conf.OrganizationSlug() if gotOrg != tc.expectedOrg { t.Errorf("expected organization to be %q, got %q", tc.expectedOrg, gotOrg) } kr := keyring.New() gotToken, _ := kr.Get(tc.orgInput) if gotToken != token { t.Errorf("expected token to be %q, got %q", token, gotToken) } }) } } ================================================ FILE: cmd/configure/configure_test.go ================================================ package configure import ( "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/keyring" "github.com/spf13/afero" ) func TestGetTokenForOrg(t *testing.T) { t.Run("returns empty string when no token exists", func(t *testing.T) { fs := afero.NewMemMapFs() conf := config.New(fs, nil) f := &factory.Factory{Config: conf} token := getTokenForOrg(f, "nonexistent") if token != "" { t.Errorf("expected empty string, got %s", token) } }) t.Run("returns token when it exists in keychain", func(t *testing.T) { keyring.MockForTesting() fs := afero.NewMemMapFs() conf := config.New(fs, nil) f := &factory.Factory{Config: conf} kr := keyring.New() kr.Set("test-org", "bk_test_token_12345") token := getTokenForOrg(f, "test-org") if token != "bk_test_token_12345" { t.Errorf("expected bk_test_token_12345, got %s", token) } }) t.Run("returns different tokens for different organizations", func(t *testing.T) { keyring.MockForTesting() fs := afero.NewMemMapFs() conf := config.New(fs, nil) f := &factory.Factory{Config: conf} kr := keyring.New() kr.Set("org1", "bk_test_token_org1") kr.Set("org2", "bk_test_token_org2") if getTokenForOrg(f, "org1") != "bk_test_token_org1" { t.Errorf("expected bk_test_token_org1 for org1") } if getTokenForOrg(f, "org2") != "bk_test_token_org2" { t.Errorf("expected bk_test_token_org2 for org2") } }) } func TestConfigureWithCredentials(t *testing.T) { t.Run("configures organization and token", func(t *testing.T) { keyring.MockForTesting() fs := afero.NewMemMapFs() conf := config.New(fs, nil) f := &factory.Factory{Config: conf} org := "test-org" token := "bk_test_token_12345" err := ConfigureWithCredentials(f, org, token) if err != nil { t.Errorf("expected no error, got %s", err) } if conf.OrganizationSlug() != org { t.Errorf("expected organization to be %s, got %s", org, conf.OrganizationSlug()) } kr := keyring.New() got, _ := kr.Get(org) if got != token { t.Errorf("expected token to be %s, got %s", token, got) } }) } func TestConfigureTokenReuse(t *testing.T) { t.Run("reuses existing token when available", func(t *testing.T) { keyring.MockForTesting() fs := afero.NewMemMapFs() conf := config.New(fs, nil) f := &factory.Factory{Config: conf} org := "test-org" existingToken := "bk_existing_token_12345" // Pre-configure a token in the keychain kr := keyring.New() kr.Set(org, existingToken) // Verify the token can be retrieved retrievedToken := getTokenForOrg(f, org) if retrievedToken != existingToken { t.Errorf("expected to retrieve existing token %s, got %s", existingToken, retrievedToken) } // Configure with the existing token err := ConfigureWithCredentials(f, org, retrievedToken) if err != nil { t.Errorf("expected no error, got %s", err) } if conf.OrganizationSlug() != org { t.Errorf("expected organization to be %s, got %s", org, conf.OrganizationSlug()) } got, _ := kr.Get(org) if got != existingToken { t.Errorf("expected token to be %s, got %s", existingToken, got) } }) } ================================================ FILE: cmd/generate/generate.go ================================================ package main import ( "context" "errors" "fmt" "net/http" "os" "github.com/Khan/genqlient/generate" "github.com/suessflorian/gqlfetch" ) //go:generate go run generate.go func main() { const schemaFile = "../../schema.graphql" if _, err := os.Stat(schemaFile); errors.Is(err, os.ErrNotExist) { headers := http.Header{ "Authorization": []string{fmt.Sprintf("Bearer %s", os.Getenv("BUILDKITE_GRAPHQL_TOKEN"))}, } fmt.Printf("Generating new schema file at %s\n", schemaFile) schema, err := gqlfetch.BuildClientSchemaWithHeaders(context.Background(), "https://graphql.buildkite.com/v1", headers, false) if err != nil { fmt.Println(err) os.Exit(1) } if err = os.WriteFile(schemaFile, []byte(schema), 0o644); err != nil { fmt.Println(err) os.Exit(1) } fmt.Printf("Schema written to %s\n", schemaFile) } fmt.Println("Generating GraphQL code") generate.Main() } ================================================ FILE: cmd/init/init.go ================================================ package init import ( "fmt" "os" "path/filepath" "github.com/alecthomas/kong" ) const ( defaultPipelineYAML = `steps: - label: "Hello, world! 👋" command: echo "Hello, world!"` ) type InitCmd struct{} func (c *InitCmd) Run(kongCtx *kong.Context) error { if found, path := findExistingPipelineFile(""); found { fmt.Printf("✨ File found at %s. You're good to go!\n", path) return nil } pipelineFile := filepath.Join(".buildkite", "pipeline.yaml") err := os.MkdirAll(filepath.Dir(pipelineFile), 0o755) if err != nil { return err } err = os.WriteFile(pipelineFile, []byte(defaultPipelineYAML), 0o660) if err != nil { return err } fmt.Printf("✨ File created at %s. You're good to go!\n", pipelineFile) return nil } func findExistingPipelineFile(base string) (bool, string) { // the order in which buildkite-agent checks for files paths := []string{ "buildkite.yml", "buildkite.yaml", "buildkite.json", filepath.FromSlash(".buildkite/pipeline.yml"), filepath.FromSlash(".buildkite/pipeline.yaml"), filepath.FromSlash(".buildkite/pipeline.json"), filepath.FromSlash("buildkite/pipeline.yml"), filepath.FromSlash("buildkite/pipeline.yaml"), filepath.FromSlash("buildkite/pipeline.json"), } for _, path := range paths { path = filepath.Join(base, path) if _, err := os.Stat(path); err == nil { return true, path } } return false, "" } ================================================ FILE: cmd/init/init_test.go ================================================ package init import ( "os" "path/filepath" "testing" ) func TestFindExistingPipelineFileWithNoFile(t *testing.T) { dir, err := os.MkdirTemp("", "bk-cli-*") if err != nil { t.Error(err) } defer os.RemoveAll(dir) if found, _ := findExistingPipelineFile(dir); found { t.Fail() } } func TestFindExistingPipelineFile(t *testing.T) { dir, err := os.MkdirTemp("", "bk-cli-*") if err != nil { t.Error(err) } defer os.RemoveAll(dir) _ = os.MkdirAll(filepath.Join(dir, ".buildkite"), 0o755) f, _ := os.Create(filepath.Join(dir, ".buildkite", "pipeline.yml")) defer f.Close() if found, _ := findExistingPipelineFile(dir); !found { t.Fail() } } ================================================ FILE: cmd/job/cancel.go ================================================ package job import ( "context" "fmt" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/graphql" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type CancelCmd struct { JobID string `arg:"" help:"Job ID to cancel" required:""` Web bool `help:"Open the job in a web browser after it has been cancelled" short:"w"` } func (c *CancelCmd) Help() string { return ` Examples: # Cancel a job (with confirmation prompt) $ bk job cancel 0190046e-e199-453b-a302-a21a4d649d31 # Cancel a job without confirmation (useful for automation) $ bk job --yes cancel 0190046e-e199-453b-a302-a21a4d649d31 # Cancel a job and open it in browser $ bk job --yes cancel 0190046e-e199-453b-a302-a21a4d649d31 --web ` } func (c *CancelCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() graphqlID := util.GenerateGraphQLID("JobTypeCommand---", c.JobID) confirmed, err := bkIO.Confirm(f, fmt.Sprintf("Cancel job %s", c.JobID)) if err != nil { return err } if !confirmed { return nil } return c.cancelJob(ctx, c.JobID, graphqlID, f) } func (c *CancelCmd) cancelJob(ctx context.Context, displayID, apiID string, f *factory.Factory) error { var result *graphql.CancelJobResponse if err := bkIO.SpinWhile(f, fmt.Sprintf("Cancelling job %s", displayID), func() error { var apiErr error result, apiErr = graphql.CancelJob(ctx, f.GraphQLClient, apiID) return apiErr }); err != nil { return err } job := result.JobTypeCommandCancel.JobTypeCommand fmt.Printf("Job canceled: %s\n", job.Url) return util.OpenInWebBrowser(c.Web, job.Url) } ================================================ FILE: cmd/job/cancel_test.go ================================================ package job import ( "testing" "github.com/buildkite/cli/v3/internal/util" ) func TestCancelCmdStructure(t *testing.T) { t.Parallel() cmd := &CancelCmd{ JobID: "01993829-d2e7-4834-9611-bbeb8c1290db", Web: true, } if cmd.JobID == "" { t.Error("JobID should be set") } if !cmd.Web { t.Error("Web flag should be true") } } func TestGraphQLIDGeneration(t *testing.T) { t.Parallel() jobUUID := "01993829-d2e7-4834-9611-bbeb8c1290db" graphqlID := util.GenerateGraphQLID("JobTypeCommand---", jobUUID) if graphqlID == "" { t.Error("GraphQL ID should not be empty") } } ================================================ FILE: cmd/job/graphql/cancel.graphql ================================================ mutation CancelJob($jobId: ID!) { jobTypeCommandCancel(input: { id: $jobId }) { clientMutationId jobTypeCommand { id uuid state url } } } ================================================ FILE: cmd/job/graphql/jobs.graphql ================================================ query FindClusters($org: ID!, $cursor: String) { organization(slug: $org) { clusters(first: 100, after: $cursor) { edges { node { id name } } pageInfo { hasNextPage endCursor } } } } query FindQueuesForCluster($clusterId: ID!, $cursor: String) { node(id: $clusterId) { ... on Cluster { id name queues(first: 100, after: $cursor) { edges { node { id key } } pageInfo { hasNextPage endCursor } } } } } query ListJobsByQueue($org: ID!, $clusterQueue: [ID!], $first: Int, $after: String) { organization(slug: $org) { jobs(clusterQueue: $clusterQueue, first: $first, after: $after) { edges { node { ... on JobTypeCommand { id uuid command state exitStatus url startedAt finishedAt createdAt cluster { id name } clusterQueue { id key } agent { id name hostname metaData } } } } pageInfo { endCursor hasNextPage } } } } query ListJobsByState($org: ID!, $state: [JobStates!], $first: Int, $after: String) { organization(slug: $org) { jobs(state: $state, first: $first, after: $after) { edges { node { ... on JobTypeCommand { id uuid label command state exitStatus url startedAt finishedAt createdAt cluster { id name } clusterQueue { id key } agent { id name hostname metaData } } } } pageInfo { endCursor hasNextPage } } } } query ListJobsByAgentQueryRules($org: ID!, $agentQueryRules: [String!], $first: Int, $after: String) { organization(slug: $org) { jobs(first: $first, after: $after, agentQueryRules: $agentQueryRules) { edges { node { ... on JobTypeCommand { id uuid command state exitStatus url startedAt finishedAt createdAt agent { id name hostname metaData } } } } pageInfo { endCursor hasNextPage } } } } ================================================ FILE: cmd/job/graphql/retry.graphql ================================================ mutation RetryJob($id: ID!) { jobTypeCommandRetry(input: {id: $id}) { jobTypeCommand { id state url } } } ================================================ FILE: cmd/job/graphql/unblock.graphql ================================================ mutation UnblockJob($id: ID!, $fields: JSON) { jobTypeBlockUnblock(input: {id: $id, fields: $fields}) { jobTypeBlock { id state isUnblockable build { url } } } } ================================================ FILE: cmd/job/list.go ================================================ package job import ( "context" "fmt" "io" "os" "sort" "strings" "sync" "time" "github.com/Khan/genqlient/graphql" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkGraphQL "github.com/buildkite/cli/v3/internal/graphql" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) const ( maxJobLimit = 5000 pageSize = 100 ) type ListCmd struct { Pipeline string `help:"Filter by pipeline slug" short:"p"` Since string `help:"Filter jobs from builds created since this time (e.g. 1h, 30m)"` Until string `help:"Filter jobs from builds created before this time (e.g. 1h, 30m)"` Duration string `help:"Filter by duration (e.g. >10m, <5m, 20m) - supports >, <, >=, <= operators"` State []string `help:"Filter by job state"` Queue string `help:"Filter by queue name"` OrderBy string `help:"Order results by field (start_time, duration)" name:"order-by"` Limit int `help:"Maximum number of jobs to return" default:"100"` NoLimit bool `help:"Fetch all jobs (overrides --limit)" name:"no-limit"` output.OutputFlags } func (c *ListCmd) Help() string { return `This command supports both server-side filtering (fast) and client-side filtering. Server-side filters are applied when fetching builds, while client-side filters are applied after extracting jobs from builds. Client-side filters: --queue, --state, --duration Server-side filters: --pipeline, --since, --until By default, fetches up to 200 builds for filtering. Use --no-limit if you need to search across more builds to find all matching jobs. Jobs can be filtered by queue, state, duration, and other attributes. When filtering by duration, you can use operators like >, <, >=, and <= to specify your criteria. Supported duration units are seconds (s), minutes (m), and hours (h). Examples: # List recent jobs (100 by default) $ bk job list # List jobs from a specific queue $ bk job list --queue test-queue # List running jobs $ bk job list --state running # List jobs that took longer than 10 minutes $ bk job list --duration ">10m" # List jobs from the last hour $ bk job list --since 1h # Combine filters $ bk job list --queue test-queue --state running --duration ">10m" # Fetch all jobs matching filters (no limit) $ bk job list --duration ">10m" --no-limit # Order by duration (longest first) $ bk job list --order-by duration # Get JSON output for bulk operations $ bk job list --queue test-queue -o json ` } type jobListOptions struct { pipeline string since string until string duration string state []string queue string orderBy string limit int noLimit bool } func (opts jobListOptions) withoutQueue() jobListOptions { newOpts := opts newOpts.queue = "" return newOpts } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) if !c.NoLimit && c.Limit > maxJobLimit { return fmt.Errorf("limit cannot exceed %d jobs (requested: %d); if you need more, use --no-limit", maxJobLimit, c.Limit) } opts := jobListOptions{ pipeline: c.Pipeline, since: c.Since, until: c.Until, duration: c.Duration, state: c.State, queue: c.Queue, orderBy: c.OrderBy, limit: c.Limit, noLimit: c.NoLimit, } listOpts, err := jobListOptionsFromFlags(&opts) if err != nil { return err } ctx := context.Background() org := f.Config.OrganizationSlug() var jobs []buildkite.Job if err = bkIO.SpinWhile(f, "Loading jobs", func() error { if opts.queue != "" { jobs, err = fetchJobsWithQueueFilter(ctx, f, org, opts) } else { jobs, err = fetchJobs(ctx, f, org, opts, listOpts) } return err }); err != nil { return fmt.Errorf("failed to list jobs: %w", err) } if opts.queue == "" && (len(opts.state) > 0 || opts.duration != "") { jobs, err = applyClientSideFilters(jobs, opts) if err != nil { return fmt.Errorf("failed to apply filters: %w", err) } } if opts.orderBy != "" { jobs = sortJobs(jobs, opts.orderBy) } // Apply limit only if --no-limit is not set if !opts.noLimit && len(jobs) > opts.limit { jobs = jobs[:opts.limit] } if len(jobs) == 0 { if format != output.FormatText { return output.Write(os.Stdout, []buildkite.Job{}, format) } fmt.Println("No jobs found matching the specified criteria.") return nil } if format == output.FormatText { writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() target := org if c.Pipeline != "" { target = fmt.Sprintf("%s/%s", org, c.Pipeline) } fmt.Fprintf(writer, "Showing %d jobs for %s\n\n", len(jobs), target) return displayJobs(jobs, format, writer) } return displayJobs(jobs, format, os.Stdout) } func fetchJobs(ctx context.Context, f *factory.Factory, org string, opts jobListOptions, listOpts *buildkite.BuildsListOptions) ([]buildkite.Job, error) { var maxBuildsToFetch int if opts.noLimit { // When --no-limit is set, fetch all available builds (no upper bound) maxBuildsToFetch = 0 // 0 means unlimited } else { // By default, fetch a reasonable number of builds (200 = 2 pages) // This provides a good pool for filtering without being tied to --limit maxBuildsToFetch = 200 } allJobs := make([]buildkite.Job, 0, opts.limit*2) buildsFetched := 0 // Calculate max pages (0 means unlimited) var maxPages int if maxBuildsToFetch > 0 { maxPages = (maxBuildsToFetch + pageSize - 1) / pageSize } for page := 1; ; page++ { // Check page limit if set if maxPages > 0 && page > maxPages { break } listOpts.Page = page listOpts.PerPage = pageSize var builds []buildkite.Build var err error if opts.pipeline != "" { builds, err = getBuildsByPipeline(ctx, f, org, opts.pipeline, listOpts) } else { builds, _, err = f.RestAPIClient.Builds.ListByOrg(ctx, org, listOpts) } if err != nil { return nil, err } if len(builds) == 0 { break } buildsFetched += len(builds) for _, build := range builds { if len(allJobs)+len(build.Jobs) > cap(allJobs) { newJobs := make([]buildkite.Job, len(allJobs), len(allJobs)+len(build.Jobs)+100) copy(newJobs, allJobs) allJobs = newJobs } allJobs = append(allJobs, build.Jobs...) } // Stop if we got fewer builds than requested (last page) if len(builds) < pageSize { break } // Stop if we've reached the maximum builds to fetch (only when limit is set) if maxBuildsToFetch > 0 && buildsFetched >= maxBuildsToFetch { break } } return allJobs, nil } type listJobsByQueue func(ctx context.Context, f *factory.Factory, org string, queueIDs []string, cursor *string) ([]buildkite.Job, *string, bool, error) func listJobsWithPagination(ctx context.Context, f *factory.Factory, org string, queueIDs []string, opts jobListOptions, listJobs listJobsByQueue) ([]buildkite.Job, error) { var jobs []buildkite.Job var cursor *string noQueueOpts := opts.withoutQueue() for len(jobs) < opts.limit { jobBatch, nextCursor, hasNext, err := listJobs(ctx, f, org, queueIDs, cursor) if err != nil { return nil, err } if len(jobBatch) == 0 { break } // Apply client-side filters if needed if len(noQueueOpts.state) > 0 || noQueueOpts.duration != "" { jobBatch, err = applyClientSideFilters(jobBatch, noQueueOpts) if err != nil { return nil, fmt.Errorf("failed to apply filters: %w", err) } } for _, job := range jobBatch { if len(jobs) >= opts.limit { break } jobs = append(jobs, job) } if !hasNext { break } cursor = nextCursor } return jobs, nil } func fetchJobsWithQueueFilter(ctx context.Context, f *factory.Factory, org string, opts jobListOptions) ([]buildkite.Job, error) { queueIDs, err := lookupQueueIDs(ctx, f, org, opts.queue) if err != nil { return nil, err } if len(queueIDs) == 0 { // Fallback to unclustered agent query rules agentQueryRules := []string{"queue=" + strings.ToLower(opts.queue)} return listJobsWithPagination(ctx, f, org, agentQueryRules, opts, listJobsByAgentQueryRules) } return listJobsWithPagination(ctx, f, org, queueIDs, opts, listJobsByClusterQueue) } const maxConcurrentRequests = 10 // Balance between performance and API rate limits type ClusterInfo struct { ID string Name string } func lookupQueueIDs(ctx context.Context, f *factory.Factory, org, queueName string) ([]string, error) { clusters, err := fetchAllClusters(ctx, f.GraphQLClient, org) if err != nil { return nil, fmt.Errorf("failed to fetch clusters: %w", err) } if len(clusters) == 0 { return []string{}, nil } return fetchQueuesFromClusters(ctx, f.GraphQLClient, clusters, queueName) } func fetchAllClusters(ctx context.Context, client graphql.Client, org string) ([]ClusterInfo, error) { var allClusters []ClusterInfo var cursor *string for { resp, err := bkGraphQL.FindClusters(ctx, client, org, cursor) if err != nil { return nil, err } if resp.Organization == nil || resp.Organization.Clusters == nil { break } for _, edge := range resp.Organization.Clusters.Edges { if edge.Node != nil { allClusters = append(allClusters, ClusterInfo{ ID: edge.Node.Id, Name: edge.Node.Name, }) } } if resp.Organization.Clusters.PageInfo != nil && resp.Organization.Clusters.PageInfo.HasNextPage { cursor = resp.Organization.Clusters.PageInfo.EndCursor } else { break } } return allClusters, nil } func fetchQueuesFromClusters(ctx context.Context, client graphql.Client, clusters []ClusterInfo, queueName string) ([]string, error) { resultChan := make(chan []string, len(clusters)) errorChan := make(chan error, len(clusters)) semaphore := make(chan struct{}, maxConcurrentRequests) var wg sync.WaitGroup for _, cluster := range clusters { wg.Add(1) go func(c ClusterInfo) { defer wg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() queueIDs, err := fetchQueuesForCluster(ctx, client, c.ID, queueName) if err != nil { errorChan <- fmt.Errorf("cluster %s: %w", c.Name, err) return } resultChan <- queueIDs }(cluster) } var allQueueIDs []string var results int expectedResults := len(clusters) for results < expectedResults { select { case queueIDs := <-resultChan: allQueueIDs = append(allQueueIDs, queueIDs...) results++ case err := <-errorChan: return nil, err case <-ctx.Done(): return nil, ctx.Err() } } return allQueueIDs, nil } func fetchQueuesForCluster(ctx context.Context, client graphql.Client, clusterID, queueName string) ([]string, error) { var matchingQueueIDs []string var cursor *string targetLower := strings.ToLower(queueName) for { resp, err := bkGraphQL.FindQueuesForCluster(ctx, client, clusterID, cursor) if err != nil { return nil, err } if resp.Node == nil { break } cluster, ok := (*resp.Node).(*bkGraphQL.FindQueuesForClusterNodeCluster) if !ok || cluster == nil || cluster.Queues == nil { break } for _, edge := range cluster.Queues.Edges { if edge.Node != nil && strings.ToLower(edge.Node.Key) == targetLower { matchingQueueIDs = append(matchingQueueIDs, edge.Node.Id) } } if cluster.Queues.PageInfo != nil && cluster.Queues.PageInfo.HasNextPage { cursor = cluster.Queues.PageInfo.EndCursor } else { break } } return matchingQueueIDs, nil } func listJobsByClusterQueue(ctx context.Context, f *factory.Factory, org string, queueIDs []string, cursor *string) ([]buildkite.Job, *string, bool, error) { first := pageSize resp, err := bkGraphQL.ListJobsByQueue(ctx, f.GraphQLClient, org, queueIDs, &first, cursor) if err != nil { return nil, nil, false, fmt.Errorf("failed to list jobs: %w", err) } if resp.Organization == nil || resp.Organization.Jobs == nil { return []buildkite.Job{}, nil, false, nil } var jobs []buildkite.Job for _, edge := range resp.Organization.Jobs.Edges { if edge.Node != nil { jobs = append(jobs, convertGraphQLJobToBuildkiteJob(edge.Node)) } } hasMore := resp.Organization.Jobs.PageInfo != nil && resp.Organization.Jobs.PageInfo.HasNextPage nextCursor := (*string)(nil) if hasMore && resp.Organization.Jobs.PageInfo.EndCursor != nil { nextCursor = resp.Organization.Jobs.PageInfo.EndCursor } return jobs, nextCursor, hasMore, nil } func convertGraphQLJobToBuildkiteJob(jobNode *bkGraphQL.ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) buildkite.Job { // Handle the union type - we only care about JobTypeCommand for now switch job := (*jobNode).(type) { case *bkGraphQL.ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand: startedAt := convertTimestamp(job.StartedAt) finishedAt := convertTimestamp(job.FinishedAt) createdAt := convertTimestamp(job.CreatedAt) agent := convertAgent(job.Agent) // Build label (jobs don't have labels in GraphQL, so we use command or empty) label := derefString(job.Command) return buildkite.Job{ ID: job.Id, Type: "script", Name: job.Uuid, // Use UUID as name Label: label, Command: derefString(job.Command), State: mapGraphQLState(string(job.State), derefString(job.ExitStatus)), WebURL: job.Url, StartedAt: startedAt, FinishedAt: finishedAt, CreatedAt: createdAt, Agent: agent, AgentQueryRules: []string{}, // Empty for GraphQL jobs } default: // For non-command jobs, return a minimal job struct return buildkite.Job{ ID: "unknown", Type: "unknown", State: "unknown", } } } func listJobsByAgentQueryRules(ctx context.Context, f *factory.Factory, org string, agentQueryRules []string, cursor *string) ([]buildkite.Job, *string, bool, error) { first := pageSize resp, err := bkGraphQL.ListJobsByAgentQueryRules(ctx, f.GraphQLClient, org, agentQueryRules, &first, cursor) if err != nil { return nil, nil, false, fmt.Errorf("failed to list jobs: %w", err) } if resp.Organization == nil || resp.Organization.Jobs == nil { return []buildkite.Job{}, nil, false, nil } var jobs []buildkite.Job for _, edge := range resp.Organization.Jobs.Edges { if edge.Node != nil { jobs = append(jobs, convertGraphQLAgentQueryRulesJobToBuildkiteJob(edge.Node, agentQueryRules)) } } hasMore := resp.Organization.Jobs.PageInfo != nil && resp.Organization.Jobs.PageInfo.HasNextPage nextCursor := (*string)(nil) if hasMore && resp.Organization.Jobs.PageInfo.EndCursor != nil { nextCursor = resp.Organization.Jobs.PageInfo.EndCursor } return jobs, nextCursor, hasMore, nil } func convertGraphQLAgentQueryRulesJobToBuildkiteJob(jobNode *bkGraphQL.ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob, agentQueryRules []string) buildkite.Job { // Handle the union type - we only care about JobTypeCommand for now var agent buildkite.Agent switch job := (*jobNode).(type) { case *bkGraphQL.ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand: startedAt := convertTimestamp(job.StartedAt) finishedAt := convertTimestamp(job.FinishedAt) createdAt := convertTimestamp(job.CreatedAt) if job.Agent != nil { agent = buildkite.Agent{ ID: job.Agent.Id, Name: job.Agent.Name, Hostname: derefString(job.Agent.Hostname), Metadata: job.Agent.MetaData, } } // Build label (jobs don't have labels in GraphQL, so we use command or empty) label := derefString(job.Command) return buildkite.Job{ ID: job.Id, Type: "script", Name: job.Uuid, // Use UUID as name Label: label, Command: derefString(job.Command), State: mapGraphQLState(string(job.State), derefString(job.ExitStatus)), WebURL: job.Url, StartedAt: startedAt, FinishedAt: finishedAt, CreatedAt: createdAt, Agent: agent, AgentQueryRules: agentQueryRules, } default: // For non-command jobs, return a minimal job struct return buildkite.Job{ ID: "unknown", Type: "unknown", State: "unknown", } } } func convertTimestamp(t *time.Time) *buildkite.Timestamp { if t == nil { return nil } return &buildkite.Timestamp{Time: *t} } func convertAgent(agentNode *bkGraphQL.ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) buildkite.Agent { if agentNode == nil { return buildkite.Agent{} } return buildkite.Agent{ ID: agentNode.Id, Name: agentNode.Name, Hostname: derefString(agentNode.Hostname), Metadata: agentNode.MetaData, } } func derefString(s *string) string { if s == nil { return "" } return *s } // mapGraphQLState converts GraphQL job states to REST API equivalent states func mapGraphQLState(graphqlState, exitStatus string) string { switch graphqlState { case "FINISHED": // For finished jobs, determine success/failure based on exit status if exitStatus == "0" { return "passed" } return "failed" case "RUNNING": return "running" case "SCHEDULED", "ASSIGNED", "ACCEPTED": return "scheduled" case "CANCELED", "CANCELING": return "canceled" case "TIMED_OUT", "TIMING_OUT": return "timed_out" case "SKIPPED": return "skipped" case "BLOCKED": return "blocked" case "WAITING": return "waiting" default: // For unknown states, return lowercase version of GraphQL state return strings.ToLower(graphqlState) } } func jobListOptionsFromFlags(opts *jobListOptions) (*buildkite.BuildsListOptions, error) { listOpts := &buildkite.BuildsListOptions{ ListOptions: buildkite.ListOptions{ PerPage: pageSize, }, } now := time.Now() if opts.since != "" { d, err := time.ParseDuration(opts.since) if err != nil { return nil, fmt.Errorf("invalid since duration '%s': %w", opts.since, err) } listOpts.CreatedFrom = now.Add(-d) } if opts.until != "" { d, err := time.ParseDuration(opts.until) if err != nil { return nil, fmt.Errorf("invalid until duration '%s': %w", opts.until, err) } listOpts.CreatedTo = now.Add(-d) } return listOpts, nil } func getBuildsByPipeline(ctx context.Context, f *factory.Factory, org, pipelineFlag string, listOpts *buildkite.BuildsListOptions) ([]buildkite.Build, error) { pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(pipelineFlag, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), ) pipeline, err := pipelineRes.Resolve(ctx) if err != nil { return nil, err } builds, _, err := f.RestAPIClient.Builds.ListByPipeline(ctx, org, pipeline.Name, listOpts) return builds, err } func applyClientSideFilters(jobs []buildkite.Job, opts jobListOptions) ([]buildkite.Job, error) { if opts.queue == "" && len(opts.state) == 0 && opts.duration == "" { return jobs, nil } var durationOp string var durationThreshold time.Duration var normalizedStates []string if len(opts.state) > 0 { normalizedStates = make([]string, len(opts.state)) for i, state := range opts.state { normalizedStates[i] = strings.ToLower(state) } } if opts.duration != "" { durationOp = ">=" durationStr := opts.duration switch { case strings.HasPrefix(opts.duration, "<"): durationOp = "<" durationStr = opts.duration[1:] case strings.HasPrefix(opts.duration, ">"): durationOp = ">" durationStr = opts.duration[1:] } d, err := time.ParseDuration(durationStr) if err != nil { return nil, fmt.Errorf("invalid duration format: %w", err) } durationThreshold = d } result := make([]buildkite.Job, 0, len(jobs)/2) for i := range jobs { job := &jobs[i] if opts.queue != "" { if !matchesQueue(*job, opts.queue) { continue } } if len(normalizedStates) > 0 { if !containsString(normalizedStates, strings.ToLower(job.State)) { continue } } if opts.duration != "" { if job.StartedAt == nil { continue } var elapsed time.Duration if job.FinishedAt != nil { elapsed = job.FinishedAt.Sub(job.StartedAt.Time) } else { elapsed = time.Since(job.StartedAt.Time) } switch durationOp { case "<": if elapsed >= durationThreshold { continue } case ">": if elapsed <= durationThreshold { continue } default: if elapsed < durationThreshold { continue } } } result = append(result, *job) } return result, nil } func matchesQueue(job buildkite.Job, queueFilter string) bool { for _, rule := range job.AgentQueryRules { if strings.Contains(strings.ToLower(rule), "queue="+strings.ToLower(queueFilter)) { return true } if strings.EqualFold(rule, queueFilter) { return true } } for _, meta := range job.Agent.Metadata { if strings.Contains(strings.ToLower(meta), "queue="+strings.ToLower(queueFilter)) { return true } if strings.EqualFold(meta, queueFilter) { return true } } return false } func sortJobs(jobs []buildkite.Job, orderBy string) []buildkite.Job { if orderBy == "" { return jobs } sort.Slice(jobs, func(i, j int) bool { switch orderBy { case "start_time": if jobs[i].StartedAt == nil && jobs[j].StartedAt == nil { return false } if jobs[i].StartedAt == nil { return false } if jobs[j].StartedAt == nil { return true } return jobs[i].StartedAt.Before(jobs[j].StartedAt.Time) case "duration": durI := getJobDuration(jobs[i]) durJ := getJobDuration(jobs[j]) return durI > durJ default: return false } }) return jobs } func getJobDuration(job buildkite.Job) time.Duration { if job.StartedAt == nil { return 0 } if job.FinishedAt != nil { return job.FinishedAt.Sub(job.StartedAt.Time) } return time.Since(job.StartedAt.Time) } func displayJobs(jobs []buildkite.Job, format output.Format, writer io.Writer) error { if format != output.FormatText { return output.Write(writer, jobs, format) } const ( maxLabelLength = 35 truncatedLength = 32 timeFormat = "2006-01-02T15:04:05Z" ) headers := []string{"State", "Label", "Started (UTC)", "Finished (UTC)", "Duration", "URL"} var rows [][]string for _, job := range jobs { label := job.Label if label == "" { label = job.Name } if len(label) > maxLabelLength { label = label[:truncatedLength] + "..." } startedAt := "-" if job.StartedAt != nil { startedAt = job.StartedAt.Format(timeFormat) } finishedAt := "-" duration := "-" if job.FinishedAt != nil { finishedAt = job.FinishedAt.Format(timeFormat) if job.StartedAt != nil { dur := job.FinishedAt.Sub(job.StartedAt.Time) duration = formatDuration(dur) } } else if job.StartedAt != nil { dur := time.Since(job.StartedAt.Time) duration = formatDuration(dur) + " (running)" } rows = append(rows, []string{ job.State, label, startedAt, finishedAt, duration, job.WebURL, }) } table := output.Table(headers, rows, map[string]string{ "state": "bold", "label": "italic", "started (utc)": "dim", "finished (utc)": "dim", "duration": "bold", "url": "dim", }) fmt.Fprint(writer, table) return nil } func formatDuration(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%.0fs", d.Seconds()) } if d < time.Hour { minutes := d / time.Minute seconds := (d % time.Minute) / time.Second return fmt.Sprintf("%dm%ds", minutes, seconds) } hours := d / time.Hour minutes := (d % time.Hour) / time.Minute return fmt.Sprintf("%dh%dm", hours, minutes) } func containsString(slice []string, item string) bool { for _, s := range slice { if strings.EqualFold(s, item) { return true } } return false } ================================================ FILE: cmd/job/list_test.go ================================================ package job import ( "bytes" "strings" "testing" "time" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestDisplayJobs_EmptyJSON(t *testing.T) { var buf bytes.Buffer err := displayJobs([]buildkite.Job{}, output.FormatJSON, &buf) if err != nil { t.Fatalf("displayJobs failed: %v", err) } got := strings.TrimSpace(buf.String()) if got != "[]" { t.Errorf("Expected empty JSON array '[]', got %q", got) } } func TestDisplayJobs_EmptyYAML(t *testing.T) { var buf bytes.Buffer err := displayJobs([]buildkite.Job{}, output.FormatYAML, &buf) if err != nil { t.Fatalf("displayJobs failed: %v", err) } got := strings.TrimSpace(buf.String()) if got != "[]" { t.Errorf("Expected empty YAML array '[]', got %q", got) } } func TestFilterJobs(t *testing.T) { now := time.Now() jobs := []buildkite.Job{ { ID: "job-1", State: "running", AgentQueryRules: []string{"queue=test-queue"}, StartedAt: &buildkite.Timestamp{Time: now.Add(-5 * time.Minute)}, FinishedAt: &buildkite.Timestamp{Time: now.Add(-4 * time.Minute)}, // 1 minute }, { ID: "job-2", State: "passed", AgentQueryRules: []string{"queue=other-queue"}, StartedAt: &buildkite.Timestamp{Time: now.Add(-30 * time.Minute)}, FinishedAt: &buildkite.Timestamp{Time: now.Add(-10 * time.Minute)}, // 20 minutes }, } opts := jobListOptions{duration: ">10m"} filtered, err := applyClientSideFilters(jobs, opts) if err != nil { t.Fatalf("applyClientSideFilters failed: %v", err) } if len(filtered) != 1 { t.Errorf("Expected 1 job >= 10m, got %d", len(filtered)) } opts = jobListOptions{queue: "test-queue"} filtered, err = applyClientSideFilters(jobs, opts) if err != nil { t.Fatalf("applyClientSideFilters failed: %v", err) } if len(filtered) != 1 { t.Errorf("Expected 1 job with 'test-queue', got %d", len(filtered)) } } ================================================ FILE: cmd/job/log.go ================================================ package job import ( "context" "fmt" "regexp" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type LogCmd struct { JobID string `arg:"" help:"Job UUID to get logs for"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}" short:"p"` BuildNumber string `help:"The build number" short:"b"` NoTimestamps bool `help:"Strip timestamp prefixes from log output" name:"no-timestamps"` } func (c *LogCmd) Help() string { return ` Examples: # Get a job's logs by UUID (requires --pipeline and --build) $ bk job log 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 # If inside a git repository with a configured pipeline $ bk job log 0190046e-e199-453b-a302-a21a4d649d31 -b 123 # Strip timestamp prefixes from output $ bk job log 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 --no-timestamps ` } func (c *LogCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) optionsResolver := options.AggregateResolver{ options.ResolveBranchFromRepository(f.GitRepository), } args := []string{} if c.BuildNumber != "" { args = []string{c.BuildNumber} } buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), ) bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { return fmt.Errorf("no build found") } var logContent string if err = bkIO.SpinWhile(f, "Fetching job log", func() error { jobLog, _, apiErr := f.RestAPIClient.Jobs.GetJobLog( ctx, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobID, ) if apiErr != nil { return apiErr } logContent = jobLog.Content return nil }); err != nil { return err } if c.NoTimestamps { logContent = stripTimestamps(logContent) } writer, cleanup := bkIO.Pager(f.NoPager) defer func() { _ = cleanup() }() fmt.Fprint(writer, logContent) return nil } var timestampRegex = regexp.MustCompile(`bk;t=\d+\x07`) func stripTimestamps(content string) string { return timestampRegex.ReplaceAllString(content, "") } ================================================ FILE: cmd/job/reprioritize.go ================================================ package job import ( "context" "fmt" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" ) type ReprioritizeCmd struct { JobID string `arg:"" help:"Job UUID to reprioritize"` Priority int `arg:"" help:"New priority value for the job"` Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}" short:"p"` BuildNumber string `help:"The build number" short:"b"` } func (c *ReprioritizeCmd) Help() string { return ` Examples: # Reprioritize a job (requires --pipeline and --build) $ bk job reprioritize 0190046e-e199-453b-a302-a21a4d649d31 1 -p my-pipeline -b 123 # If inside a git repository with a configured pipeline $ bk job reprioritize 0190046e-e199-453b-a302-a21a4d649d31 1 -b 123 ` } func (c *ReprioritizeCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), ) optionsResolver := options.AggregateResolver{ options.ResolveBranchFromRepository(f.GitRepository), } args := []string{} if c.BuildNumber != "" { args = []string{c.BuildNumber} } buildRes := buildResolver.NewAggregateResolver( buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), ) bld, err := buildRes.Resolve(ctx) if err != nil { return err } if bld == nil { return fmt.Errorf("no build found") } var job buildkite.Job if err = bkIO.SpinWhile(f, "Reprioritizing job", func() error { var apiErr error job, _, apiErr = f.RestAPIClient.Jobs.ReprioritizeJob( ctx, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobID, &buildkite.JobReprioritizationOptions{ Priority: c.Priority, }, ) return apiErr }); err != nil { return err } fmt.Printf("Job reprioritized to %d: %s\n", c.Priority, job.WebURL) return nil } ================================================ FILE: cmd/job/retry.go ================================================ package job import ( "context" "fmt" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkGraphQL "github.com/buildkite/cli/v3/internal/graphql" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) const jobCommandPrefix = "JobTypeCommand---" type RetryCmd struct { JobID string `arg:"" help:"Job UUID to retry"` } func (c *RetryCmd) Help() string { return `Use this command to retry build jobs. Examples: # Retry a job by UUID $ bk job retry 0190046e-e199-453b-a302-a21a4d649d31 ` } func (c *RetryCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } // Given a job UUID argument, we need to generate the GraphQL ID matching graphqlID := util.GenerateGraphQLID(jobCommandPrefix, c.JobID) ctx := context.Background() var j *bkGraphQL.RetryJobResponse if err = bkIO.SpinWhile(f, "Retrying job", func() error { j, err = bkGraphQL.RetryJob(ctx, f.GraphQLClient, graphqlID) return err }); err != nil { return err } // Fixes segfault when error is returned, e.g. "Jobs from canceled builds cannot be retried" if j == nil || j.JobTypeCommandRetry == nil { return fmt.Errorf("failed to retry job") } fmt.Println("Successfully retried job: " + j.JobTypeCommandRetry.JobTypeCommand.Url) return nil } ================================================ FILE: cmd/job/unblock.go ================================================ package job import ( "context" "errors" "fmt" "io" "os" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkGraphQL "github.com/buildkite/cli/v3/internal/graphql" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/vektah/gqlparser/v2/gqlerror" ) const jobBlockPrefix = "JobTypeBlock---" type UnblockCmd struct { JobID string `arg:"" help:"Job UUID to unblock"` Data string `help:"JSON formatted data to unblock the job"` } func (c *UnblockCmd) Help() string { return ` Unblock a job. Use this command to unblock build jobs. Currently, this does not support submitting fields to the step. Examples: # Unblock a job by UUID $ bk job unblock 0190046e-e199-453b-a302-a21a4d649d31 # Unblock with JSON data $ bk job unblock 0190046e-e199-453b-a302-a21a4d649d31 --data '{"field": "value"}' # Unblock with data from stdin $ echo '{"field": "value"}' | bk job unblock 0190046e-e199-453b-a302-a21a4d649d31 ` } func (c *UnblockCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } // Given a job UUID argument, we need to generate the GraphQL ID matching graphqlID := util.GenerateGraphQLID(jobBlockPrefix, c.JobID) // Get unblock step fields if available var fields *string if bkIO.HasDataAvailable(os.Stdin) { stdin := new(strings.Builder) _, err := io.Copy(stdin, os.Stdin) if err != nil { return err } input := stdin.String() fields = &input } else if c.Data != "" { fields = &c.Data } else { // The GraphQL API errors if providing a null fields value so we need to provide an empty json object input := "{}" fields = &input } ctx := context.Background() var result *bkGraphQL.UnblockJobResponse err = bkIO.SpinWhile(f, "Unblocking job", func() error { result, err = bkGraphQL.UnblockJob(ctx, f.GraphQLClient, graphqlID, fields) return err }) if err != nil { // Handle a "graphql error" if the job is already unblocked var errList gqlerror.List if errors.As(err, &errList) { for _, gqlErr := range errList { if gqlErr.Message == "The job's state must be blocked" { fmt.Println("This job is already unblocked") return nil } } } return err } if err := validateUnblockResponse(result); err != nil { return err } fmt.Println("Successfully unblocked job") return nil } func validateUnblockResponse(result *bkGraphQL.UnblockJobResponse) error { if result == nil || result.JobTypeBlockUnblock == nil { return fmt.Errorf("failed to unblock job") } return nil } ================================================ FILE: cmd/job/unblock_test.go ================================================ package job import ( "testing" bkGraphQL "github.com/buildkite/cli/v3/internal/graphql" ) func TestValidateUnblockResponse(t *testing.T) { t.Parallel() tests := []struct { name string input *bkGraphQL.UnblockJobResponse wantErr bool }{ { name: "nil response", input: nil, wantErr: true, }, { name: "nil payload", input: &bkGraphQL.UnblockJobResponse{ JobTypeBlockUnblock: nil, }, wantErr: true, }, { name: "successful unblock", input: &bkGraphQL.UnblockJobResponse{ JobTypeBlockUnblock: &bkGraphQL.UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload{ JobTypeBlock: bkGraphQL.UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock{ State: bkGraphQL.JobStatesUnblocked, }, }, }, wantErr: false, }, { name: "non-nil payload with finished state", input: &bkGraphQL.UnblockJobResponse{ JobTypeBlockUnblock: &bkGraphQL.UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload{ JobTypeBlock: bkGraphQL.UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock{ State: bkGraphQL.JobStatesFinished, }, }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() err := validateUnblockResponse(tt.input) if (err != nil) != tt.wantErr { t.Errorf("validateUnblockResponse() error = %v, wantErr %v", err, tt.wantErr) } }) } } ================================================ FILE: cmd/maintainer/create.go ================================================ package maintainer import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type CreateCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID to add maintainer to" name:"cluster-uuid"` User string `help:"User UUID to add as maintainer" optional:"" xor:"actor"` Team string `help:"Team UUID to add as maintainer" optional:"" xor:"actor"` output.OutputFlags } func (c *CreateCmd) Help() string { return ` Create a cluster maintainer. Either --user or --team must be specified. Examples: # Create a user maintainer assignment $ bk maintainer create my-cluster-uuid --user user-uuid # Create a team maintainer assignment $ bk maintainer create my-cluster-uuid --team team-uuid ` } func (c *CreateCmd) Validate() error { if c.User == "" && c.Team == "" { return fmt.Errorf("either --user or --team must be specified") } return nil } func (c *CreateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() input := buildkite.ClusterMaintainer{} if c.User != "" { input.UserID = c.User } else { input.TeamID = c.Team } var maintainer buildkite.ClusterMaintainerEntry if err = bkIO.SpinWhile(f, "Creating cluster maintainer", func() error { var apiErr error maintainer, _, apiErr = f.RestAPIClient.ClusterMaintainers.Create(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, input) return apiErr }); err != nil { return fmt.Errorf("error creating cluster maintainer: %v", err) } maintainerView := output.Viewable[buildkite.ClusterMaintainerEntry]{ Data: maintainer, Render: renderMaintainerText, } if format != output.FormatText { return output.Write(os.Stdout, maintainerView, format) } fmt.Fprintf(os.Stdout, "Maintainer created successfully\n\n") writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() return output.Write(writer, maintainerView, format) } func renderMaintainerText(m buildkite.ClusterMaintainerEntry) string { name := m.Actor.Slug if m.Actor.Name != "" { name = m.Actor.Name } rows := [][]string{ {"Assignment ID", output.ValueOrDash(m.ID)}, {"Actor ID", output.ValueOrDash(m.Actor.ID)}, {"Type", output.ValueOrDash(m.Actor.Type)}, {"Name", output.ValueOrDash(name)}, } table := output.Table( []string{"Field", "Value"}, rows, map[string]string{"field": "dim", "value": "italic"}, ) var sb strings.Builder fmt.Fprintf(&sb, "Maintainer assignment %s\n\n", output.ValueOrDash(m.ID)) sb.WriteString(table) return sb.String() } ================================================ FILE: cmd/maintainer/delete.go ================================================ package maintainer import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type DeleteCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID" name:"cluster-uuid"` MaintainerID string `arg:"" help:"Maintainer assignment ID to delete" name:"maintainer-id"` } func (c *DeleteCmd) Help() string { return ` Delete a cluster maintainer. You will be prompted to confirm deletion unless --yes is set. Examples: # Delete a maintainer assignment (with confirmation prompt) $ bk maintainer delete my-cluster-uuid maintainer-id # Delete without confirmation $ bk maintainer delete my-cluster-uuid maintainer-id --yes # Use list to find maintainer assignment IDs $ bk maintainer list my-cluster-uuid ` } func (c *DeleteCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() confirmed, err := bkIO.Confirm(f, fmt.Sprintf("Are you sure you want to delete maintainer %s?", c.MaintainerID)) if err != nil { return err } if !confirmed { fmt.Fprintln(os.Stderr, "Deletion cancelled.") return nil } if err = bkIO.SpinWhile(f, "Deleting cluster maintainer", func() error { _, apiErr := f.RestAPIClient.ClusterMaintainers.Delete(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.MaintainerID) return apiErr }); err != nil { return fmt.Errorf("error deleting cluster maintainer: %v", err) } fmt.Fprintln(os.Stderr, "Maintainer deleted successfully.") return nil } ================================================ FILE: cmd/maintainer/list.go ================================================ package maintainer import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ListCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID to list maintainers for" name:"cluster-uuid"` output.OutputFlags } func (c *ListCmd) Help() string { return ` List cluster maintainers. Examples: # List all maintainers for a cluster $ bk maintainer list my-cluster-uuid # List in JSON format $ bk maintainer list my-cluster-uuid -o json ` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var maintainers []buildkite.ClusterMaintainerEntry if err = bkIO.SpinWhile(f, "Fetching cluster maintainers", func() error { var apiErr error maintainers, _, apiErr = f.RestAPIClient.ClusterMaintainers.List(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, nil) return apiErr }); err != nil { return fmt.Errorf("error fetching cluster maintainers: %v", err) } if format != output.FormatText { return output.Write(os.Stdout, maintainers, format) } if len(maintainers) == 0 { fmt.Fprintln(os.Stdout, "No maintainers found") return nil } rows := make([][]string, 0, len(maintainers)) for _, m := range maintainers { name := m.Actor.Slug if m.Actor.Name != "" { name = m.Actor.Name } rows = append(rows, []string{m.ID, m.Actor.ID, m.Actor.Type, name}) } table := output.Table( []string{"Assignment ID", "Actor ID", "Type", "Name"}, rows, map[string]string{"assignment id": "bold", "name": "italic"}, ) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() _, err = fmt.Fprintf(writer, "Maintainers (%d)\n\n%s\n", len(maintainers), table) return err } ================================================ FILE: cmd/maintainer/maintainer_test.go ================================================ package maintainer import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestListMaintainers(t *testing.T) { t.Parallel() t.Run("fetches maintainers through API", func(t *testing.T) { t.Parallel() maintainers := []buildkite.ClusterMaintainerEntry{ { ID: "maintainer-1", Actor: buildkite.ClusterMaintainerActor{ Type: "user", Name: "Jurgen Klopp", }, }, { ID: "maintainer-2", Actor: buildkite.ClusterMaintainerActor{ Type: "team", Slug: "platform", }, }, } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters/cluster-123/maintainers") { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(maintainers) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterMaintainers.List(context.Background(), "test-org", "cluster-123", nil) if err != nil { t.Fatal(err) } if len(result) != 2 { t.Fatalf("expected 2 maintainers, got %d", len(result)) } if result[0].Actor.Name != "Jurgen Klopp" { t.Errorf("expected name 'Jurgen Klopp', got %q", result[0].Actor.Name) } if result[1].Actor.Slug != "platform" { t.Errorf("expected slug 'platform', got %q", result[1].Actor.Slug) } }) t.Run("empty result returns empty slice", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]buildkite.ClusterMaintainerEntry{}) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterMaintainers.List(context.Background(), "test-org", "cluster-123", nil) if err != nil { t.Fatal(err) } if len(result) != 0 { t.Errorf("expected 0 maintainers, got %d", len(result)) } }) } func TestCreateMaintainer(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters/cluster-123/maintainers") { t.Errorf("unexpected path: %s", r.URL.Path) } var input buildkite.ClusterMaintainer if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Fatal(err) } if input.UserID != "user-123" { t.Errorf("expected user id 'user-123', got %q", input.UserID) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(buildkite.ClusterMaintainerEntry{ ID: "maintainer-123", Actor: buildkite.ClusterMaintainerActor{ Type: "user", Name: "Jurgen Klopp", }, }) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterMaintainers.Create(context.Background(), "test-org", "cluster-123", buildkite.ClusterMaintainer{UserID: "user-123"}) if err != nil { t.Fatal(err) } if result.ID != "maintainer-123" { t.Errorf("expected ID 'maintainer-123', got %q", result.ID) } if result.Actor.Type != "user" { t.Errorf("expected actor type 'user', got %q", result.Actor.Type) } } func TestDeleteMaintainer(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { t.Errorf("expected DELETE, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/maintainers/maintainer-123") { t.Errorf("unexpected path: %s", r.URL.Path) } w.WriteHeader(http.StatusNoContent) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } _, err = client.ClusterMaintainers.Delete(context.Background(), "test-org", "cluster-123", "maintainer-123") if err != nil { t.Fatal(err) } } ================================================ FILE: cmd/organization/list.go ================================================ package organization import ( "fmt" "os" "slices" "strconv" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/output" ) type ListCmd struct { output.OutputFlags } type Organization struct { Slug string `json:"slug" yaml:"slug"` Selected bool `json:"selected" yaml:"selected"` } func (c *ListCmd) Help() string { return `List configured organizations. Examples: # List all configured organizations (JSON by default) $ bk organization list # List organizations in text format $ bk organization list -o text ` } func (c *ListCmd) Run(globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.NoPager = f.NoPager || globals.DisablePager() format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) orgs := f.Config.ConfiguredOrganizations() if len(orgs) == 0 { return output.WriteTextOrStructured(os.Stdout, format, []Organization{}, "No organizations configured. Run `bk configure` to add one.") } slices.Sort(orgs) selectedOrg := f.Config.OrganizationSlug() organizations := make([]Organization, len(orgs)) for i, org := range orgs { organizations[i] = Organization{ Slug: org, Selected: org == selectedOrg, } } if format != output.FormatText { return output.Write(os.Stdout, organizations, format) } rows := make([][]string, 0, len(organizations)) for _, org := range organizations { rows = append(rows, []string{org.Slug, strconv.FormatBool(org.Selected)}) } table := output.Table( []string{"Organization Slug", "Selected"}, rows, map[string]string{"organization slug": "bold", "selected": "italic"}, ) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "Showing configured organization(s)\n\n%s\n", table) return nil } ================================================ FILE: cmd/pipeline/convert.go ================================================ package pipeline import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "slices" "strings" "time" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) const convertEndpoint = "https://m4vrh5pvtd.execute-api.us-east-1.amazonaws.com/production/migrate" type conversionRequest struct { Vendor string `json:"vendor"` Code string `json:"code"` } type conversionResponse struct { JobID string `json:"jobId"` Status string `json:"status"` Message string `json:"message"` StatusURL string `json:"statusUrl"` } type statusResponse struct { JobID string `json:"jobId"` Status string `json:"status"` Vendor string `json:"vendor"` CreatedAt string `json:"createdAt"` CompletedAt string `json:"completedAt,omitempty"` Result string `json:"result,omitempty"` Error string `json:"error,omitempty"` } type ConvertCmd struct { File string `help:"Path to the pipeline file to convert (required)" short:"F" required:""` Vendor string `help:"CI/CD vendor (auto-detected if the file name matches vendor path and name - otherwise, needs to be specified)" short:"v"` Output string `help:"Custom path to save the converted pipeline (default: .buildkite/pipeline.<vendor>.yml)" short:"o"` Timeout int `help:"The time (in seconds) after which a conversion should be cancelled" default:"300"` } func (c *ConvertCmd) Help() string { return ` Supported vendors: - github (GitHub Actions) - bitbucket (Bitbucket Pipelines) - circleci (CircleCI) - jenkins (Jenkins) - gitlab (GitLab CI) (beta) - harness (Harness CI) (beta) - bitrise (Bitrise) (beta) The command will automatically detect the vendor based on the file path and name if not specified. When using --file, the converted pipeline is saved to .buildkite/pipeline.<vendor>.yml by default. When reading from stdin, output goes to stdout by default. Use the --output flag to specify a custom output path in either case. Note: This command does not require an API token since it uses a public conversion API. Examples: # Convert a GitHub Actions workflow $ bk pipeline convert -F .github/workflows/ci.yml # Convert with explicit vendor specification $ bk pipeline convert -F pipeline.yml --vendor circleci # Save output to a file $ bk pipeline convert -F .github/workflows/ci.yml -o .buildkite/pipeline.yml # Read from stdin $ cat .github/workflows/ci.yml | bk pipeline convert --vendor github $ bk pipeline convert --vendor github < .github/workflows/ci.yml ` } func (c *ConvertCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() fromStdin := c.File == "" var content []byte if fromStdin { if !bkIO.HasDataAvailable(os.Stdin) { return errors.New("no input: provide a file with --file or pipe content via stdin") } if c.Vendor == "" { return errors.New("--vendor is required when reading from stdin") } content, err = io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("error reading stdin: %w", err) } } else { content, err = os.ReadFile(c.File) if err != nil { return fmt.Errorf("error reading file: %w", err) } } if c.Vendor == "" { c.Vendor, err = detectVendor(c.File) if err != nil { return err } fmt.Printf("Detected vendor: %s\n", c.Vendor) } supportedVendors := []string{"github", "bitbucket", "circleci", "jenkins", "gitlab", "harness", "bitrise"} if !slices.Contains(supportedVendors, c.Vendor) { return fmt.Errorf("unsupported vendor: %s (supported: %s)", c.Vendor, strings.Join(supportedVendors, ", ")) } if c.Timeout < 1 { return errors.New("a timeout cannot be less than 1 second") } req := conversionRequest{ Vendor: c.Vendor, Code: string(content), } fmt.Println("Submitting conversion job...") jobResp, err := submitConversionJob(req) if err != nil { return fmt.Errorf("error submitting conversion job: %w", err) } fmt.Println("Job submitted. Processing with AI (this may take several minutes)...") var result *statusResponse if err = bkIO.SpinWhile(f, "Processing conversion...", func() error { var pollErr error result, pollErr = pollJobStatus(jobResp.JobID, c.Timeout) return pollErr }); err != nil { return fmt.Errorf("error polling job status: %w", err) } if result.Status == "failed" { return fmt.Errorf("conversion failed: %s", result.Error) } if c.Output != "" { if err := os.WriteFile(c.Output, []byte(result.Result), 0o644); err != nil { return fmt.Errorf("error writing output file: %w", err) } fmt.Printf("\n✅ conversion completed successfully!\n") fmt.Printf("Output saved to: %s\n", c.Output) } else if fromStdin { fmt.Print(result.Result) } else { buildkiteDir := ".buildkite" if err := os.MkdirAll(buildkiteDir, 0o755); err != nil { return fmt.Errorf("error creating .buildkite directory: %w", err) } outputFilename := fmt.Sprintf("pipeline.%s.yml", c.Vendor) defaultOutputPath := filepath.Join(buildkiteDir, outputFilename) if err := os.WriteFile(defaultOutputPath, []byte(result.Result), 0o644); err != nil { return fmt.Errorf("error writing output file: %w", err) } fmt.Printf("\n✅ conversion completed successfully!\n") fmt.Printf("Output saved to: %s\n", defaultOutputPath) } return nil } func detectVendor(filePath string) (string, error) { fileName := filepath.Base(filePath) if strings.Contains(filePath, ".github/workflows") || strings.Contains(filePath, ".github\\workflows") { return "github", nil } if fileName == "bitbucket-pipelines.yml" || fileName == "bitbucket-pipelines.yaml" { return "bitbucket", nil } if strings.Contains(filePath, ".circleci") { return "circleci", nil } if fileName == "Jenkinsfile" || strings.HasPrefix(fileName, "Jenkinsfile.") { return "jenkins", nil } return "", fmt.Errorf("could not detect vendor from file path. Please specify vendor explicitly with --vendor") } func submitConversionJob(req conversionRequest) (*conversionResponse, error) { return submitConversionJobAtEndpoint(convertEndpoint, req) } func submitConversionJobAtEndpoint(endpoint string, req conversionRequest) (*conversionResponse, error) { reqBody, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("error marshaling request: %w", err) } httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpoint, bytes.NewReader(reqBody)) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("error making request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error reading response: %w", err) } if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) } var jobResp conversionResponse if err := json.Unmarshal(body, &jobResp); err != nil { return nil, fmt.Errorf("error parsing response: %w", err) } return &jobResp, nil } func pollJobStatus(jobID string, timeoutSeconds int) (*statusResponse, error) { maxAttempts := timeoutSeconds / 5 if maxAttempts < 1 { maxAttempts = 1 } return pollJobStatusWithConfig(jobID, pollConfig{ endpoint: convertEndpoint, client: &http.Client{Timeout: 30 * time.Second}, maxAttempts: maxAttempts, interval: 5 * time.Second, timeout: time.Duration(timeoutSeconds) * time.Second, }) } type pollConfig struct { endpoint string client *http.Client maxAttempts int interval time.Duration timeout time.Duration } func pollJobStatusWithConfig(jobID string, cfg pollConfig) (*statusResponse, error) { if cfg.endpoint == "" { cfg.endpoint = convertEndpoint } if cfg.client == nil { cfg.client = &http.Client{Timeout: 30 * time.Second} } if cfg.maxAttempts < 1 { cfg.maxAttempts = 1 } if cfg.interval < 0 { cfg.interval = 0 } if cfg.timeout < 0 { cfg.timeout = 0 } statusURL := fmt.Sprintf("%s/%s/status", cfg.endpoint, jobID) for attempt := 0; attempt < cfg.maxAttempts; attempt++ { if attempt > 0 && cfg.interval > 0 { time.Sleep(cfg.interval) } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, statusURL, nil) if err != nil { return nil, fmt.Errorf("error creating status request: %w", err) } resp, err := cfg.client.Do(req) if err != nil { return nil, fmt.Errorf("error checking status: %w", err) } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("error reading status response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("status check failed (status %d): %s", resp.StatusCode, string(body)) } var status statusResponse if err := json.Unmarshal(body, &status); err != nil { return nil, fmt.Errorf("error parsing status response: %w", err) } if status.Status == "completed" { return &status, nil } if status.Status == "failed" { return &status, nil } } if cfg.timeout > 0 { return nil, fmt.Errorf("conversion timed out after %d seconds", int(cfg.timeout/time.Second)) } return nil, fmt.Errorf("conversion timed out after %d attempts", cfg.maxAttempts) } ================================================ FILE: cmd/pipeline/convert_test.go ================================================ package pipeline import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "testing" "time" ) const conversionIntegrationTestsEnv = "BK_RUN_INTEGRATION_TESTS" func TestConversionAPIEndpoint(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } if os.Getenv(conversionIntegrationTestsEnv) == "" { t.Skipf("Skipping external conversion API test; set %s=1 to run it", conversionIntegrationTestsEnv) } // Create a simple GitHub Actions workflow for testing testWorkflow := `name: Test on: [push] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - run: echo "Hello World" ` // Submit a Conversion job req := conversionRequest{ Vendor: "github", Code: testWorkflow, } jobResp, err := submitConversionJob(req) if err != nil { t.Fatalf("Conversion API endpoint is not accessible or broken. This will break the CLI for users. Error: %v", err) } if jobResp.JobID == "" { t.Error("Expected job ID to be returned") } if jobResp.Status != "processing" && jobResp.Status != "queued" { t.Errorf("Expected status to be 'processing' or 'queued', got: %s", jobResp.Status) } // Poll for completion with a reasonable timeout result, err := pollJobStatus(jobResp.JobID, 60) if err != nil { t.Fatalf("Failed to poll job status: %v", err) } if result.Status == "failed" { t.Errorf("Conversion failed: %s", result.Error) } if result.Status != "completed" { t.Errorf("Expected status to be 'completed', got: %s", result.Status) } if result.Result == "" { t.Error("Expected result to contain migrated pipeline YAML") } // Verify the result is valid YAML if !strings.Contains(result.Result, "steps:") { t.Errorf("Expected result to contain 'steps:', got: %s", result.Result) } } func TestDetectVendor(t *testing.T) { t.Parallel() tests := []struct { name string filePath string wantVendor string wantErr bool }{ { name: "GitHub Actions workflow", filePath: ".github/workflows/ci.yml", wantVendor: "github", wantErr: false, }, { name: "GitHub Actions workflow (Windows path)", filePath: ".github\\workflows\\ci.yml", wantVendor: "github", wantErr: false, }, { name: "Bitbucket Pipelines", filePath: "bitbucket-pipelines.yml", wantVendor: "bitbucket", wantErr: false, }, { name: "Bitbucket Pipelines (yaml extension)", filePath: "bitbucket-pipelines.yaml", wantVendor: "bitbucket", wantErr: false, }, { name: "CircleCI config", filePath: ".circleci/config.yml", wantVendor: "circleci", wantErr: false, }, { name: "Jenkins file", filePath: "Jenkinsfile", wantVendor: "jenkins", wantErr: false, }, { name: "Jenkins file with extension", filePath: "Jenkinsfile.production", wantVendor: "jenkins", wantErr: false, }, { name: "Unknown file", filePath: "some-random-file.yml", wantErr: true, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() vendor, err := detectVendor(tt.filePath) if tt.wantErr { if err == nil { t.Error("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } if vendor != tt.wantVendor { t.Errorf("Expected vendor %q, got %q", tt.wantVendor, vendor) } }) } } func TestContains(t *testing.T) { t.Parallel() tests := []struct { name string slice []string str string want bool }{ { name: "string present", slice: []string{"github", "bitbucket", "circleci"}, str: "github", want: true, }, { name: "string not present", slice: []string{"github", "bitbucket", "circleci"}, str: "jenkins", want: false, }, { name: "empty slice", slice: []string{}, str: "github", want: false, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got := slices.Contains(tt.slice, tt.str) if got != tt.want { t.Errorf("Expected %v, got %v", tt.want, got) } }) } } func TestSubmitConversionJob(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("Expected POST request, got %s", r.Method) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type: application/json, got %s", r.Header.Get("Content-Type")) } var req conversionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("Failed to decode request body: %v", err) w.WriteHeader(http.StatusBadRequest) return } if req.Vendor == "" || req.Code == "" { t.Error("Expected vendor and code fields") w.WriteHeader(http.StatusBadRequest) return } resp := conversionResponse{ JobID: "test-job-123", Status: "processing", Message: "Conversion job queued for processing", StatusURL: "https://example.com/migrate/test-job-123/status", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(resp) })) defer server.Close() req := conversionRequest{ Vendor: "github", Code: "name: Test\non: [push]", } resp, err := submitConversionJobAtEndpoint(server.URL, req) if err != nil { t.Fatalf("Failed to submit Conversion job: %v", err) } if resp.JobID != "test-job-123" { t.Errorf("Expected job ID 'test-job-123', got %q", resp.JobID) } if resp.Status != "processing" { t.Errorf("Expected status 'processing', got %q", resp.Status) } } func TestPollJobStatus(t *testing.T) { t.Parallel() attempt := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempt++ var status string var result string // First attempt returns "processing", second returns "completed" if attempt == 1 { status = "processing" } else { status = "completed" result = "steps:\n - command: echo 'test'\n" } resp := statusResponse{ JobID: "test-job-123", Status: status, Vendor: "github", CreatedAt: time.Now().Format(time.RFC3339), CompletedAt: time.Now().Format(time.RFC3339), Result: result, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) })) defer server.Close() result, err := pollJobStatusWithConfig("test-job-123", pollConfig{ endpoint: server.URL, client: server.Client(), maxAttempts: 2, interval: 0, }) if err != nil { t.Fatalf("Failed to poll job status: %v", err) } if result.Status != "completed" { t.Errorf("Expected status 'completed', got %q", result.Status) } if result.Result == "" { t.Error("Expected result to be populated") } } func TestPollJobStatusTimeout(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := statusResponse{ JobID: "test-job-123", Status: "processing", Vendor: "github", CreatedAt: time.Now().Format(time.RFC3339), } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) })) defer server.Close() result, err := pollJobStatusWithConfig("test-job-123", pollConfig{ endpoint: server.URL, client: server.Client(), maxAttempts: 1, interval: 0, }) if err == nil { t.Error("Expected timeout error but got none") return } if result != nil { t.Error("Expected nil result on timeout") } if !strings.Contains(err.Error(), "timed out") { t.Errorf("Expected timeout error, got: %v", err) } } func TestMigrateCommandCreation(t *testing.T) { t.Parallel() cmd := &ConvertCmd{ File: "test.yml", Vendor: "github", Timeout: 300, } if cmd.File != "test.yml" { t.Errorf("Expected File to be 'test.yml', got %q", cmd.File) } if cmd.Vendor != "github" { t.Errorf("Expected Vendor to be 'github', got %q", cmd.Vendor) } if cmd.Timeout != 300 { t.Errorf("Expected Timeout to be 300, got %d", cmd.Timeout) } } func TestMigrateAutoDetection(t *testing.T) { t.Parallel() tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, ".github", "workflows", "test.yml") if err := os.MkdirAll(filepath.Dir(testFile), 0o755); err != nil { t.Fatalf("Failed to create test directory: %v", err) } testWorkflow := `name: Test on: [push] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - run: echo "Test" ` if err := os.WriteFile(testFile, []byte(testWorkflow), 0o644); err != nil { t.Fatalf("Failed to write test file: %v", err) } vendor, err := detectVendor(testFile) if err != nil { t.Fatalf("Failed to detect vendor: %v", err) } if vendor != "github" { t.Errorf("Expected vendor to be 'github', got %q", vendor) } } ================================================ FILE: cmd/pipeline/copy.go ================================================ package pipeline import ( "context" "fmt" "net/http" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type CopyCmd struct { Pipeline string `arg:"" help:"Source pipeline to copy (slug or org/slug). Uses current pipeline if not specified." optional:""` Org string `help:"Organization slug" name:"org"` Target string `help:"Name for the new pipeline, or org/name to copy to a different organization" short:"t"` ClusterUUID string `help:"Cluster UUID for the new pipeline" name:"cluster-uuid"` ClusterName string `help:"Cluster name for the new pipeline (resolved to UUID)" name:"cluster-name"` ClusterShorthand string `short:"c" hidden:"" name:"c" help:""` DryRun bool `help:"Show what would be copied without creating the pipeline"` output.OutputFlags } // we store the target organization and pipeline name for a future go-buildkite call type copyTarget struct { Org string Name string } func (c *CopyCmd) orgSlug(f *factory.Factory) string { if c.Org != "" { return c.Org } return f.Config.OrganizationSlug() } func (c *CopyCmd) Validate() error { if c.ClusterShorthand != "" { return fmt.Errorf("-c is no longer supported for cluster; use --cluster-uuid or --cluster-name instead") } if c.ClusterUUID != "" && c.ClusterName != "" { return fmt.Errorf("only one of --cluster-uuid or --cluster-name can be specified") } return nil } func (c *CopyCmd) Help() string { return `Copy an existing pipeline's configuration to create a new pipeline. This command copies all configuration from a source pipeline including: - Pipeline steps (YAML configuration) - Repository settings - Branch configuration - Build skipping/cancellation rules - Provider settings (trigger mode, PR builds, commit statuses, etc.) - Environment variables - Tags and visibility When copying to a different organization, cluster configuration is skipped (clusters are organization-specific). Examples: # Copy the current pipeline to a new pipeline $ bk pipeline cp --target "my-pipeline-v2" # Copy a specific pipeline $ bk pipeline cp my-existing-pipeline --target "my-new-pipeline" # Copy a pipeline from another org (if you have access) $ bk pipeline cp other-org/their-pipeline --target "my-copy" # Copy to a different organization $ bk pipeline cp my-pipeline --target "other-org/my-pipeline" --cluster-uuid "8302f0b-9b99-4663-23f3-2d64f88s693e" # Copy to a different organization using cluster name $ bk pipeline cp my-pipeline --target "other-org/my-pipeline" --cluster-name "my-cluster" # Interactive mode - prompts for source and target $ bk pipeline cp # Preview what would be copied without creating $ bk pipeline cp my-pipeline --target "copy" --dry-run # Output the new pipeline details as JSON $ bk pipeline cp my-pipeline -t "new-pipeline" -o json ` } func (c *CopyCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithOrgOverride(c.Org)) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfigurationForOrg(f.Config, kongCtx.Command(), c.Org); err != nil { return err } ctx := context.Background() // Resolve source pipeline // looks at current project if no source provided, or tries to resolve it using the current selected org sourcePipeline, err := c.resolveSourcePipeline(ctx, f) if err != nil { return err } // Get target org and name // Spoiler: we use `/` as an indicator for org/pipeline split target, err := c.resolveTarget(f, sourcePipeline.Name) if err != nil { return err } source, err := c.fetchSourcePipeline(ctx, f, sourcePipeline.Org, sourcePipeline.Name) if err != nil { return err } // Determine if this is a cross-org copy isCrossOrg := target.Org != sourcePipeline.Org // Resolve cluster ID - required for cross-org copies clusterID, err := c.resolveCluster(ctx, f, target.Org, source.ClusterID, isCrossOrg) if err != nil { return err } if c.DryRun { return c.runDryRun(kongCtx, f, source, target, isCrossOrg, clusterID) } return c.runCopy(kongCtx, f, source, target, isCrossOrg, clusterID) } func (c *CopyCmd) resolveSourcePipeline(ctx context.Context, f *factory.Factory) (*pipeline.Pipeline, error) { var args []string if c.Pipeline != "" { args = []string{c.Pipeline} } picker := resolver.PickOneWithFactory(f) cachedPicker := resolver.CachedPicker(f.Config, picker) repositoryResolver := resolver.ResolveFromRepository(f, cachedPicker) if c.Org != "" { repositoryResolver = resolver.ResolveFromRepositoryInOrg(f, cachedPicker, c.Org) } pipelineRes := resolver.NewAggregateResolver( resolver.WithOrg(c.Org, resolver.ResolveFromPositionalArgument(args, 0, f.Config)), resolver.WithOrg(c.Org, resolver.ResolveFromConfig(f.Config, picker)), repositoryResolver, ) p, err := pipelineRes.Resolve(ctx) if err != nil { return nil, fmt.Errorf("could not resolve source pipeline, ensure correct config is in use (`bk org ls`): %w", err) } return p, nil } func (c *CopyCmd) resolveTarget(f *factory.Factory, sourceName string) (*copyTarget, error) { targetStr := c.Target if targetStr == "" { // Interactive prompt for target name defaultName := fmt.Sprintf("%s-copy", sourceName) var err error targetStr, err = bkIO.PromptForInput("Target pipeline (or org/pipeline)", defaultName, f.NoInput) if err != nil { return nil, err } } // Parse target - could be "name" or "org/name" // we check to see if `/` is present for org name, if not we use the existing org selected return parseTarget(targetStr, c.orgSlug(f)), nil } // parseTarget parses a target string into org and name components. // If no org is specified, defaultOrg is used which is the current selected org. func parseTarget(target, defaultOrg string) *copyTarget { if strings.Contains(target, "/") { parts := strings.SplitN(target, "/", 2) return ©Target{ Org: parts[0], Name: parts[1], } } return ©Target{ Org: defaultOrg, Name: target, } } func (c *CopyCmd) resolveCluster(ctx context.Context, f *factory.Factory, targetOrg, sourceClusterID string, isCrossOrg bool) (string, error) { if c.ClusterUUID != "" { return c.ClusterUUID, nil } if c.ClusterName != "" { return resolveClusterName(ctx, f, targetOrg, c.ClusterName) } if !isCrossOrg { return sourceClusterID, nil } return bkIO.PromptForInput("Target cluster UUID (required for cross-org copy)", "", f.NoInput) } func (c *CopyCmd) fetchSourcePipeline(ctx context.Context, f *factory.Factory, org, slug string) (*buildkite.Pipeline, error) { var pipeline buildkite.Pipeline var resp *buildkite.Response var err error if err = bkIO.SpinWhile(f, fmt.Sprintf("Fetching pipeline %s/%s", org, slug), func() error { pipeline, resp, err = f.RestAPIClient.Pipelines.Get(ctx, org, slug) return err }); err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("pipeline %s/%s not found", org, slug) } return nil, fmt.Errorf("failed to fetch pipeline: %w", err) } return &pipeline, nil } // runDryRun allows a user to validate what their changes will do, based on the current `--dry-run` flag in Create func (c *CopyCmd) runDryRun(kongCtx *kong.Context, f *factory.Factory, source *buildkite.Pipeline, target *copyTarget, isCrossOrg bool, clusterID string) error { format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID) // For dry-run, default to JSON if text format requested if format == output.FormatText { format = output.FormatJSON } return output.Write(kongCtx.Stdout, createReq, format) } func (c *CopyCmd) runCopy(kongCtx *kong.Context, f *factory.Factory, source *buildkite.Pipeline, target *copyTarget, isCrossOrg bool, clusterID string) error { ctx := context.Background() format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) // For cross-org copies, we need a client authenticated for the target org targetClient := f.RestAPIClient if isCrossOrg { var err error targetClient, err = c.getClientForOrg(f, target.Org) if err != nil { return err } } createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID) var newPipeline buildkite.Pipeline var resp *buildkite.Response var err error if err = bkIO.SpinWhile(f, fmt.Sprintf("Creating pipeline %s/%s", target.Org, target.Name), func() error { newPipeline, resp, err = targetClient.Pipelines.Create(ctx, target.Org, createReq) return err }); err != nil { if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { // Check if a pipeline with this name already exists and error out if it does (not fussed with adding -1, -2 etc) if existing := c.findPipelineByName(ctx, targetClient, target); existing != nil { return fmt.Errorf("a pipeline with the name '%s' already exists: %s", target.Name, existing.WebURL) } } return fmt.Errorf("failed to create pipeline: %w", err) } if format != output.FormatText { return output.Write(kongCtx.Stdout, newPipeline, format) } fmt.Printf("%s\n", newPipeline.WebURL) return nil } // getClientForOrg creates a Buildkite client authenticated for the specified organization func (c *CopyCmd) getClientForOrg(f *factory.Factory, org string) (*buildkite.Client, error) { token := f.Config.APITokenForOrg(org) if token == "" { return nil, fmt.Errorf("no API token configured for organization %q. Run 'bk configure' to add it", org) } return buildkite.NewOpts( buildkite.WithBaseURL(f.Config.RESTAPIEndpoint()), buildkite.WithTokenAuth(token), ) } func (c *CopyCmd) buildCreatePipeline(source *buildkite.Pipeline, targetName string, isCrossOrg bool, clusterID string) buildkite.CreatePipeline { create := buildkite.CreatePipeline{ Name: targetName, Repository: source.Repository, Configuration: source.Configuration, // Branch and build settings DefaultBranch: source.DefaultBranch, Description: source.Description, BranchConfiguration: source.BranchConfiguration, SkipQueuedBranchBuilds: source.SkipQueuedBranchBuilds, SkipQueuedBranchBuildsFilter: source.SkipQueuedBranchBuildsFilter, CancelRunningBranchBuilds: source.CancelRunningBranchBuilds, CancelRunningBranchBuildsFilter: source.CancelRunningBranchBuildsFilter, // Visibility and tags Visibility: source.Visibility, Tags: source.Tags, // Provider settings (trigger mode, PR builds, commit statuses, etc.) ProviderSettings: source.Provider.Settings, } // Use explicit cluster if provided, otherwise copy from source for same-org copies if clusterID != "" { create.ClusterID = clusterID } else if !isCrossOrg { create.ClusterID = source.ClusterID } // Convert environment variables (map[string]any -> map[string]string) if len(source.Env) > 0 { create.Env = make(map[string]string, len(source.Env)) for k, v := range source.Env { create.Env[k] = fmt.Sprintf("%v", v) } } return create } func (c *CopyCmd) findPipelineByName(ctx context.Context, client *buildkite.Client, target *copyTarget) *buildkite.Pipeline { opts := buildkite.PipelineListOptions{ ListOptions: buildkite.ListOptions{ PerPage: 100, }, } pipelines, _, err := client.Pipelines.List(ctx, target.Org, &opts) if err != nil { return nil } for _, p := range pipelines { if p.Name == target.Name { return &p } } return nil } ================================================ FILE: cmd/pipeline/create.go ================================================ package pipeline import ( "context" "fmt" "net/http" "sort" "strings" "time" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/graphql" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type CreateCmd struct { Name string `arg:"" help:"Name of the pipeline" required:""` Org string `help:"Organization slug." name:"org"` Description string `help:"Description of the pipeline" short:"d"` Repository string `help:"Repository URL" short:"r"` ClusterUUID string `help:"Cluster UUID to assign the pipeline to" name:"cluster-uuid"` ClusterName string `help:"Cluster name to assign the pipeline to (resolved to UUID)" name:"cluster-name"` ClusterShorthand string `short:"c" hidden:"" name:"c" help:""` CreateWebhook bool `help:"Create an SCM webhook for the pipeline (GitHub and GitHub Enterprise only)" short:"W"` DryRun bool `help:"Simulate pipeline creation without actually creating it"` output.OutputFlags } func (c *CreateCmd) orgSlug(conf *config.Config) string { if c.Org != "" { return c.Org } return conf.OrganizationSlug() } func (c *CreateCmd) Validate() error { if c.ClusterShorthand != "" { return fmt.Errorf("-c is no longer supported for cluster; use --cluster-uuid or --cluster-name instead") } if c.ClusterUUID != "" && c.ClusterName != "" { return fmt.Errorf("only one of --cluster-uuid or --cluster-name can be specified") } return nil } func (c *CreateCmd) Help() string { return `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 by default. Use --cluster-uuid to assign a pipeline to a cluster by UUID, or --cluster-name to assign by name (the name will be resolved to the corresponding UUID). Examples: # Create a new pipeline $ bk pipeline create "My Pipeline" --description "My pipeline description" --repository "git@github.com:org/repo.git" # Create a new pipeline and view the created pipeline in JSON format $ bk pipeline create "My Pipeline" --description "My pipeline description" --repository "git@github.com:org/repo.git" --output json # Create a pipeline with a cluster (by UUID) $ bk pipeline create "My Pipeline" -d "Description" -r "git@github.com:org/repo.git" --cluster-uuid "cluster-uuid-123" # Create a pipeline with a cluster (by name) $ bk pipeline create "My Pipeline" -d "Description" -r "git@github.com:org/repo.git" --cluster-name "my-cluster" # Create a pipeline and set up a GitHub webhook $ bk pipeline create "My Pipeline" -d "Description" -r "git@github.com:org/repo.git" --create-webhook # Simulate creating a pipeline and view the output in yaml format $ bk pipeline create "My Pipeline" -d "Description" -r "git@github.com:org/repo.git" --dry-run --output yaml ` } func (c *CreateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithOrgOverride(c.Org)) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfigurationForOrg(f.Config, kongCtx.Command(), c.Org); err != nil { return err } if c.DryRun { return c.runPipelineCreateDryRun(kongCtx, f) } return c.runPipelineCreate(kongCtx, f) } func (c *CreateCmd) runPipelineCreateDryRun(kongCtx *kong.Context, f *factory.Factory) error { ctx := context.Background() format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) pipeline, err := c.createPipelineDryRun(ctx, f) if err != nil { return err } // for dry-run, if text format is requested, always default to json if format == output.FormatText { format = output.FormatJSON } return output.Write(kongCtx.Stdout, pipeline, format) } func (c *CreateCmd) runPipelineCreate(kongCtx *kong.Context, f *factory.Factory) error { ctx := context.Background() format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) pipeline, err := c.createPipeline(ctx, f) if err != nil { return err } if c.CreateWebhook { repoURL := getRepositoryURL(f, c.Repository) if repoURL == "" { fmt.Fprintln(kongCtx.Stderr, "Warning: could not determine repository URL, skipping webhook creation") } else if !isGitHubURL(repoURL) { fmt.Fprintln(kongCtx.Stderr, "Warning: webhook creation is only supported for GitHub repositories, skipping") } else { if err := createWebhook(ctx, f, pipeline.GraphQLID); err != nil { return fmt.Errorf("pipeline created but webhook creation failed: %w", err) } } } if format != output.FormatText { return output.Write(kongCtx.Stdout, pipeline, format) } fmt.Printf("%s\n", pipeline.WebURL) return nil } func (c *CreateCmd) createPipeline(ctx context.Context, f *factory.Factory) (*buildkite.Pipeline, error) { clusterID, err := c.resolveClusterUUID(ctx, f) if err != nil { return nil, err } repoURL := getRepositoryURL(f, c.Repository) var pipeline buildkite.Pipeline var resp *buildkite.Response if err = bkIO.SpinWhile(f, fmt.Sprintf("Creating pipeline %s", c.Name), func() error { createPipeline := buildkite.CreatePipeline{ Name: c.Name, Repository: repoURL, Description: c.Description, ClusterID: clusterID, Configuration: "steps:\n - label: \":pipeline:\"\n command: buildkite-agent pipeline upload", } pipeline, resp, err = f.RestAPIClient.Pipelines.Create(ctx, c.orgSlug(f.Config), createPipeline) return err }); err != nil { // Check if this is a 422 error (validation failed) if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { // Try to find an existing pipeline with the same name if existingPipeline := c.findPipelineByName(ctx, f); existingPipeline != nil { return nil, fmt.Errorf("a pipeline with the name '%s' already exists: %s", c.Name, existingPipeline.WebURL) } } return nil, err } return &pipeline, nil } func (c *CreateCmd) findPipelineByName(ctx context.Context, f *factory.Factory) *buildkite.Pipeline { opts := buildkite.PipelineListOptions{ ListOptions: buildkite.ListOptions{ PerPage: 100, }, } pipelines, _, err := f.RestAPIClient.Pipelines.List(ctx, c.orgSlug(f.Config), &opts) if err != nil { return nil } for _, p := range pipelines { if p.Name == c.Name { return &p } } return nil } 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 (c *CreateCmd) createPipelineDryRun(ctx context.Context, f *factory.Factory) (*PipelineDryRun, error) { pipelineSlug := generateSlug(c.Name) pipelineSlug, err := getAvailablePipelineSlug(ctx, f, c.orgSlug(f.Config), pipelineSlug, c.Name) if err != nil { return nil, err } orgSlug := c.orgSlug(f.Config) pipeline := initialisePipelineDryRun() 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 = c.Name pipeline.Description = c.Description pipeline.Slug = pipelineSlug pipeline.Repository = c.Repository clusterUUID, _ := c.resolveClusterUUID(ctx, f) pipeline.ClusterID = clusterUUID pipeline.ClusterURL = getClusterUrl(orgSlug, clusterUUID) 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(c.Repository), }, } pipeline.CreatedBy = getCreatedByDetails(ctx, f) return &pipeline, nil } func generateSlug(name string) string { 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 == '_' { if !lastWasSeparator && slug.Len() > 0 { slug.WriteRune('-') lastWasSeparator = true } } } result := slug.String() return strings.TrimRight(result, "-") } func extractRepoPath(repoURL string) string { if strings.HasPrefix(repoURL, "git@github.com:") { path := strings.TrimPrefix(repoURL, "git@github.com:") return strings.TrimSuffix(path, ".git") } 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, org, pipelineSlug, pipelineName string) (string, error) { pipeline, resp, err := f.RestAPIClient.Pipelines.Get(ctx, org, pipelineSlug) if err != nil { if resp != nil && resp.StatusCode == 404 { return pipelineSlug, nil } return "", fmt.Errorf("failed to validate pipeline name") } if pipeline.Name == pipelineName { return "", fmt.Errorf("a pipeline with the name '%s' already exists", pipelineName) } counter := 1 for { newSlug := fmt.Sprintf("%s-%d", pipelineSlug, counter) pipeline, resp, err := f.RestAPIClient.Pipelines.Get(ctx, org, newSlug) if err != nil { if resp != nil && resp.StatusCode == 404 { return newSlug, nil } return "", fmt.Errorf("failed to validate pipeline name") } if pipeline.Name == pipelineName { return "", fmt.Errorf("a pipeline with the name '%s' already exists", pipelineName) } counter++ 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 getClusters(ctx context.Context, f *factory.Factory, org string) (map[string]string, error) { clusterMap := make(map[string]string) page := 1 per_page := 30 for more_clusters := true; more_clusters; { opts := buildkite.ClustersListOptions{ ListOptions: buildkite.ListOptions{ Page: page, PerPage: per_page, }, } clusters, resp, err := f.RestAPIClient.Clusters.List(ctx, org, &opts) if err != nil { return map[string]string{}, err } if len(clusters) < 1 { return map[string]string{}, nil } for _, c := range clusters { clusterMap[c.Name] = c.ID } if resp.NextPage == 0 { more_clusters = false } else { page = resp.NextPage } } return clusterMap, nil } func listClusterNames(ctx context.Context, f *factory.Factory, org string) ([]string, error) { clusterMap, err := getClusters(ctx, f, org) if err != nil { return nil, err } clusterNames := make([]string, 0, len(clusterMap)) for name := range clusterMap { clusterNames = append(clusterNames, name) } sort.Strings(clusterNames) return clusterNames, nil } func (c *CreateCmd) resolveClusterUUID(ctx context.Context, f *factory.Factory) (string, error) { if c.ClusterUUID != "" { return c.ClusterUUID, nil } if c.ClusterName == "" { return "", nil } return resolveClusterName(ctx, f, c.orgSlug(f.Config), c.ClusterName) } func resolveClusterName(ctx context.Context, f *factory.Factory, org, clusterName string) (string, error) { clusterMap, err := getClusters(ctx, f, org) if err != nil { return "", fmt.Errorf("failed to fetch clusters: %w", err) } if clusterID, exists := clusterMap[clusterName]; exists { return clusterID, nil } clusterNames, _ := listClusterNames(ctx, f, org) if len(clusterNames) > 0 { return "", fmt.Errorf("cluster '%s' not found. Available clusters: %s", clusterName, strings.Join(clusterNames, ", ")) } return "", fmt.Errorf("cluster '%s' not found", clusterName) } func getCreatedByDetails(ctx context.Context, f *factory.Factory) *buildkite.User { user, _, err := f.RestAPIClient.User.CurrentUser(ctx) if err != nil { return nil } return &user } // isGitHubURL checks if a repository URL points to a GitHub-hosted repository. func isGitHubURL(repoURL string) bool { return strings.HasPrefix(repoURL, "git@github.com:") || strings.Contains(repoURL, "://github.com/") || strings.HasPrefix(repoURL, "git@github.") || strings.Contains(repoURL, "://github.") } // getRepositoryURL determines the repository URL from the flag or the git remote. func getRepositoryURL(f *factory.Factory, repoFlag string) string { if repoFlag != "" { return repoFlag } if f == nil || f.GitRepository == nil { return "" } c, err := f.GitRepository.Config() if err != nil { return "" } origin, ok := c.Remotes["origin"] if !ok || len(origin.URLs) == 0 { return "" } return origin.URLs[0] } func createWebhook(ctx context.Context, f *factory.Factory, pipelineGraphQLID string) error { return bkIO.SpinWhile(f, "Creating webhook", func() error { _, err := graphql.PipelineCreateWebhook(ctx, f.GraphQLClient, pipelineGraphQLID) return err }) } ================================================ FILE: cmd/pipeline/create_test.go ================================================ package pipeline import ( "testing" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) func TestIsGitHubURL(t *testing.T) { t.Parallel() tests := []struct { name string url string expected bool }{ { name: "GitHub SSH URL", url: "git@github.com:org/repo.git", expected: true, }, { name: "GitHub HTTPS URL", url: "https://github.com/org/repo.git", expected: true, }, { name: "GitHub HTTPS URL without .git", url: "https://github.com/org/repo", expected: true, }, { name: "GitHub Enterprise SSH URL", url: "git@github.mycompany.com:org/repo.git", expected: true, }, { name: "GitHub Enterprise HTTPS URL", url: "https://github.mycompany.com/org/repo.git", expected: true, }, { name: "GitLab SSH URL", url: "git@gitlab.com:org/repo.git", expected: false, }, { name: "GitLab HTTPS URL", url: "https://gitlab.com/org/repo.git", expected: false, }, { name: "Bitbucket SSH URL", url: "git@bitbucket.org:org/repo.git", expected: false, }, { name: "Bitbucket HTTPS URL", url: "https://bitbucket.org/org/repo.git", expected: false, }, { name: "empty string", url: "", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := isGitHubURL(tt.url) if got != tt.expected { t.Errorf("isGitHubURL(%q) = %v, want %v", tt.url, got, tt.expected) } }) } } func TestGetRepositoryURL(t *testing.T) { t.Parallel() t.Run("returns flag value when provided", func(t *testing.T) { t.Parallel() f := &factory.Factory{} repoURL := getRepositoryURL(f, "git@github.com:org/repo.git") if repoURL != "git@github.com:org/repo.git" { t.Errorf("expected flag value, got %q", repoURL) } }) t.Run("returns empty when no flag and nil git repo", func(t *testing.T) { t.Parallel() f := &factory.Factory{} repoURL := getRepositoryURL(f, "") if repoURL != "" { t.Errorf("expected empty string, got %q", repoURL) } }) t.Run("returns empty when factory is nil and no flag", func(t *testing.T) { t.Parallel() repoURL := getRepositoryURL(nil, "") if repoURL != "" { t.Errorf("expected empty string, got %q", repoURL) } }) t.Run("returns flag value even when factory is nil", func(t *testing.T) { t.Parallel() repoURL := getRepositoryURL(nil, "git@github.com:org/repo.git") if repoURL != "git@github.com:org/repo.git" { t.Errorf("expected flag value, got %q", repoURL) } }) } func TestExtractRepoPath(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string }{ {name: "GitHub SSH", input: "git@github.com:org/repo.git", expected: "org/repo"}, {name: "GitHub HTTPS", input: "https://github.com/org/repo.git", expected: "org/repo"}, {name: "GitHub HTTPS no .git", input: "https://github.com/org/repo", expected: "org/repo"}, {name: "non-GitHub URL", input: "git@gitlab.com:org/repo.git", expected: "git@gitlab.com:org/repo.git"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := extractRepoPath(tt.input) if got != tt.expected { t.Errorf("extractRepoPath(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } ================================================ FILE: cmd/pipeline/graphql/create_webhook.graphql ================================================ mutation PipelineCreateWebhook($id: ID!) { pipelineCreateWebhook(input: { id: $id }) { clientMutationId pipelineID } } ================================================ FILE: cmd/pipeline/list.go ================================================ package pipeline import ( "context" "fmt" "os" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) const ( maxPipelineLimit = 3000 pageSize = 100 ) type ListCmd struct { Org string `help:"Organization slug." name:"org"` Name string `help:"Filter pipelines by name (supports partial matches, case insensitive)" short:"n"` Repository string `help:"Filter pipelines by repository URL (supports partial matches, case insensitive)" short:"r"` Limit int `help:"Maximum number of pipelines to return (max: 3000)" short:"l" default:"100"` output.OutputFlags } func (c *ListCmd) Help() string { return `List pipelines with optional filtering. This command lists all pipelines in the current organization that match the specified criteria. You can filter by pipeline name or repository URL. Examples: # List all pipelines (default limit: 100) $ bk pipeline list # List pipelines matching a name pattern $ bk pipeline list --name pipeline # List pipelines by repository $ bk pipeline list --repo my-repo # Get more pipelines (automatically paginates) $ bk pipeline list --limit 500 # Output as JSON $ bk pipeline list --name pipeline -o json # Use with other commands (e.g., get longest builds from matching pipelines) $ bk pipeline list --name pipeline | xargs -I {} bk build list --pipeline {} --since 48h --duration 1h ` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithOrgOverride(c.Org)) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfigurationForOrg(f.Config, kongCtx.Command(), c.Org); err != nil { return err } if c.Limit > maxPipelineLimit { return fmt.Errorf("limit cannot exceed %d pipelines (requested: %d)", maxPipelineLimit, c.Limit) } ctx := context.Background() return c.runPipelineList(ctx, f) } func (c *ListCmd) runPipelineList(ctx context.Context, f *factory.Factory) error { org := c.Org if org == "" { org = f.Config.OrganizationSlug() } if org == "" { return fmt.Errorf("no organization configured. Use 'bk configure' to set up your organization") } listOpts := c.pipelineListOptionsFromFlags() var pipelines []buildkite.Pipeline if err := bkIO.SpinWhile(f, "Loading pipelines", func() error { var apiErr error pipelines, apiErr = c.fetchPipelines(ctx, f, org, listOpts) return apiErr }); err != nil { return fmt.Errorf("failed to list pipelines: %w", err) } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) if len(pipelines) == 0 { return output.WriteTextOrStructured(os.Stdout, format, []buildkite.Pipeline{}, "No pipelines found matching the specified criteria.") } return c.displayPipelines(pipelines, f) } func (c *ListCmd) pipelineListOptionsFromFlags() *buildkite.PipelineListOptions { listOpts := &buildkite.PipelineListOptions{ ListOptions: buildkite.ListOptions{ PerPage: pageSize, }, } if c.Name != "" { listOpts.Name = c.Name } if c.Repository != "" { listOpts.Repository = c.Repository } return listOpts } func (c *ListCmd) fetchPipelines(ctx context.Context, f *factory.Factory, org string, listOpts *buildkite.PipelineListOptions) ([]buildkite.Pipeline, error) { var allPipelines []buildkite.Pipeline for page := 1; len(allPipelines) < c.Limit; page++ { listOpts.Page = page listOpts.PerPage = min(pageSize, c.Limit-len(allPipelines)) pipelines, _, err := f.RestAPIClient.Pipelines.List(ctx, org, listOpts) if err != nil { return nil, err } if len(pipelines) == 0 { break } allPipelines = append(allPipelines, pipelines...) if len(pipelines) < listOpts.PerPage { break } } return allPipelines, nil } func (c *ListCmd) displayPipelines(pipelines []buildkite.Pipeline, f *factory.Factory) error { format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) if format != output.FormatText { return output.Write(os.Stdout, pipelines, format) } rows := make([][]string, 0, len(pipelines)) for _, pipeline := range pipelines { rows = append(rows, []string{ output.ValueOrDash(strings.TrimSpace(pipeline.Name)), output.ValueOrDash(strings.TrimSpace(pipeline.Repository)), }) } table := output.Table( []string{"Name", "Repository"}, rows, map[string]string{"name": "bold", "repository": "italic"}, ) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() _, err := fmt.Fprintf(writer, "Pipelines (%d)\n\n%s\n", len(pipelines), table) return err } ================================================ FILE: cmd/pipeline/validate.go ================================================ package pipeline import ( "fmt" "io" "os" "path/filepath" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/goccy/go-yaml" "github.com/xeipuuv/gojsonschema" ) const schemaURL = "https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json" // fallbackSchema is a simplified schema used when the online schema cannot be accessed // It implements the basic structure validation but doesn't include all checks var fallbackSchema = []byte(`{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "steps": { "type": "array", "items": { "type": "object", "properties": { "label": { "type": "string" }, "command": { "type": "string" }, "plugins": { "type": "object" }, "agents": { "type": "object" }, "env": { "type": "object" }, "branches": { "type": ["string", "object"] }, "if": { "type": "string" }, "depends_on": { "type": ["string", "array"] } } } }, "env": { "type": "object" }, "agents": { "type": "object" } }, "required": ["steps"] }`) type ValidateCmd struct { File []string `help:"Path to the pipeline YAML file(s) to validate" short:"f"` } func (c *ValidateCmd) Help() string { return `Validate a pipeline YAML file against the Buildkite pipeline schema. By default, this command looks for a file at .buildkite/pipeline.yaml or .buildkite/pipeline.yml in the current directory. You can specify different files using the --file flag. Note: This command does not require an API token since validation is done locally. Examples: # Validate the default pipeline file $ bk pipeline validate # Validate a specific pipeline file $ bk pipeline validate --file path/to/pipeline.yaml # Validate multiple pipeline files $ bk pipeline validate --file path/to/pipeline1.yaml --file path/to/pipeline2.yaml ` } func (c *ValidateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() filePaths := c.File if len(filePaths) == 0 { defaultPath, err := findPipelineFile() if err != nil { return err } filePaths = []string{defaultPath} } var validationErrors []string fileCount := len(filePaths) fmt.Printf("Validating %d pipeline file(s)...\n\n", fileCount) for _, filePath := range filePaths { err := validatePipeline(os.Stdout, filePath) if err != nil { validationErrors = append(validationErrors, fmt.Sprintf("%s: %v", filePath, err)) } } if len(validationErrors) > 0 { errorCount := len(validationErrors) fmt.Printf("\n%d of %d file(s) failed validation.\n", errorCount, fileCount) return fmt.Errorf("pipeline validation failed") } fmt.Println("\nAll pipeline files passed validation successfully!") return nil } // findPipelineFile attempts to locate a pipeline file in the default locations func findPipelineFile() (string, error) { // Check for pipeline files in various standard locations // The order matches the Buildkite agent's lookup order paths := []string{ "buildkite.yml", "buildkite.yaml", "buildkite.json", filepath.Join(".buildkite", "pipeline.yml"), filepath.Join(".buildkite", "pipeline.yaml"), filepath.Join(".buildkite", "pipeline.json"), filepath.Join("buildkite", "pipeline.yml"), filepath.Join("buildkite", "pipeline.yaml"), filepath.Join("buildkite", "pipeline.json"), } // Check each path for _, path := range paths { if fileExists(path) { return path, nil } } // If no file found, provide detailed error message return "", fmt.Errorf("could not find pipeline file in default locations. Please specify a file with --file or create one in a standard location:\n" + " • .buildkite/pipeline.yml\n" + " • .buildkite/pipeline.yaml\n" + " • buildkite.yml\n" + " • buildkite.yaml") } // fileExists checks if a file exists and is not a directory func fileExists(path string) bool { info, err := os.Stat(path) if err != nil { return false } return !info.IsDir() } // validatePipeline validates the given pipeline file against the schema func validatePipeline(w io.Writer, filePath string) error { // Read the pipeline file pipelineData, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("error reading pipeline file: %w", err) } // Trim whitespace to handle empty files more gracefully if len(strings.TrimSpace(string(pipelineData))) == 0 { fmt.Fprintf(w, "❌ Pipeline file is invalid: %s\n\n", filePath) fmt.Fprintf(w, "- File is empty\n") return fmt.Errorf("empty pipeline file") } // Convert YAML to JSON for validation jsonData, err := yaml.YAMLToJSON(pipelineData) if err != nil { fmt.Fprintf(w, "❌ Pipeline file is invalid: %s\n\n", filePath) fmt.Fprintf(w, "- YAML parsing error: %s\n", err.Error()) fmt.Fprintf(w, " Hint: Check for syntax errors like improper indentation, missing quotes, or invalid characters.\n") return fmt.Errorf("invalid YAML format: %w", err) } // Load the schema and document schemaLoader := gojsonschema.NewReferenceLoader(schemaURL) documentLoader := gojsonschema.NewBytesLoader(jsonData) // Try to validate against the online schema result, err := gojsonschema.Validate(schemaLoader, documentLoader) if err != nil { // If online schema access fails, try the fallback schema fmt.Fprintf(w, "⚠️ Warning: Could not access online pipeline schema: %s\n", err.Error()) fmt.Fprintf(w, " Using simplified fallback schema for basic validation.\n\n") // Create a schema loader using the fallback schema fallbackLoader := gojsonschema.NewBytesLoader(fallbackSchema) result, err = gojsonschema.Validate(fallbackLoader, documentLoader) if err != nil { fmt.Fprintf(w, "❌ Pipeline file is invalid: %s\n\n", filePath) fmt.Fprintf(w, "- Schema validation error: %s\n", err.Error()) return fmt.Errorf("schema validation error: %w", err) } } if result.Valid() { fmt.Fprintf(w, "✅ Pipeline file is valid: %s\n", filePath) return nil } // Return validation errors fmt.Fprintf(w, "❌ Pipeline file is invalid: %s\n\n", filePath) for _, err := range result.Errors() { // Format the error message for better readability message := formatValidationError(err) fmt.Fprintf(w, "- %s\n", message) } return fmt.Errorf("pipeline validation failed") } // formatValidationError formats a validation error for better readability func formatValidationError(err gojsonschema.ResultError) string { field := err.Field() if strings.Contains(field, "[") && strings.Contains(field, "]") { parts := strings.Split(field, ".") for i, part := range parts { if strings.Contains(part, "[") { index := strings.TrimRight(strings.TrimLeft(part, "["), "]") name := strings.Split(part, "[")[0] parts[i] = fmt.Sprintf("%s item #%s", name, index) } } field = strings.Join(parts, " > ") } else if field != "" { field = strings.ReplaceAll(field, ".", " > ") } message := err.Description() // Format the message with the field highlighted if field != "" { message = fmt.Sprintf("%s: %s", field, message) } // Include details about what was received vs what was expected if available details := err.Details() // Add more context about expected values var contextParts []string if val, ok := details["field"]; ok && val != field { contextParts = append(contextParts, fmt.Sprintf("field: %v", val)) } if val, ok := details["expected"]; ok { contextParts = append(contextParts, fmt.Sprintf("expected: %v", val)) } if val, ok := details["actual"]; ok { contextParts = append(contextParts, fmt.Sprintf("got: %v", val)) } // If we have context parts, add them to the message if len(contextParts) > 0 { message += fmt.Sprintf(" (%s)", strings.Join(contextParts, ", ")) } // Add a helpful hint based on the error type switch err.Type() { case "required": message += "\n Hint: This field is required but was not found in your pipeline." case "type_error": message += "\n Hint: Check that you're using the correct data type for this field." case "enum": message += "\n Hint: The value must be one of the allowed options." case "const": message += "\n Hint: This field must have the specific required value." case "array_no_items": message += "\n Hint: This array cannot be empty." } return message } ================================================ FILE: cmd/pipeline/validate_test.go ================================================ package pipeline import ( "bytes" "fmt" "io" "os" "path/filepath" "strings" "testing" "github.com/goccy/go-yaml" "github.com/xeipuuv/gojsonschema" ) func TestValidatePipeline(t *testing.T) { t.Parallel() // Create a test schema that matches actual Buildkite schema requirements: // This simplified schema ensures: // - Steps are required // - Each step must have at least a "command" field // - A "label" field is optional and must be a string // - A "command" field must be a string testSchema := []byte(`{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": ["steps"], "properties": { "steps": { "type": "array", "items": { "type": "object", "properties": { "label": { "type": "string" }, "command": { "type": "string" } }, "required": ["command"] } } } }`) testSchemaLoader := gojsonschema.NewBytesLoader(testSchema) tests := []struct { name string fileContent string expectError bool expectOutput string }{ { name: "valid pipeline", fileContent: `steps: - label: "Hello, world! 👋" command: echo "Hello, world!"`, expectError: false, expectOutput: "✅ Pipeline file is valid", }, { name: "valid pipeline with command only", fileContent: `steps: - command: echo "Hello, world!"`, expectError: false, expectOutput: "✅ Pipeline file is valid", }, { name: "invalid pipeline missing command", fileContent: `steps: - label: "Hello, world!"`, expectError: true, expectOutput: "❌ Pipeline file is invalid", }, { name: "invalid pipeline with wrong type", fileContent: `steps: - label: 123 command: echo "Hello, world!"`, expectError: true, expectOutput: "❌ Pipeline file is invalid", }, { name: "empty file", fileContent: "", expectError: true, expectOutput: "File is empty", }, { name: "invalid YAML syntax", fileContent: `steps: - label: "This has invalid syntax command: echo "Missing closing quote and improper indentation`, expectError: true, expectOutput: "YAML parsing error", }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() tmpFile, err := os.CreateTemp("", "pipeline-*.yaml") if err != nil { t.Fatal(err) } defer os.Remove(tmpFile.Name()) if _, err := tmpFile.Write([]byte(test.fileContent)); err != nil { t.Fatal(err) } if err := tmpFile.Close(); err != nil { t.Fatal(err) } var stdout bytes.Buffer mockValidatePipeline := func(w io.Writer, filePath string) error { pipelineData, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("error reading pipeline file: %w", err) } if len(strings.TrimSpace(string(pipelineData))) == 0 { fmt.Fprintf(w, "❌ Pipeline file is invalid: %s\n\n", filePath) fmt.Fprintf(w, "- File is empty\n") return fmt.Errorf("empty pipeline file") } jsonData, err := yaml.YAMLToJSON(pipelineData) if err != nil { fmt.Fprintf(w, "❌ Pipeline file is invalid: %s\n\n", filePath) fmt.Fprintf(w, "- YAML parsing error: %s\n", err.Error()) fmt.Fprintf(w, " Hint: Check for syntax errors like improper indentation, missing quotes, or invalid characters.\n") return fmt.Errorf("invalid YAML format: %w", err) } documentLoader := gojsonschema.NewBytesLoader(jsonData) result, err := gojsonschema.Validate(testSchemaLoader, documentLoader) if err != nil { return fmt.Errorf("error validating pipeline: %w", err) } if result.Valid() { fmt.Fprintf(w, "✅ Pipeline file is valid: %s\n", filePath) return nil } fmt.Fprintf(w, "❌ Pipeline file is invalid: %s\n\n", filePath) for _, err := range result.Errors() { message := formatValidationError(err) fmt.Fprintf(w, "- %s\n", message) } return fmt.Errorf("pipeline validation failed") } err = mockValidatePipeline(&stdout, tmpFile.Name()) if test.expectError { if err == nil { t.Error("Expected error but got none") } } else { if err != nil { t.Errorf("Expected no error but got: %v", err) } } if !strings.Contains(stdout.String(), test.expectOutput) { t.Errorf("Expected output to contain %q, but got: %q", test.expectOutput, stdout.String()) } }) } } //nolint:tparallel // they change dir, so let them run one at a time to avoid flakiness func TestFindPipelineFile(t *testing.T) { t.Parallel() t.Run("no file exists", func(t *testing.T) { tmpDir := t.TempDir() origDir, _ := os.Getwd() defer os.Chdir(origDir) os.Chdir(tmpDir) _, err := findPipelineFile() if err == nil { t.Error("Expected error but got none") } }) t.Run("find pipeline.yml", func(t *testing.T) { tmpDir := t.TempDir() buildkiteDir := filepath.Join(tmpDir, ".buildkite") os.MkdirAll(buildkiteDir, 0o755) testFile := filepath.Join(buildkiteDir, "pipeline.yml") os.WriteFile(testFile, []byte("steps: []"), 0o644) origDir, _ := os.Getwd() defer os.Chdir(origDir) os.Chdir(tmpDir) path, err := findPipelineFile() if err != nil { t.Errorf("Expected no error but got: %v", err) } expected := filepath.Join(".buildkite", "pipeline.yml") if path != expected { t.Errorf("Expected %q but got %q", expected, path) } }) t.Run("find pipeline.yaml", func(t *testing.T) { tmpDir := t.TempDir() buildkiteDir := filepath.Join(tmpDir, ".buildkite") os.MkdirAll(buildkiteDir, 0o755) testFile := filepath.Join(buildkiteDir, "pipeline.yaml") os.WriteFile(testFile, []byte("steps: []"), 0o644) origDir, _ := os.Getwd() defer os.Chdir(origDir) os.Chdir(tmpDir) path, err := findPipelineFile() if err != nil { t.Errorf("Expected no error but got: %v", err) } if !strings.Contains(path, "pipeline.y") { t.Errorf("Expected path to contain pipeline.yaml or pipeline.yml, got %q", path) } }) } // Test formatting validation errors func TestFormatValidationError(t *testing.T) { t.Parallel() // This is a simplified test since we can't directly create gojsonschema.ResultError objects // Create a test schema that we can use to generate validation errors schemaJSON := []byte(`{ "type": "object", "properties": { "steps": { "type": "array", "items": { "type": "object", "properties": { "label": { "type": "string" }, "command": { "type": "string" } } } } } }`) // Invalid YAML that will cause validation errors invalidYaml := ` steps: - label: "Test" command: echo "Hello" - label: "Test 2" command: 123 ` // Load the pipeline file tmpFile, err := os.CreateTemp("", "invalid-pipeline-*.yaml") if err != nil { t.Fatal(err) } defer os.Remove(tmpFile.Name()) if _, err := tmpFile.Write([]byte(invalidYaml)); err != nil { t.Fatal(err) } if err := tmpFile.Close(); err != nil { t.Fatal(err) } var buf bytes.Buffer pipelineData, err := os.ReadFile(tmpFile.Name()) if err != nil { t.Fatalf("error reading pipeline file: %v", err) } // Convert YAML to JSON for validation jsonData, err := yaml.YAMLToJSON(pipelineData) if err != nil { t.Fatalf("error converting YAML to JSON: %v", err) } // Validate using our test schema schemaLoader := gojsonschema.NewBytesLoader(schemaJSON) documentLoader := gojsonschema.NewBytesLoader(jsonData) result, err := gojsonschema.Validate(schemaLoader, documentLoader) if err != nil { t.Fatalf("error validating pipeline: %v", err) } // Output the validation errors to the buffer fmt.Fprintf(&buf, "❌ Pipeline file is invalid: %s\n\n", tmpFile.Name()) for _, err := range result.Errors() { message := formatValidationError(err) fmt.Fprintf(&buf, "- %s\n", message) } output := buf.String() // Output should mention the error for the invalid field type if !strings.Contains(output, "invalid") { t.Errorf("Expected output to mention invalid field, got: %s", output) } } func TestFileExists(t *testing.T) { t.Parallel() t.Run("file exists", func(t *testing.T) { t.Parallel() tmpFile, err := os.CreateTemp("", "test-*.txt") if err != nil { t.Fatal(err) } defer os.Remove(tmpFile.Name()) tmpFile.Close() if !fileExists(tmpFile.Name()) { t.Error("Expected file to exist") } }) t.Run("file does not exist", func(t *testing.T) { t.Parallel() if fileExists("/this/path/does/not/exist/file.txt") { t.Error("Expected file to not exist") } }) t.Run("directory exists but not a file", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() if fileExists(tmpDir) { t.Error("Expected fileExists to return false for directory") } }) } ================================================ FILE: cmd/pipeline/view.go ================================================ package pipeline import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/pkg/browser" ) type ViewCmd struct { // Pipeline is the positional arg; PipelineFlag (--pipeline/-p) takes priority when both are provided. Pipeline string `arg:"" help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." optional:""` PipelineFlag string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p" name:"pipeline"` Org string `help:"Organization slug." name:"org"` Web bool `help:"Open the pipeline in a web browser." short:"w"` output.OutputFlags } func (c *ViewCmd) Help() string { return `View information about a pipeline. Examples: # View a pipeline $ bk pipeline view my-pipeline # View a pipeline using flags $ bk pipeline view --org my-org --pipeline my-pipeline # View a pipeline in a specific organization $ bk pipeline view my-org/my-pipeline # Open pipeline in browser $ bk pipeline view my-pipeline --web # Output as JSON $ bk pipeline view my-pipeline -o json ` } func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithOrgOverride(c.Org)) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfigurationForOrg(f.Config, kongCtx.Command(), c.Org); err != nil { return err } if c.Pipeline != "" && c.PipelineFlag != "" && c.Pipeline != c.PipelineFlag { return fmt.Errorf("pipeline provided as both positional argument (%q) and --pipeline flag (%q); use only one", c.Pipeline, c.PipelineFlag) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() pipelineArg := c.PipelineFlag if pipelineArg == "" { pipelineArg = c.Pipeline } var args []string if pipelineArg != "" { args = []string{pipelineArg} } picker := resolver.PickOneWithFactory(f) cachedPicker := resolver.CachedPicker(f.Config, picker) repositoryResolver := resolver.ResolveFromRepository(f, cachedPicker) if c.Org != "" { repositoryResolver = resolver.ResolveFromRepositoryInOrg(f, cachedPicker, c.Org) } pipelineRes := resolver.NewAggregateResolver( resolver.WithOrg(c.Org, resolver.ResolveFromPositionalArgument(args, 0, f.Config)), resolver.WithOrg(c.Org, resolver.ResolveFromConfig(f.Config, picker)), repositoryResolver, ) pipeline, err := pipelineRes.Resolve(ctx) if err != nil { return err } slug := fmt.Sprintf("%s/%s", pipeline.Org, pipeline.Name) if c.Web { return browser.OpenURL(fmt.Sprintf("https://buildkite.com/%s", slug)) } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) var p buildkite.Pipeline if err = bkIO.SpinWhile(f, "Loading pipeline information", func() error { var apiErr error p, _, apiErr = f.RestAPIClient.Pipelines.Get(ctx, pipeline.Org, pipeline.Name) return apiErr }); err != nil { return err } pipelineView := output.Viewable[buildkite.Pipeline]{ Data: p, Render: renderPipelineText, } if format != output.FormatText { return output.Write(os.Stdout, pipelineView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() return output.Write(writer, pipelineView, format) } func renderPipelineText(p buildkite.Pipeline) string { rows := [][]string{ {"Description", output.ValueOrDash(p.Description)}, {"Repository", output.ValueOrDash(p.Repository)}, {"Default Branch", output.ValueOrDash(p.DefaultBranch)}, {"Visibility", output.ValueOrDash(p.Visibility)}, {"Web URL", output.ValueOrDash(p.WebURL)}, } if len(p.Tags) > 0 { rows = append(rows, []string{"Tags", strings.Join(p.Tags, ", ")}) } if p.ClusterID != "" { rows = append(rows, []string{"Cluster ID", p.ClusterID}) } var sb strings.Builder fmt.Fprintf(&sb, "Viewing %s\n\n", output.ValueOrDash(p.Name)) table := output.Table( []string{"Field", "Value"}, rows, map[string]string{"field": "dim", "value": "italic"}, ) sb.WriteString(table) if p.Configuration != "" { sb.WriteString("\n\nConfiguration:\n") sb.WriteString(p.Configuration) } return sb.String() } ================================================ FILE: cmd/pkg/push.go ================================================ package pkg import ( "context" "errors" "fmt" "io" "os" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/go-buildkite/v4" ) var ( ErrInvalidConfig = errors.New("invalid config") ErrAPIError = errors.New("API error") // To be overridden in testing // Actually diddling an io.Reader so that it looks like a readable stdin is tricky // so we'll just stub this out isStdInReadableFunc = isStdinReadable ) type PushCmd struct { RegistrySlug string `arg:"" required:"" help:"The slug of the registry to push the package to" ` FilePath string `xor:"input" help:"Path to the package file to push"` StdinFileName string `xor:"input" help:"The filename to use when reading the package from stdin"` StdInArg string `arg:"" optional:"" hidden:"" help:"Use '-' as value to pass package via stdin. Required if --stdin-file-name is used."` Web bool `short:"w" help:"Open the pipeline in a web browser." ` } func (c *PushCmd) Help() string { return `Push a new package to a Buildkite registry. The package can be passed as a path to a file with the --file-path flag, or via stdin. If passed via stdin, the filename must be provided with the --stdin-file-name flag, as a Buildkite registry requires a filename for the package. Examples: Push a package to a Buildkite registry The web URL of the uploaded package will be printed to stdout. # Push package from file $ bk package push my-registry --file-path my-package.tar.gz # Push package via stdin $ cat my-package.tar.gz | bk package push my-registry --stdin-file-name my-package.tar.gz - # Pass package via stdin, note hyphen as the argument # add -w to open the build in your web browser $ bk package push my-registry --file-path my-package.tar.gz -w ` } func (c *PushCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() err = c.Validate() if err != nil { return fmt.Errorf("failed to validate flags and args: %w", err) } var ( from io.Reader packageName string ) switch { case c.FilePath != "": packageName = c.FilePath file, err := os.Open(c.FilePath) if err != nil { return fmt.Errorf("couldn't open file %s: %w", c.FilePath, err) } defer file.Close() from = file case c.StdinFileName != "": packageName = c.StdinFileName from = os.Stdin default: panic("Neither file path nor stdin file name are available, there has been an error in the config validation. Report this to support@buildkite.com") } ctx := context.Background() var pkg buildkite.Package if err = bkIO.SpinWhile(f, "Pushing file", func() error { var apiErr error pkg, _, apiErr = f.RestAPIClient.PackagesService.Create(ctx, f.Config.OrganizationSlug(), c.RegistrySlug, buildkite.CreatePackageInput{ Filename: packageName, Package: from, }) return apiErr }); err != nil { return fmt.Errorf("%w: request to create package failed: %w", ErrAPIError, err) } return util.OpenInWebBrowser(c.Web, pkg.WebURL) } func isStdinReadable() (bool, error) { stat, err := os.Stdin.Stat() if err != nil { return false, fmt.Errorf("failed to stat stdin: %w", err) } readable := (stat.Mode() & os.ModeCharDevice) == 0 return readable, nil } func (c *PushCmd) Validate() error { // Validate the args such that either a file path is provided or stdin is being used // check if c.FilePath and c.Stdin cannot be both set or both empty if c.FilePath == "" && c.StdinFileName == "" { return fmt.Errorf("%w: either a file path argument or --stdin-file-name must be provided", ErrInvalidConfig) } if c.FilePath != "" && c.StdinFileName != "" { return fmt.Errorf("%w: cannot provide both a file path argument and --stdin-file-name", ErrInvalidConfig) } if c.StdinFileName != "" { if c.StdInArg != "-" { return fmt.Errorf("%w: when passing a package file via stdin, the final argument must be '-'", ErrInvalidConfig) } stdInReadable, err := isStdInReadableFunc() if err != nil { return fmt.Errorf("failed to check if stdin is readable: %w", err) } if !stdInReadable { return fmt.Errorf("%w: stdin is not readable", ErrInvalidConfig) } return nil } else { // Validate if an std-in arg is provided without stdin-file-name if c.StdInArg == "-" { return fmt.Errorf("%w: when passing a package file via stdin, --stdin-file-name must be provided", ErrInvalidConfig) } // We have a file path, check it exists and is a regular file fi, err := os.Stat(c.FilePath) if err != nil { return fmt.Errorf("%w: %w", ErrInvalidConfig, err) } if !fi.Mode().IsRegular() { mode := "directory" if !fi.Mode().IsDir() { mode = fi.Mode().String() } return fmt.Errorf("%w: file at %s is not a regular file, mode was: %s", ErrInvalidConfig, c.FilePath, mode) } return nil } } ================================================ FILE: cmd/pkg/push_test.go ================================================ package pkg import ( "errors" "io" "strings" "testing" ) func TestPackagePushCommandArgs(t *testing.T) { t.Parallel() cases := []struct { name string stdin io.Reader cmd PushCmd wantErrContain string wantErr error }{ // Config validation errors { name: "no args", cmd: PushCmd{ RegistrySlug: "my-registry", FilePath: "", StdinFileName: "", StdInArg: "", }, wantErrContain: "either a file path argument or --stdin-file-name must be provided", wantErr: ErrInvalidConfig, }, { name: "file that's a directory", cmd: PushCmd{ RegistrySlug: "my-registry", FilePath: "/", StdinFileName: "", StdInArg: "", }, wantErr: ErrInvalidConfig, wantErrContain: "file at / is not a regular file, mode was: directory", }, { name: "file that doesn't exist", cmd: PushCmd{ RegistrySlug: "my-registry", FilePath: "/does-not-exist", StdinFileName: "", StdInArg: "", }, wantErr: ErrInvalidConfig, wantErrContain: "stat /does-not-exist: no such file or directory", }, { name: "cannot provide both file path and stdin file name", cmd: PushCmd{ RegistrySlug: "my-registry", FilePath: "/a-test-package.pkg", StdinFileName: "a-test-package.pkg", StdInArg: "", }, wantErr: ErrInvalidConfig, wantErrContain: "cannot provide both a file path argument and --stdin-file-name", }, { name: "file path but with stdin arg '-'", cmd: PushCmd{ RegistrySlug: "my-registry", FilePath: "/directory/test.pkg", StdinFileName: "", StdInArg: "-", }, stdin: strings.NewReader("test package stream contents!"), wantErr: ErrInvalidConfig, wantErrContain: "when passing a package file via stdin, --stdin-file-name must be provided", }, { name: "stdin without --stdin-file-name", cmd: PushCmd{ RegistrySlug: "my-registry", FilePath: "", StdinFileName: "test", StdInArg: "", }, stdin: strings.NewReader("test package stream contents!"), wantErr: ErrInvalidConfig, wantErrContain: "when passing a package file via stdin, the final argument must be '-'", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() err := tc.cmd.Validate() if !errors.Is(err, tc.wantErr) { t.Errorf("Expected error %v, got %v", tc.wantErr, err) } if err != nil && !strings.Contains(err.Error(), tc.wantErrContain) { t.Errorf("Expected error to contain %q, got %q", tc.wantErrContain, err.Error()) } }) } } ================================================ FILE: cmd/preflight/cleanup_cmd.go ================================================ package preflight import ( "context" "errors" "fmt" "os" "time" "github.com/alecthomas/kong" "github.com/google/uuid" "github.com/buildkite/cli/v3/internal/cli" bkErrors "github.com/buildkite/cli/v3/internal/errors" "github.com/buildkite/cli/v3/internal/preflight" ) // CleanupCmd deletes remote bk/preflight/* branches whose builds have completed. type CleanupCmd struct { Pipeline string `help:"The pipeline to check builds against. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` PreflightUUID string `help:"Target a single preflight branch by its UUID (bk/preflight/<uuid>)." name:"preflight-uuid"` DryRun bool `help:"Show which branches would be deleted without actually deleting them." name:"dry-run"` Text bool `help:"Use plain text output instead of interactive terminal UI." xor:"output"` JSON bool `help:"Emit one JSON object per event (JSONL)." xor:"output"` } func (c *CleanupCmd) Help() string { return `Deletes remote bk/preflight/* branches whose builds have completed (passed, failed, canceled). Branches with in-progress builds are left untouched to avoid interrupting concurrent preflight runs. Pass --preflight-uuid to target a single preflight branch.` } func (c *CleanupCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { if c.PreflightUUID != "" { if _, err := uuid.Parse(c.PreflightUUID); err != nil { return bkErrors.NewValidationError(err, fmt.Sprintf("invalid preflight UUID %q", c.PreflightUUID)) } } pCtx, err := setup(c.Pipeline, globals) if err != nil { return err } defer pCtx.Stop() ctx := pCtx.Ctx repoRoot := pCtx.RepoRoot resolvedPipeline := pCtx.Pipeline var branches []preflight.BranchBuild if c.PreflightUUID != "" { branch, err := preflight.LookupRemotePreflightBranch(repoRoot, c.PreflightUUID, globals.EnableDebug()) if err != nil { return bkErrors.NewInternalError(err, "failed to look up preflight branch") } if branch != nil { branches = []preflight.BranchBuild{*branch} } } else { branches, err = preflight.ListRemotePreflightBranches(repoRoot, globals.EnableDebug()) if err != nil { return bkErrors.NewInternalError(err, "failed to list remote preflight branches") } } if len(branches) == 0 { if c.PreflightUUID != "" { fmt.Fprintf(os.Stdout, "No preflight branch found for UUID %s\n", c.PreflightUUID) } else { fmt.Fprintln(os.Stdout, "No preflight branches found") } return nil } renderer := rendererFactory(os.Stdout, c.JSON, c.Text, pCtx.Stop) defer renderer.Close() _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Found %d preflight branch(es), checking build status...", len(branches))}) if err := preflight.ResolveBuilds(ctx, pCtx.Factory.RestAPIClient, resolvedPipeline.Org, resolvedPipeline.Name, branches); err != nil { if errors.Is(err, context.Canceled) { _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: "Cleanup interrupted"}) return nil } return bkErrors.NewInternalError(err, "failed to check build status for preflight branches") } var toDelete []string var deleted, skipped int for i := range branches { bb := branches[i] if !bb.IsCompleted() { _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Skipping %s (build state: %s)", bb.Branch, bb.Build.State)}) skipped++ continue } state := "no build found" if bb.Build != nil { state = bb.Build.State } if c.DryRun { _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Would delete %s (%s)", bb.Branch, state)}) } else { _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Deleting %s (%s)", bb.Branch, state)}) toDelete = append(toDelete, bb.Ref) } deleted++ } if !c.DryRun && len(toDelete) > 0 { if err := preflight.CleanupRefs(repoRoot, toDelete, globals.EnableDebug()); err != nil { return bkErrors.NewInternalError(err, "failed to delete preflight branches from remote") } } verb := "deleted" if c.DryRun { verb = "would delete" } _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), Title: fmt.Sprintf("Cleanup complete: %d %s, %d skipped", deleted, verb, skipped)}) return nil } ================================================ FILE: cmd/preflight/cleanup_cmd_test.go ================================================ package preflight import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/buildkite/cli/v3/internal/config" bkErrors "github.com/buildkite/cli/v3/internal/errors" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestCleanupCmd_Run(t *testing.T) { t.Run("returns validation error when experiment disabled", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "alpha") cmd := &CleanupCmd{} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } if !bkErrors.IsValidationError(err) { t.Fatalf("expected validation error, got %T: %v", err, err) } }) t.Run("deletes completed branches and skips running ones", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") // Create a test repo with two preflight branches. worktree := initTestRepo(t) t.Chdir(worktree) // Create two preflight branches by pushing commits. createPreflightBranch(t, worktree, "bk/preflight/completed-one") createPreflightBranch(t, worktree, "bk/preflight/still-running") // Verify both branches exist on remote. refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if !strings.Contains(refs, "bk/preflight/completed-one") { t.Fatal("expected completed-one branch to exist") } if !strings.Contains(refs, "bk/preflight/still-running") { t.Fatal("expected still-running branch to exist") } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { branches := r.URL.Query()["branch[]"] var builds []buildkite.Build for _, branch := range branches { switch branch { case "bk/preflight/completed-one": builds = append(builds, buildkite.Build{Number: 1, State: "passed", Branch: branch}) case "bk/preflight/still-running": builds = append(builds, buildkite.Build{Number: 2, State: "running", Branch: branch}) } } json.NewEncoder(w).Encode(builds) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } // Verify completed branch was deleted. refs = runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/completed-one") { t.Error("expected completed-one branch to be deleted") } // Verify running branch was preserved. if !strings.Contains(refs, "bk/preflight/still-running") { t.Error("expected still-running branch to be preserved") } }) t.Run("reports no branches when none exist", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") worktree := initTestRepo(t) t.Chdir(worktree) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } }) t.Run("deletes orphaned branches with no builds", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") worktree := initTestRepo(t) t.Chdir(worktree) createPreflightBranch(t, worktree, "bk/preflight/orphaned") s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { json.NewEncoder(w).Encode([]buildkite.Build{}) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/orphaned") { t.Error("expected orphaned branch to be deleted") } }) t.Run("falls back to git cli when factory has no repository", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") originalNewFactory := newFactory t.Cleanup(func() { newFactory = originalNewFactory }) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { branches := r.URL.Query()["branch[]"] var builds []buildkite.Build for _, branch := range branches { builds = append(builds, buildkite.Build{Number: 1, State: "failed", Branch: branch}) } json.NewEncoder(w).Encode(builds) return } http.NotFound(w, r) })) defer s.Close() newFactory = func(...factory.FactoryOpt) (*factory.Factory, error) { client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { return nil, err } return &factory.Factory{ Config: config.New(nil, nil), RestAPIClient: client, }, nil } worktree := initTestRepo(t) t.Chdir(worktree) createPreflightBranch(t, worktree, "bk/preflight/to-clean") cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} if err := cmd.Run(nil, stubGlobals{}); err != nil { t.Fatalf("expected no error, got: %v", err) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/to-clean") { t.Error("expected branch to be deleted") } }) t.Run("returns error when API fails", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") worktree := initTestRepo(t) t.Chdir(worktree) createPreflightBranch(t, worktree, "bk/preflight/some-branch") s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { http.Error(w, `{"message":"internal error"}`, http.StatusInternalServerError) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error when API fails, got nil") } // Branch should still exist since the error prevented cleanup. refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if !strings.Contains(refs, "bk/preflight/some-branch") { t.Error("expected branch to be preserved when API fails") } }) t.Run("dry run shows branches without deleting them", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") worktree := initTestRepo(t) t.Chdir(worktree) createPreflightBranch(t, worktree, "bk/preflight/dry-run-test") s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { branches := r.URL.Query()["branch[]"] var builds []buildkite.Build for _, branch := range branches { builds = append(builds, buildkite.Build{Number: 1, State: "passed", Branch: branch}) } json.NewEncoder(w).Encode(builds) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true, DryRun: true} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } // Branch should still exist after dry run. refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if !strings.Contains(refs, "bk/preflight/dry-run-test") { t.Error("expected branch to be preserved during dry run") } }) t.Run("stops processing when context is cancelled", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") worktree := initTestRepo(t) t.Chdir(worktree) createPreflightBranch(t, worktree, "bk/preflight/cancel-a") createPreflightBranch(t, worktree, "bk/preflight/cancel-b") // Override notifyContext to return an already-cancelled context so // the API call in ResolveBuilds returns context.Canceled immediately. originalNotify := notifyContext notifyContext = func(parent context.Context, _ ...os.Signal) (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(parent) cancel() return ctx, cancel } t.Cleanup(func() { notifyContext = originalNotify }) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error on cancellation, got: %v", err) } // Both branches should still exist since cleanup was interrupted. refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if !strings.Contains(refs, "bk/preflight/cancel-a") { t.Error("expected cancel-a to be preserved after cancellation") } if !strings.Contains(refs, "bk/preflight/cancel-b") { t.Error("expected cancel-b to be preserved after cancellation") } }) t.Run("preflight-uuid targets a single branch and leaves others alone", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") worktree := initTestRepo(t) t.Chdir(worktree) const targetUUID = "01935b62-0000-7000-8000-000000000001" const otherUUID = "01935b62-0000-7000-8000-000000000002" targetBranch := "bk/preflight/" + targetUUID otherBranch := "bk/preflight/" + otherUUID createPreflightBranch(t, worktree, targetBranch) createPreflightBranch(t, worktree, otherBranch) var queriedBranches []string s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds") { queriedBranches = append(queriedBranches, r.URL.Query()["branch[]"]...) var builds []buildkite.Build for _, branch := range r.URL.Query()["branch[]"] { builds = append(builds, buildkite.Build{Number: 1, State: "passed", Branch: branch}) } json.NewEncoder(w).Encode(builds) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true, PreflightUUID: targetUUID} if err := cmd.Run(nil, stubGlobals{}); err != nil { t.Fatalf("expected no error, got: %v", err) } // Only the targeted branch should have been queried for build state. if len(queriedBranches) != 1 || queriedBranches[0] != targetBranch { t.Errorf("expected build lookup scoped to %s, got %v", targetBranch, queriedBranches) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, targetBranch) { t.Errorf("expected targeted branch %s to be deleted", targetBranch) } if !strings.Contains(refs, otherBranch) { t.Errorf("expected untargeted branch %s to be preserved", otherBranch) } }) t.Run("preflight-uuid with invalid UUID returns validation error", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") cmd := &CleanupCmd{Pipeline: "test-org/test-pipeline", Text: true, PreflightUUID: "not-a-uuid"} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected validation error, got nil") } if !bkErrors.IsValidationError(err) { t.Fatalf("expected validation error, got %T: %v", err, err) } }) t.Run("preflight-uuid with no matching branch exits cleanly", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") worktree := initTestRepo(t) t.Chdir(worktree) // An unrelated preflight branch exists, but the targeted one does not. createPreflightBranch(t, worktree, "bk/preflight/01935b62-0000-7000-8000-000000000003") s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("unexpected API request: %s %s", r.Method, r.URL.Path) http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) cmd := &CleanupCmd{ Pipeline: "test-org/test-pipeline", Text: true, PreflightUUID: "01935b62-0000-7000-8000-0000000000ff", } if err := cmd.Run(nil, stubGlobals{}); err != nil { t.Fatalf("expected no error, got: %v", err) } // The unrelated branch should still exist. refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if !strings.Contains(refs, "bk/preflight/01935b62-0000-7000-8000-000000000003") { t.Error("expected unrelated preflight branch to be preserved") } }) } // createPreflightBranch creates a preflight branch on the remote by pushing a commit. func createPreflightBranch(t *testing.T, worktree, branch string) { t.Helper() // Create a file and commit it on a temporary local branch. file := filepath.Join(worktree, "preflight-marker.txt") if err := os.WriteFile(file, []byte(branch+"\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, worktree, "add", "preflight-marker.txt") runGit(t, worktree, "commit", "-m", "preflight snapshot for "+branch) // Push HEAD to the preflight branch on origin, then reset back. commit := runGit(t, worktree, "rev-parse", "HEAD") runGit(t, worktree, "push", "origin", commit+":refs/heads/"+branch) runGit(t, worktree, "reset", "--hard", "HEAD~1") } ================================================ FILE: cmd/preflight/event.go ================================================ package preflight import ( "time" "github.com/buildkite/cli/v3/internal/build/watch" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" buildkite "github.com/buildkite/go-buildkite/v4" ) // EventType identifies the kind of preflight event. type EventType string const ( EventOperation EventType = "operation" EventBuildStatus EventType = "build_status" EventJobFailure EventType = "job_failure" EventJobRetryPassed EventType = "job_retry_passed" EventBuildSummary EventType = "build_summary" EventTestFailure EventType = "test_failure" ) // Event is the single data model emitted by a preflight run. // Renderers project events differently by output mode (TTY, text, JSON). type Event struct { Type EventType `json:"type"` Time time.Time `json:"timestamp"` PreflightID string `json:"preflight_id,omitempty"` // Title is the primary status text shown in the TTY dynamic area. Title string `json:"title,omitempty"` // Detail is supplementary information printed to the scrollback log. Detail string `json:"detail,omitempty"` Pipeline string `json:"pipeline,omitempty"` BuildNumber int `json:"build_number,omitempty"` BuildURL string `json:"build_url,omitempty"` BuildState string `json:"build_state,omitempty"` // Incomplete is set for build_summary events when the CLI stops before a terminal build state. Incomplete bool `json:"incomplete,omitempty"` // StopReason describes why the summary was emitted early. StopReason string `json:"stop_reason,omitempty"` // BuildCanceled is set when the CLI attempted early-exit cleanup that cancels the remote build. BuildCanceled *bool `json:"build_canceled,omitempty"` Jobs *watch.JobSummary `json:"jobs,omitempty"` // Job is set for job_failure and job_retry_passed events. Job *buildkite.Job `json:"job,omitempty"` // FailedJobs is set for build_summary events when the build failed. Contains hard-failed jobs only (soft failures excluded). FailedJobs []buildkite.Job `json:"failed_jobs,omitempty"` // PassedJobs is set for build_summary events when the build passed and has 10 or fewer jobs. PassedJobs []buildkite.Job `json:"passed_jobs,omitempty"` // Duration is set for build_summary events. Total elapsed time of the preflight run. Duration time.Duration `json:"duration_ns,omitempty"` // TestFailures is set for test_failure events. TestFailures []buildkite.BuildTest `json:"test_failures,omitempty"` // Tests is set for build_summary events when aggregated test summary data is available. Tests internalpreflight.SummaryTests `json:"tests,omitempty"` } func newBuildSummaryEvent(preflightID, pipeline string, buildNumber int, buildURL string, finalBuild buildkite.Build, startedAt time.Time) Event { return Event{ Type: EventBuildSummary, Time: time.Now(), PreflightID: preflightID, Pipeline: pipeline, BuildNumber: buildNumber, BuildURL: buildURL, BuildState: finalBuild.State, Duration: time.Since(startedAt), } } func (e *Event) ApplySummaryMeta(meta summaryMeta) { e.Incomplete = meta.Incomplete e.StopReason = meta.StopReason if meta.StopReason == "" { return } buildCanceled := meta.BuildCanceled e.BuildCanceled = &buildCanceled } func (e *Event) ApplyJobResults(finalBuild buildkite.Build, tracker *watch.JobTracker) { if NewResult(finalBuild).Passed() { if passed := tracker.PassedJobs(); len(passed) <= 10 { e.PassedJobs = passed } return } e.FailedJobs = tracker.FailedJobs() } ================================================ FILE: cmd/preflight/event_test.go ================================================ package preflight import ( "testing" "time" "github.com/buildkite/cli/v3/internal/build/watch" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestEvent_Operation(t *testing.T) { e := Event{ Type: EventOperation, Time: time.Now(), PreflightID: "preflight-123", Title: "Creating snapshot of working tree...", } if e.Type != EventOperation { t.Fatalf("expected EventOperation, got %q", e.Type) } if e.Title == "" { t.Fatal("expected Title to be set") } if e.BuildState != "" { t.Fatal("expected BuildState to be empty for operation event") } } func TestEvent_BuildStatus(t *testing.T) { e := Event{ Type: EventBuildStatus, Time: time.Now(), PreflightID: "preflight-123", Pipeline: "buildkite/cli", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", BuildState: "running", Jobs: &watch.JobSummary{ Passed: 8, Running: 3, }, } if e.Type != EventBuildStatus { t.Fatalf("expected EventBuildStatus, got %q", e.Type) } if e.BuildNumber != 42 { t.Fatalf("expected BuildNumber 42, got %d", e.BuildNumber) } if e.Jobs.Passed != 8 { t.Fatalf("expected 8 passed, got %d", e.Jobs.Passed) } } func TestEvent_JobFailure(t *testing.T) { e := Event{ Type: EventJobFailure, Time: time.Now(), PreflightID: "preflight-123", Pipeline: "buildkite/cli", BuildNumber: 42, BuildState: "failing", Job: &buildkite.Job{ ID: "job-1", Name: "Lint", State: "failed", }, } if e.Type != EventJobFailure { t.Fatalf("expected EventJobFailure, got %q", e.Type) } if e.Job == nil { t.Fatal("expected Job to be set") } if e.Job.ID != "job-1" { t.Fatalf("expected job ID job-1, got %q", e.Job.ID) } } func TestEvent_BuildSummaryStoppedEarly(t *testing.T) { buildCanceled := false e := Event{ Type: EventBuildSummary, Time: time.Now(), PreflightID: "preflight-123", Pipeline: "buildkite/cli", BuildNumber: 42, BuildState: "failing", Incomplete: true, StopReason: "build-failing", BuildCanceled: &buildCanceled, } if e.Type != EventBuildSummary { t.Fatalf("expected EventBuildSummary, got %q", e.Type) } if !e.Incomplete { t.Fatal("expected Incomplete to be set") } if e.StopReason != "build-failing" { t.Fatalf("expected stop reason build-failing, got %q", e.StopReason) } if e.BuildCanceled == nil || *e.BuildCanceled { t.Fatalf("expected BuildCanceled=false, got %#v", e.BuildCanceled) } } ================================================ FILE: cmd/preflight/job_presenter.go ================================================ package preflight import ( "fmt" "github.com/buildkite/cli/v3/internal/build/watch" "github.com/buildkite/cli/v3/internal/emoji" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/charmbracelet/lipgloss" ) type jobPresenter struct { pipeline string buildNumber int buildURL string } func (p jobPresenter) failParts(j buildkite.Job, action string) (symbol, name, detail string) { job := watch.NewFormattedJob(j) name = job.DisplayName() var status string switch { case job.IsSoftFailed(): status = "soft failed" default: status = j.State } if j.ExitStatus != nil && *j.ExitStatus != 0 { status += fmt.Sprintf(" with exit %d", *j.ExitStatus) } symbol = "✗" if job.IsSoftFailed() { symbol = "⚠" } detail = fmt.Sprintf("%s - %s", status, action) return symbol, name, detail } func (p jobPresenter) Line(j buildkite.Job) string { symbol, name, detail := p.failParts(j, jobLogCommand(p.pipeline, p.buildNumber, j.ID)) return fmt.Sprintf("%s %s %s", symbol, name, detail) } func (p jobPresenter) PassedLine(j buildkite.Job) string { name := watch.NewFormattedJob(j).DisplayName() return fmt.Sprintf("✔ %s", name) } func (p jobPresenter) RetryPassedLine(j buildkite.Job) string { name := watch.NewFormattedJob(j).DisplayName() return fmt.Sprintf("✔ %s passed on retry (attempt %d)", name, j.RetriesCount+1) } func (p jobPresenter) ColoredRetryPassedLine(j buildkite.Job) string { emojiPrefix, textName := emoji.Split(watch.NewFormattedJob(j).DisplayName()) style := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) detail := fmt.Sprintf("passed on retry (attempt %d)", j.RetriesCount+1) if emojiPrefix != "" { return style.Render("✔ ") + emoji.Render(emojiPrefix) + " " + style.Render(fmt.Sprintf("%s %s", textName, detail)) } return style.Render(fmt.Sprintf("✔ %s %s", textName, detail)) } func (p jobPresenter) ColoredPassedLine(j buildkite.Job, style lipgloss.Style) string { emojiPrefix, textName := emoji.Split(watch.NewFormattedJob(j).DisplayName()) if emojiPrefix != "" { return style.Render("✔ ") + emoji.Render(emojiPrefix) + " " + style.Render(textName) } return style.Render(fmt.Sprintf("✔ %s", textName)) } // ColoredLine renders emoji outside the ANSI colour span to avoid // Kitty/iTerm2 graphics escape sequences breaking lipgloss styling. func (p jobPresenter) ColoredLine(j buildkite.Job) string { job := watch.NewFormattedJob(j) symbol, name, detail := p.failParts(j, p.jobLink(j)) emojiPrefix, textName := emoji.Split(name) style := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) if job.IsSoftFailed() { style = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) } if emojiPrefix != "" { return style.Render(symbol+" ") + emoji.Render(emojiPrefix) + " " + style.Render(fmt.Sprintf("%s %s", textName, detail)) } return style.Render(fmt.Sprintf("%s %s %s", symbol, textName, detail)) } func (p jobPresenter) jobLink(j buildkite.Job) string { url := j.WebURL if url == "" && p.buildURL != "" && j.ID != "" { url = p.buildURL + "#" + j.ID } if url == "" { return jobLogCommand(p.pipeline, p.buildNumber, j.ID) } return terminalHyperlink("\033[4:4mView job\033[24m", url) } ================================================ FILE: cmd/preflight/job_presenter_test.go ================================================ package preflight import ( "strings" "testing" "time" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestJobPresenter_FailedLine(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} exitStatus := 1 line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 183663, }.Line(scriptJob("failed-windows-smoke-tests", "Windows smoke tests", "failed", false, &startedAt, &finishedAt, &exitStatus)) assertStringContainsAll(t, line, []string{ "✗ Windows smoke tests", "failed with exit 1", "- bk job log -b 183663 -p buildkite/cli failed-windows-smoke-tests", }) } func TestJobPresenter_SoftFailedLine(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} line := jobPresenter{ pipeline: "buildkite", buildNumber: 183663, }.Line(scriptJob("failed-2", "Bundle Audit", "failed", true, &startedAt, &finishedAt, nil)) assertStringContainsAll(t, line, []string{ "⚠ Bundle Audit", "soft failed", "- bk job log -b 183663 -p buildkite failed-2", }) } func TestJobPresenter_FailedNoExit(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.Line(scriptJob("job-1", "Lint", "failed", false, &startedAt, &finishedAt, nil)) assertStringContainsAll(t, line, []string{ "✗ Lint", "failed", "- bk job log -b 42 -p buildkite/cli job-1", }) if strings.Contains(line, "with exit") { t.Fatalf("did not expect exit status when nil: %q", line) } } func TestJobPresenter_PassedLine(t *testing.T) { line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.PassedLine(buildkite.Job{ID: "j1", Name: "Lint", Type: "script", State: "passed"}) assertStringContainsAll(t, line, []string{"✔ Lint"}) } func TestJobPresenter_PassedLine_WithEmoji(t *testing.T) { line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.PassedLine(buildkite.Job{ID: "j1", Name: ":checkered_flag: Feature flags", Type: "script", State: "passed"}) if !strings.Contains(line, "✔") { t.Fatalf("expected check mark in %q", line) } if !strings.Contains(line, "Feature flags") { t.Fatalf("expected job name in %q", line) } } func TestJobPresenter_RetryPassedLine(t *testing.T) { line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.RetryPassedLine(buildkite.Job{ID: "retry-1", Name: "Lint", Type: "script", State: "passed", RetriesCount: 1}) assertStringContainsAll(t, line, []string{"✔ Lint", "passed on retry", "attempt 2"}) } func TestJobPresenter_RetryPassedLine_MultipleRetries(t *testing.T) { line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.RetryPassedLine(buildkite.Job{ID: "retry-2", Name: "Test", Type: "script", State: "passed", RetriesCount: 2}) assertStringContainsAll(t, line, []string{"✔ Test", "passed on retry", "attempt 3"}) } func TestJobPresenter_ColoredRetryPassedLine(t *testing.T) { line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.ColoredRetryPassedLine(buildkite.Job{ID: "retry-1", Name: "Lint", Type: "script", State: "passed", RetriesCount: 1}) assertStringContainsAll(t, line, []string{"✔", "Lint", "passed on retry", "attempt 2"}) } func TestJobPresenter_ColoredRetryPassedLine_WithEmoji(t *testing.T) { line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.ColoredRetryPassedLine(buildkite.Job{ID: "retry-1", Name: ":docker: Build image", Type: "script", State: "passed", RetriesCount: 1}) assertStringContainsAll(t, line, []string{"✔", "Build image", "passed on retry"}) } func TestJobPresenter_ColoredLine(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} exitStatus := 1 line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.ColoredLine(scriptJob("job-1", "Test", "failed", false, &startedAt, &finishedAt, &exitStatus)) assertStringContainsAll(t, line, []string{"✗", "Test", "failed with exit 1"}) } func TestJobPresenter_ColoredLine_UsesClickableJobLink(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} exitStatus := 1 job := scriptJob("job-1", "Test", "failed", false, &startedAt, &finishedAt, &exitStatus) job.WebURL = "https://buildkite.com/buildkite/cli/builds/42#job-1" line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.ColoredLine(job) assertStringContainsAll(t, line, []string{"✗", "Test", "failed with exit 1 - ", "\033[4:4mView job\033[24m", job.WebURL}) if strings.Contains(line, "bk job log") { t.Fatalf("expected clickable job link instead of job log command: %q", line) } } func TestJobPresenter_ColoredLine_DerivesClickableJobLinkFromBuildURL(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} exitStatus := 1 line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, buildURL: "https://buildkite.com/buildkite/cli/builds/42", }.ColoredLine(scriptJob("job-1", "Test", "failed", false, &startedAt, &finishedAt, &exitStatus)) assertStringContainsAll(t, line, []string{"View job", "https://buildkite.com/buildkite/cli/builds/42#job-1"}) if strings.Contains(line, "bk job log") { t.Fatalf("expected derived clickable job link instead of job log command: %q", line) } } func TestJobPresenter_ColoredLine_SoftFailed(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.ColoredLine(scriptJob("job-1", "Audit", "failed", true, &startedAt, &finishedAt, nil)) assertStringContainsAll(t, line, []string{"⚠", "Audit", "soft failed"}) } func TestJobPresenter_ColoredLine_WithEmoji(t *testing.T) { startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)} finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)} exitStatus := 1 line := jobPresenter{ pipeline: "buildkite/cli", buildNumber: 42, }.ColoredLine(scriptJob("job-1", ":docker: Build image", "failed", false, &startedAt, &finishedAt, &exitStatus)) assertStringContainsAll(t, line, []string{"✗", "Build image", "failed with exit 1"}) } func assertStringContainsAll(t *testing.T, got string, want []string) { t.Helper() for _, needle := range want { if !strings.Contains(got, needle) { t.Fatalf("missing %q in %q", needle, got) } } } ================================================ FILE: cmd/preflight/preflight.go ================================================ package preflight import ( "context" "errors" "fmt" "net/http" "os" "os/signal" "strconv" "strings" "syscall" "time" "github.com/alecthomas/kong" "github.com/google/uuid" "github.com/buildkite/cli/v3/cmd/version" buildstate "github.com/buildkite/cli/v3/internal/build/state" "github.com/buildkite/cli/v3/internal/build/watch" "github.com/buildkite/cli/v3/internal/cli" internalconfig "github.com/buildkite/cli/v3/internal/config" bkErrors "github.com/buildkite/cli/v3/internal/errors" bkhttp "github.com/buildkite/cli/v3/internal/http" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/internal/pipeline/resolver" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" ) type RunCmd struct { Pipeline string `help:"The pipeline to build. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Watch bool `help:"Watch the build until completion." default:"true" negatable:""` ExitOn []internalpreflight.ExitPolicy `help:"Exit when a condition is met. Options: build-failing (default, exits when a build enters the failing state), build-terminal (exits when the build reaches a terminal state)."` Interval float64 `help:"Polling interval in seconds when watching." default:"2"` NoCleanup bool `help:"Skip cleanup after completion or early exit. The preflight branch remains and the build keeps running if exiting early."` AwaitTestResults awaitTestResultsFlag `name:"await-test-results" help:"After the build finishes, wait for test results to be processed by Test Engine. Provide a duration like 10s, or omit the value to wait 30s."` Text bool `help:"Use plain text output instead of interactive terminal UI." xor:"output"` JSON bool `help:"Emit one JSON object per event (JSONL)." xor:"output"` } var ( notifyContext = signal.NotifyContext newFactory = factory.New rendererFactory = newRenderer errExitOnBuildFailing = errors.New("exit-on build-failing") ) const defaultAwaitTestResultsDuration = 30 * time.Second func HelpText() string { return `Preflight is an experimental preview and subject to change without notice. Snapshots your working tree (staged, unstaged, and untracked files) to a temporary commit on a bk/preflight/<id> branch, triggers a build on the selected pipeline, monitors failures, exits as soon as the build starts failing, and cleans up the temporary branch when finished.` } type summaryMeta struct { Incomplete bool StopReason string BuildCanceled bool } func preflightUserAgentSuffix() string { major := strings.TrimPrefix(version.Version, "v") if i := strings.IndexByte(major, '.'); i >= 0 { major = major[:i] } if major == "" || major == "DEV" { major = "DEV" } return "buildkite-cli-preflight/" + major + ".x" } type awaitTestResultsFlag struct { Enabled bool Duration time.Duration } func (f *awaitTestResultsFlag) Decode(ctx *kong.DecodeContext) error { var value string if err := ctx.Scan.PopValueInto("duration", &value); err != nil { f.Enabled = true f.Duration = defaultAwaitTestResultsDuration return nil } duration, err := time.ParseDuration(value) if err != nil { return err } f.Enabled = true f.Duration = duration return nil } func (f awaitTestResultsFlag) IsBool() bool { return true } func (c *RunCmd) Help() string { return HelpText() } func (c *RunCmd) Validate() error { if c.Interval <= 0 { return bkErrors.NewValidationError(fmt.Errorf("interval must be greater than 0"), "invalid polling interval") } return internalpreflight.ValidateExitPolicies(c.ExitOn, c.Watch) } func (c *RunCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { if err := c.Validate(); err != nil { return err } exitPolicy := internalpreflight.EffectiveExitPolicy(c.ExitOn) pCtx, err := setup(c.Pipeline, globals) if err != nil { return err } defer pCtx.Stop() f := pCtx.Factory repoRoot := pCtx.RepoRoot resolvedPipeline := pCtx.Pipeline ctx := pCtx.Ctx stop := pCtx.Stop rlTransport := pCtx.RateLimitTransport preflightID, err := uuid.NewV7() if err != nil { return bkErrors.NewInternalError(err, "UUIDv7 generation failed") } startedAt := time.Now() sourceContext, err := internalpreflight.ResolveSourceContext(repoRoot, globals.EnableDebug()) if err != nil { return bkErrors.NewValidationError( err, "failed to resolve preflight source git context", "Ensure the repository has at least one commit", ) } renderer := rendererFactory(os.Stdout, c.JSON, c.Text, stop) defer renderer.Close() rlTransport.OnRateLimit = func(attempt int, delay time.Duration) { if globals.EnableDebug() { _ = renderer.Render(Event{ Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Rate limited by API, waiting %s before retrying (attempt %d/%d)...", delay.Truncate(time.Second), attempt+1, rlTransport.MaxRetries), }) } } _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: "Pushing snapshot of working tree..."}) var opts []internalpreflight.SnapshotOption if globals.EnableDebug() { opts = append(opts, internalpreflight.WithDebug()) } result, err := internalpreflight.SnapshotContext(ctx, repoRoot, preflightID, opts...) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) { return bkErrors.NewUserAbortedError(context.Canceled, "preflight canceled by user") } return bkErrors.NewSnapshotError( err, "failed to create preflight snapshot", "Ensure you have uncommitted or committed changes to snapshot", "Ensure you have push access to the remote repository", ) } snapshotDetail := fmt.Sprintf("Commit: %s\nRef: %s", result.ShortCommit(), result.Ref) if len(result.Files) > 0 { snapshotDetail += fmt.Sprintf("\nFiles: %d changed", len(result.Files)) for _, file := range result.Files { snapshotDetail += fmt.Sprintf("\n %s %s", file.StatusSymbol(), file.Path) } } _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: "Pushed snapshot of working tree...", Detail: snapshotDetail}) cleanupBranch := func() { if c.NoCleanup { return } cleanupRemoteBranch(renderer, repoRoot, result.Branch, result.Ref, preflightID.String(), globals.EnableDebug()) } _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Creating build on %s/%s...", resolvedPipeline.Org, resolvedPipeline.Name)}) env := map[string]string{ "PREFLIGHT": "true", "BUILDKITE_PREFLIGHT": "true", // deprecated "PREFLIGHT_SOURCE_COMMIT": sourceContext.Commit, } if sourceContext.Branch != "" { env["PREFLIGHT_SOURCE_BRANCH"] = sourceContext.Branch } build, _, err := f.RestAPIClient.Builds.Create(ctx, resolvedPipeline.Org, resolvedPipeline.Name, buildkite.CreateBuild{ Message: fmt.Sprintf("Preflight %s", preflightID), Commit: result.Commit, Branch: result.Branch, Env: env, }) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) { cleanupBranch() return bkErrors.NewUserAbortedError(context.Canceled, "preflight canceled by user") } return bkErrors.WrapAPIError(err, "creating preflight build") } pipelineName := fmt.Sprintf("%s/%s", resolvedPipeline.Org, resolvedPipeline.Name) _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Created build on %s/%s...", resolvedPipeline.Org, resolvedPipeline.Name), Detail: fmt.Sprintf("Build: %s", build.WebURL)}) if !c.Watch { return nil } interval := time.Duration(c.Interval * float64(time.Second)) tracker := watch.NewJobTracker() finalBuild, err := watch.WatchBuild(ctx, f.RestAPIClient, resolvedPipeline.Org, resolvedPipeline.Name, build.Number, interval, func(b buildkite.Build) error { status := tracker.Update(b) for _, failed := range status.NewlyFailed { if err := renderer.Render(Event{ Type: EventJobFailure, Time: time.Now(), PreflightID: preflightID.String(), Pipeline: pipelineName, BuildNumber: build.Number, BuildURL: build.WebURL, Job: &failed, }); err != nil { return err } } for _, retryPassed := range status.NewlyRetryPassed { if err := renderer.Render(Event{ Type: EventJobRetryPassed, Time: time.Now(), PreflightID: preflightID.String(), Pipeline: pipelineName, BuildNumber: build.Number, BuildURL: build.WebURL, Job: &retryPassed, }); err != nil { return err } } if err := renderer.Render(Event{ Type: EventBuildStatus, Time: time.Now(), PreflightID: preflightID.String(), Pipeline: pipelineName, BuildNumber: build.Number, BuildURL: build.WebURL, BuildState: b.State, Jobs: &status.Summary, }); err != nil { return err } if exitPolicy == internalpreflight.ExitOnBuildFailing && buildstate.State(b.State) == buildstate.Failing { return errExitOnBuildFailing } return nil }, watch.WithRetriedJobs()) finalErr := NewResult(finalBuild).Error() if errors.Is(err, context.Canceled) { cleanupBranch() if finalBuild.FinishedAt == nil && !buildstate.IsTerminal(buildstate.State(finalBuild.State)) && !c.NoCleanup { cancelBuild(f, renderer, resolvedPipeline.Org, resolvedPipeline.Name, build.Number, preflightID.String(), globals.EnableDebug()) } return bkErrors.NewUserAbortedError(context.Canceled, "preflight canceled by user") } if errors.Is(err, errExitOnBuildFailing) { buildCanceled := false if !c.NoCleanup { buildCanceled = cancelBuild(f, renderer, resolvedPipeline.Org, resolvedPipeline.Name, build.Number, preflightID.String(), globals.EnableDebug()) } summaryEvent := newBuildSummaryEvent(preflightID.String(), pipelineName, build.Number, build.WebURL, finalBuild, startedAt) summaryEvent.ApplySummaryMeta(summaryMeta{Incomplete: true, StopReason: "build-failing", BuildCanceled: buildCanceled}) summaryEvent.ApplyJobResults(finalBuild, tracker) showResult, showErr := c.loadFinalResult(ctx, f.RestAPIClient, resolvedPipeline.Org, resolvedPipeline.Name, build.Number) if showErr == nil { summaryEvent.Tests = showResult.Tests } else if globals.EnableDebug() { _ = renderer.Render(Event{ Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Debug: failed to load final test summary: %v", showErr), }) } _ = renderer.Render(summaryEvent) cleanupBranch() return finalErr } // Emit a final summary showing pass/fail, passed jobs (if ≤10), or hard-failed jobs. if buildstate.IsTerminal(buildstate.State(finalBuild.State)) { summaryEvent := newBuildSummaryEvent(preflightID.String(), pipelineName, build.Number, build.WebURL, finalBuild, startedAt) summaryEvent.ApplySummaryMeta(summaryMeta{}) summaryEvent.ApplyJobResults(finalBuild, tracker) showResult, showErr := c.loadFinalResult(ctx, f.RestAPIClient, resolvedPipeline.Org, resolvedPipeline.Name, build.Number) if showErr == nil { summaryEvent.Tests = showResult.Tests } else if globals.EnableDebug() { _ = renderer.Render(Event{ Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Debug: failed to load final test summary: %v", showErr), }) } _ = renderer.Render(summaryEvent) } cleanupBranch() if err != nil { return bkErrors.NewInternalError( err, "watching build failed", "Buildkite API may be unavailable or your network may be unstable", "Retry the preflight command once connectivity is restored", ) } return finalErr } func cleanupRemoteBranch(renderer renderer, repoRoot, branch, ref, preflightID string, debug bool) { _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID, Title: fmt.Sprintf("Cleaning up remote branch %s...", branch)}) if cleanupErr := internalpreflight.Cleanup(repoRoot, ref, debug); cleanupErr != nil { _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID, Title: fmt.Sprintf("Warning: failed to delete remote branch %s: %v", ref, cleanupErr)}) return } _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID, Title: fmt.Sprintf("Deleted remote branch %s", branch)}) } func cancelBuild(f *factory.Factory, renderer renderer, org, pipeline string, buildNumber int, preflightID string, debug bool) bool { cancelCtx, cancelStop := context.WithTimeout(context.Background(), 5*time.Second) defer cancelStop() if _, err := f.RestAPIClient.Builds.Cancel(cancelCtx, org, pipeline, strconv.Itoa(buildNumber)); err != nil { var apiErr *buildkite.ErrorResponse if errors.As(err, &apiErr) && apiErr.Response.StatusCode == http.StatusUnprocessableEntity && apiErr.Message == "Build can't be canceled because it's already finished." { if debug { _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID, Title: fmt.Sprintf("Debug: build #%d already finished, skipping cancel", buildNumber)}) } return false } _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID, Title: fmt.Sprintf("Warning: failed to cancel build #%d: %v", buildNumber, err)}) return false } _ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID, Title: fmt.Sprintf("Cancelled build #%d", buildNumber)}) return true } func (c *RunCmd) loadFinalResult(ctx context.Context, client *buildkite.Client, org, pipeline string, buildNumber int) (internalpreflight.SummaryResult, error) { buildWithTests, _, buildErr := client.Builds.Get(ctx, org, pipeline, strconv.Itoa(buildNumber), &buildkite.BuildGetOptions{IncludeTestEngine: true}) expectTestSummary := buildErr == nil && buildWithTests.TestEngine != nil && len(buildWithTests.TestEngine.Runs) > 0 if buildErr != nil { return internalpreflight.SummaryResult{}, buildErr } if !c.AwaitTestResults.Enabled || c.AwaitTestResults.Duration <= 0 { return c.loadSummary(ctx, client, org, buildWithTests.ID) } if !expectTestSummary { return internalpreflight.SummaryResult{Tests: internalpreflight.SummaryTests{Runs: map[string]internalpreflight.SummaryTestRun{}, Failures: []internalpreflight.SummaryTestFailure{}}}, nil } timer := time.NewTimer(c.AwaitTestResults.Duration) defer timer.Stop() select { case <-ctx.Done(): return internalpreflight.SummaryResult{}, ctx.Err() case <-timer.C: } return c.loadSummary(ctx, client, org, buildWithTests.ID) } func (c *RunCmd) loadSummary(ctx context.Context, client *buildkite.Client, org, buildID string) (internalpreflight.SummaryResult, error) { if buildID == "" { return internalpreflight.SummaryResult{Tests: internalpreflight.SummaryTests{Runs: map[string]internalpreflight.SummaryTestRun{}, Failures: []internalpreflight.SummaryTestFailure{}}}, nil } summary, err := internalpreflight.NewRunSummaryService(client).Get(ctx, org, buildID, &internalpreflight.RunSummaryGetOptions{ Result: "^failed", State: "enabled", IncludeFailures: true, }) if err != nil { return internalpreflight.SummaryResult{}, err } return summary.SummaryResult(), nil } // preflightContext holds the common dependencies for preflight subcommands. type preflightContext struct { Factory *factory.Factory RepoRoot string Pipeline *pipeline.Pipeline Ctx context.Context Stop context.CancelFunc RateLimitTransport *bkhttp.RateLimitTransport } // setup initializes the common preflight dependencies: factory, experiment // gate, repository root, signal context, and pipeline resolution. func setup(pipelineFlag string, globals cli.GlobalFlags) (*preflightContext, error) { rlTransport := bkhttp.NewRateLimitTransport(http.DefaultTransport) f, err := newFactory( factory.WithDebug(globals.EnableDebug()), factory.WithTransport(rlTransport), factory.WithUserAgentSuffix(preflightUserAgentSuffix()), ) if err != nil { return nil, bkErrors.NewInternalError(err, "failed to initialize CLI", "This is likely a bug", "Report to Buildkite") } if !f.Config.HasExperiment(internalconfig.ExperimentPreflight) { return nil, bkErrors.NewValidationError( fmt.Errorf("experiment not enabled"), "preflight is disabled by the current experiments override. Add `preflight` to `BUILDKITE_EXPERIMENTS` or run `bk config set experiments preflight` to re-enable it", ) } repoRoot, err := resolveRepositoryRoot(f, globals.EnableDebug()) if err != nil { return nil, bkErrors.NewValidationError( fmt.Errorf("not in a git repository: %w", err), "preflight must be run from a git repository", "Run this command from inside a git repository", ) } ctx, stop := notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) resolvers := resolver.NewAggregateResolver( resolver.ResolveFromFlag(pipelineFlag, f.Config), resolver.ResolveFromConfig(f.Config, resolver.PickOneWithFactory(f)), resolver.ResolveFromRepository(f, resolver.CachedPicker(f.Config, resolver.PickOneWithFactory(f))), ) resolvedPipeline, err := resolvers.Resolve(ctx) if err != nil { stop() return nil, bkErrors.NewValidationError( err, "could not resolve a pipeline", "Specify a pipeline with --pipeline or link your repository to a pipeline", ) } return &preflightContext{ Factory: f, RepoRoot: repoRoot, Pipeline: resolvedPipeline, Ctx: ctx, Stop: stop, RateLimitTransport: rlTransport, }, nil } func resolveRepositoryRoot(f *factory.Factory, debug bool) (string, error) { if f.GitRepository != nil { wt, err := f.GitRepository.Worktree() if err == nil { return wt.Filesystem.Root(), nil } } return internalpreflight.RepositoryRoot(".", debug) } ================================================ FILE: cmd/preflight/preflight_test.go ================================================ package preflight import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/buildkite/cli/v3/internal/config" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/buildkite/cli/v3/internal/build/watch" bkErrors "github.com/buildkite/cli/v3/internal/errors" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type stubGlobals struct{} func (s stubGlobals) SkipConfirmation() bool { return false } func (s stubGlobals) DisableInput() bool { return false } func (s stubGlobals) IsQuiet() bool { return false } func (s stubGlobals) DisablePager() bool { return false } func (s stubGlobals) EnableDebug() bool { return false } var _ cli.GlobalFlags = stubGlobals{} func unsetEnv(t *testing.T, key string) { t.Helper() original, had := os.LookupEnv(key) if err := os.Unsetenv(key); err != nil { t.Fatalf("failed to unset env %s: %v", key, err) } t.Cleanup(func() { var err error if had { err = os.Setenv(key, original) } else { err = os.Unsetenv(key) } if err != nil { t.Fatalf("failed to restore env %s: %v", key, err) } }) } func TestParseExitConditions(t *testing.T) { tests := []struct { name string cmd RunCmd wantPolicy internalpreflight.ExitPolicy wantErrText string }{ {name: "defaults to build-failing", cmd: RunCmd{Watch: true, Interval: 1}, wantPolicy: internalpreflight.ExitOnBuildFailing}, {name: "accepts build-failing", cmd: RunCmd{Watch: true, Interval: 1, ExitOn: []internalpreflight.ExitPolicy{internalpreflight.ExitOnBuildFailing}}, wantPolicy: internalpreflight.ExitOnBuildFailing}, {name: "accepts build-terminal", cmd: RunCmd{Watch: true, Interval: 1, ExitOn: []internalpreflight.ExitPolicy{internalpreflight.ExitOnBuildTerminal}}, wantPolicy: internalpreflight.ExitOnBuildTerminal}, {name: "accepts repeated build-terminal", cmd: RunCmd{Watch: true, Interval: 1, ExitOn: []internalpreflight.ExitPolicy{internalpreflight.ExitOnBuildTerminal, internalpreflight.ExitOnBuildTerminal}}, wantPolicy: internalpreflight.ExitOnBuildTerminal}, {name: "rejects mixed lifecycle policies", cmd: RunCmd{Watch: true, Interval: 1, ExitOn: []internalpreflight.ExitPolicy{internalpreflight.ExitOnBuildFailing, internalpreflight.ExitOnBuildTerminal}}, wantErrText: "build-failing and build-terminal cannot be used together"}, {name: "rejects exit-on when watch disabled", cmd: RunCmd{Watch: false, Interval: 1, ExitOn: []internalpreflight.ExitPolicy{internalpreflight.ExitOnBuildFailing}}, wantErrText: "--exit-on requires --watch"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.cmd.Validate() if tt.wantErrText != "" { if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, bkErrors.ErrValidation) { t.Fatalf("expected validation error, got %T: %v", err, err) } if !strings.Contains(err.Error(), tt.wantErrText) { t.Fatalf("expected error containing %q, got %q", tt.wantErrText, err.Error()) } return } if err != nil { t.Fatalf("expected no error, got %v", err) } got := internalpreflight.EffectiveExitPolicy(tt.cmd.ExitOn) if got != tt.wantPolicy { t.Fatalf("policy = %v, want %v", got, tt.wantPolicy) } }) } } func TestPreflightCmd_Run(t *testing.T) { t.Run("returns validation error when experiment disabled", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "alpha") cmd := &RunCmd{Interval: 1} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } var bkErr *bkErrors.Error if !errors.As(err, &bkErr) { t.Fatalf("expected bkErrors.Error, got %T: %v", err, err) } if !errors.Is(bkErr, bkErrors.ErrValidation) { t.Errorf("expected ErrValidation, got category: %v", bkErr.Category) } if !strings.Contains(bkErr.Details, "preflight is disabled") { t.Errorf("expected disabled experiment validation, got details %q", bkErr.Details) } }) t.Run("preflight is enabled by default", func(t *testing.T) { unsetEnv(t, "BUILDKITE_EXPERIMENTS") // Run from a temp dir that is not a git repo. This should pass the // experiment gate and fail on repository validation instead. t.Chdir(t.TempDir()) cmd := &RunCmd{Interval: 1} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } var bkErr *bkErrors.Error if !errors.As(err, &bkErr) { t.Fatalf("expected bkErrors.Error, got %T: %v", err, err) } if !errors.Is(bkErr, bkErrors.ErrValidation) { t.Errorf("expected ErrValidation, got category: %v", bkErr.Category) } if !strings.Contains(bkErr.Details, "git repository") { t.Errorf("expected git repository validation after default experiment gate, got details %q", bkErr.Details) } }) t.Run("build-failing early exit enriches summary with test results", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var buildCancelRequests atomic.Int32 var buildPolls atomic.Int32 var summaryRequests atomic.Int32 var includeLatestFail atomic.Bool var stateEnabled atomic.Bool s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/builds/1/cancel"): buildCancelRequests.Add(1) json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "canceling"}) return case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): poll := buildPolls.Add(1) exitOne := 1 build := buildkite.Build{ ID: "build-id-123", Number: 1, State: "running", Jobs: []buildkite.Job{{ ID: "job-running", Type: "script", Name: "Lint", State: "running", }}, } if poll >= 2 { build.State = "failing" build.TestEngine = &buildkite.TestEngineProperty{ Runs: []buildkite.TestEngineRun{{ ID: "run-1", Suite: buildkite.TestEngineSuite{ Slug: "rspec", }, }}, } build.Jobs = []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "Lint", State: "failed", ExitStatus: &exitOne, }} } json.NewEncoder(w).Encode(build) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/analytics/organizations/test-org/builds/build-id-123/preflight/v1": summaryRequests.Add(1) if r.URL.Query().Get("include") == "latest_fail" { includeLatestFail.Store(true) } if r.URL.Query().Get("state") == "enabled" { stateEnabled.Store(true) } _, _ = w.Write([]byte(`{ "tests": { "runs": { "run-1": { "suite": {"id": "suite-1", "slug": "rspec", "name": "RSpec"}, "passed": 47, "failed": 1, "skipped": 12 } }, "failures": [ { "run_id": "run-1", "suite_name": "RSpec", "suite_slug": "rspec", "name": "AuthService.validateToken handles expired tokens", "location": "src/auth.test.ts:89", "latest_fail": { "failure_reason": "Expected 'expired' but got 'invalid'" } } ] } }`)) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, JSON: true} err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightIncompleteFailure) { t.Fatalf("expected incomplete failure error, got %v", err) } }) events := decodeJSONLEvents(t, stdout) var buildStatusCount int var summaries []Event for _, event := range events { if event.Type == EventBuildStatus { buildStatusCount++ } if event.Type == EventBuildSummary { summaries = append(summaries, event) } } if buildStatusCount != 2 { t.Fatalf("expected 2 build status events before early stop, got %d", buildStatusCount) } if len(summaries) != 1 { t.Fatalf("expected exactly 1 build summary event, got %d", len(summaries)) } summary := summaries[0] if !summary.Incomplete { t.Fatal("expected summary to be marked incomplete") } if summary.StopReason != "build-failing" { t.Fatalf("expected stop reason build-failing, got %q", summary.StopReason) } if summary.BuildCanceled == nil || !*summary.BuildCanceled { t.Fatalf("expected build_canceled=true, got %#v", summary.BuildCanceled) } if summary.BuildState != "failing" { t.Fatalf("expected failing build state, got %q", summary.BuildState) } if len(summary.FailedJobs) != 1 || summary.FailedJobs[0].Name != "Lint" { t.Fatalf("expected failed jobs in summary, got %#v", summary.FailedJobs) } if got := summary.Tests.Runs["run-1"]; got.SuiteName != "RSpec" || got.Failed != 1 || got.Passed != 47 || got.Skipped != 12 { t.Fatalf("expected enriched test run summary, got %#v", got) } if len(summary.Tests.Failures) != 1 || summary.Tests.Failures[0].Name != "AuthService.validateToken handles expired tokens" { t.Fatalf("expected enriched test failures, got %#v", summary.Tests.Failures) } if !includeLatestFail.Load() { t.Fatal("expected early-exit summary to request latest_fail details") } if !stateEnabled.Load() { t.Fatal("expected early-exit summary to request state=enabled") } if summaryRequests.Load() != 1 { t.Fatalf("expected one preflight summary request, got %d", summaryRequests.Load()) } if buildCancelRequests.Load() != 1 { t.Fatalf("expected one build cancel request, got %d", buildCancelRequests.Load()) } if buildPolls.Load() != 3 { t.Fatalf("expected three build polls including final summary fetch, got %d", buildPolls.Load()) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/") { t.Errorf("expected preflight branch to be cleaned up, but found: %s", refs) } }) t.Run("snapshots and creates build", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var gotReq buildkite.CreateBuild var gotUserAgent string s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && strings.Contains(r.URL.Path, "/builds") { gotUserAgent = r.Header.Get("User-Agent") json.NewDecoder(r.Body).Decode(&gotReq) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", Message: gotReq.Message, Commit: gotReq.Commit, Branch: gotReq.Branch, URL: "https://api.buildkite.com/v2/organizations/test-org/pipelines/test-pipeline/builds/1", }) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) // Create a dirty file so the snapshot has something to commit. if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } expectedSourceBranch := runGit(t, worktree, "branch", "--show-current") expectedSourceCommit := runGit(t, worktree, "rev-parse", "HEAD") cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: false, Interval: 2} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } if gotReq.Commit == "" { t.Fatal("expected build creation request with a commit, got empty") } if !strings.HasPrefix(gotReq.Branch, "bk/preflight/") { t.Errorf("expected branch starting with bk/preflight/, got %q", gotReq.Branch) } if !strings.HasPrefix(gotReq.Message, "Preflight ") { t.Errorf("expected message starting with 'Preflight ', got %q", gotReq.Message) } if gotReq.Env["PREFLIGHT"] != "true" { t.Errorf("expected PREFLIGHT=true, got %#v", gotReq.Env) } if gotReq.Env["BUILDKITE_PREFLIGHT"] != "true" { t.Errorf("expected BUILDKITE_PREFLIGHT=true (deprecated), got %#v", gotReq.Env) } if gotReq.Env["PREFLIGHT_SOURCE_BRANCH"] != expectedSourceBranch { t.Errorf("expected PREFLIGHT_SOURCE_BRANCH=%q, got %#v", expectedSourceBranch, gotReq.Env) } if gotReq.Env["PREFLIGHT_SOURCE_COMMIT"] != expectedSourceCommit { t.Errorf("expected PREFLIGHT_SOURCE_COMMIT=%q, got %#v", expectedSourceCommit, gotReq.Env) } if !strings.Contains(gotUserAgent, buildkite.DefaultUserAgent) { t.Errorf("expected User-Agent to contain %q, got %q", buildkite.DefaultUserAgent, gotUserAgent) } if !strings.Contains(gotUserAgent, "buildkite-cli-preflight/") { t.Errorf("expected User-Agent to contain preflight token, got %q", gotUserAgent) } }) t.Run("omits source branch env when git HEAD is detached", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var gotReq buildkite.CreateBuild s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && strings.Contains(r.URL.Path, "/builds") { json.NewDecoder(r.Body).Decode(&gotReq) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) expectedSourceCommit := runGit(t, worktree, "rev-parse", "HEAD") runGit(t, worktree, "checkout", expectedSourceCommit) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: false, Interval: 2} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } if _, ok := gotReq.Env["PREFLIGHT_SOURCE_BRANCH"]; ok { t.Errorf("expected PREFLIGHT_SOURCE_BRANCH to be omitted in detached HEAD, got %#v", gotReq.Env) } if gotReq.Env["PREFLIGHT_SOURCE_COMMIT"] != expectedSourceCommit { t.Errorf("expected PREFLIGHT_SOURCE_COMMIT=%q, got %#v", expectedSourceCommit, gotReq.Env) } }) t.Run("falls back to git cli when factory cannot open repository", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") originalNewFactory := newFactory t.Cleanup(func() { newFactory = originalNewFactory }) now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == "POST" && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return case r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1"): json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "passed", FinishedAt: &buildkite.Timestamp{Time: now}, }) return } http.NotFound(w, r) })) defer s.Close() newFactory = func(...factory.FactoryOpt) (*factory.Factory, error) { client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { return nil, err } return &factory.Factory{ Config: config.New(nil, nil), RestAPIClient: client, }, nil } worktree := initTestRepo(t) subdir := filepath.Join(worktree, "nested", "dir") if err := os.MkdirAll(subdir, 0o755); err != nil { t.Fatal(err) } t.Chdir(subdir) if err := os.WriteFile(filepath.Join(subdir, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} if err := cmd.Run(nil, stubGlobals{}); err != nil { t.Fatalf("expected no error, got: %v", err) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/") { t.Errorf("expected preflight branch to be cleaned up, but found: %s", refs) } }) t.Run("watches build until completion and cleans up remote branch", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") pollCount := 0 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "POST" && strings.Contains(r.URL.Path, "/builds") { json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return } if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1") { pollCount++ b := buildkite.Build{Number: 1, State: "running"} if pollCount >= 3 { b.State = "passed" b.FinishedAt = &buildkite.Timestamp{Time: now} } json.NewEncoder(w).Encode(b) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } if pollCount < 3 { t.Errorf("expected at least 3 polls, got %d", pollCount) } // Verify the remote preflight branch was deleted. refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/") { t.Errorf("expected preflight branch to be cleaned up, but found: %s", refs) } }) t.Run("early exit summary tolerates summary endpoint failure", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var buildCancelRequests atomic.Int32 var buildPolls atomic.Int32 var summaryRequests atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/builds/1/cancel"): buildCancelRequests.Add(1) json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "canceling"}) return case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): poll := buildPolls.Add(1) build := buildkite.Build{ ID: "build-id-123", Number: 1, State: "running", Jobs: []buildkite.Job{{ ID: "job-running", Type: "script", Name: "Lint", State: "running", }}, } if poll >= 2 { exitOne := 1 build.State = "failing" build.Jobs = []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "Lint", State: "failed", ExitStatus: &exitOne, }} } json.NewEncoder(w).Encode(build) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/analytics/organizations/test-org/builds/build-id-123/preflight/v1": summaryRequests.Add(1) w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"API::Error::NotFound"}`)) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, JSON: true} err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightIncompleteFailure) { t.Fatalf("expected incomplete failure error, got %v", err) } }) events := decodeJSONLEvents(t, stdout) var buildStatusCount int var summaries []Event for _, event := range events { if event.Type == EventBuildStatus { buildStatusCount++ } if event.Type == EventBuildSummary { summaries = append(summaries, event) } } if buildStatusCount != 2 { t.Fatalf("expected 2 build status events before early stop, got %d", buildStatusCount) } if len(summaries) != 1 { t.Fatalf("expected exactly 1 build summary event, got %d", len(summaries)) } summary := summaries[0] if !summary.Incomplete { t.Fatal("expected summary to be marked incomplete") } if summary.StopReason != "build-failing" { t.Fatalf("expected stop reason build-failing, got %q", summary.StopReason) } if summary.BuildCanceled == nil || !*summary.BuildCanceled { t.Fatalf("expected build_canceled=true, got %#v", summary.BuildCanceled) } if summary.BuildState != "failing" { t.Fatalf("expected failing build state, got %q", summary.BuildState) } if len(summary.FailedJobs) != 1 || summary.FailedJobs[0].Name != "Lint" { t.Fatalf("expected failed jobs in summary, got %#v", summary.FailedJobs) } if len(summary.Tests.Runs) != 0 || len(summary.Tests.Failures) != 0 { t.Fatalf("expected no enriched tests when summary endpoint fails, got %#v", summary.Tests) } if summaryRequests.Load() != 1 { t.Fatalf("expected one preflight summary request, got %d", summaryRequests.Load()) } if buildCancelRequests.Load() != 1 { t.Fatalf("expected one build cancel request, got %d", buildCancelRequests.Load()) } if buildPolls.Load() != 3 { t.Fatalf("expected three build polls including final summary fetch, got %d", buildPolls.Load()) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/") { t.Errorf("expected preflight branch to be cleaned up, but found: %s", refs) } }) t.Run("no-cleanup leaves branch and build running after early stop", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var buildCancelRequests atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/builds/1/cancel"): buildCancelRequests.Add(1) json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "canceling"}) return case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): exitOne := 1 json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "failing", Jobs: []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "Lint", State: "failed", ExitStatus: &exitOne, }}, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, JSON: true, NoCleanup: true} err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightIncompleteFailure) { t.Fatalf("expected incomplete failure error, got %v", err) } }) events := decodeJSONLEvents(t, stdout) var summaries []Event for _, event := range events { if event.Type == EventBuildSummary { summaries = append(summaries, event) } } if len(summaries) != 1 { t.Fatalf("expected exactly 1 build summary event, got %d", len(summaries)) } summary := summaries[0] if summary.BuildCanceled == nil || *summary.BuildCanceled { t.Fatalf("expected build_canceled=false, got %#v", summary.BuildCanceled) } if buildCancelRequests.Load() != 0 { t.Fatalf("expected no build cancel requests, got %d", buildCancelRequests.Load()) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if !strings.Contains(refs, "bk/preflight/") { t.Error("expected preflight branch to still exist with --no-cleanup") } }) t.Run("build-terminal waits for terminal completion", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var buildCancelRequests atomic.Int32 var buildPolls atomic.Int32 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/builds/1/cancel"): buildCancelRequests.Add(1) json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "canceling"}) return case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): poll := buildPolls.Add(1) exitOne := 1 build := buildkite.Build{ ID: "build-id-123", Number: 1, State: "running", Jobs: []buildkite.Job{{ ID: "job-running", Type: "script", Name: "Lint", State: "running", }}, } if poll >= 2 { build.State = "failing" build.Jobs = []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "Lint", State: "failed", ExitStatus: &exitOne, }} } if poll >= 3 { build.State = "failed" build.FinishedAt = &buildkite.Timestamp{Time: now} } json.NewEncoder(w).Encode(build) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, JSON: true, ExitOn: []internalpreflight.ExitPolicy{internalpreflight.ExitOnBuildTerminal}} err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightCompletedFailure) { t.Fatalf("expected completed failure error, got %v", err) } }) events := decodeJSONLEvents(t, stdout) var buildStatusCount int var summaries []Event for _, event := range events { if event.Type == EventBuildStatus { buildStatusCount++ } if event.Type == EventBuildSummary { summaries = append(summaries, event) } } if buildStatusCount != 3 { t.Fatalf("expected 3 build status events before terminal exit, got %d", buildStatusCount) } if len(summaries) != 1 { t.Fatalf("expected exactly 1 build summary event, got %d", len(summaries)) } summary := summaries[0] if summary.Incomplete { t.Fatal("expected terminal summary, got incomplete=true") } if summary.StopReason != "" { t.Fatalf("expected empty stop reason, got %q", summary.StopReason) } if summary.BuildCanceled != nil { t.Fatalf("expected no build_canceled metadata for terminal summary, got %#v", summary.BuildCanceled) } if summary.BuildState != "failed" { t.Fatalf("expected failed build state, got %q", summary.BuildState) } if buildCancelRequests.Load() != 0 { t.Fatalf("expected no build cancel requests, got %d", buildCancelRequests.Load()) } if buildPolls.Load() < 3 { t.Fatalf("expected to keep polling through terminal state, got %d polls", buildPolls.Load()) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/") { t.Errorf("expected preflight branch to be cleaned up, but found: %s", refs) } }) t.Run("final summary does not retry test results without await flag", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var includeLatestFail atomic.Bool var summaryRequests atomic.Int32 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "failed", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", FinishedAt: &buildkite.Timestamp{Time: now}, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, TestEngine: &buildkite.TestEngineProperty{ Runs: []buildkite.TestEngineRun{{ ID: "run-1", Suite: buildkite.TestEngineSuite{ Slug: "rspec", }, }}, }, Jobs: []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "RSpec shard 1", State: "failed", }}, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/test-org/builds": json.NewEncoder(w).Encode([]buildkite.Build{{ ID: "build-id-123", Number: 1, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/analytics/organizations/test-org/builds/build-id-123/preflight/v1": summaryRequests.Add(1) if r.URL.Query().Get("include") == "latest_fail" { includeLatestFail.Store(true) } if summaryRequests.Load() == 1 { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"API::Error::NotFound"}`)) return } _, _ = w.Write([]byte(`{ "tests": { "runs": { "run-1": { "suite": {"id": "suite-1", "slug": "rspec", "name": "RSpec"}, "passed": 47, "failed": 1, "skipped": 12 } }, "failures": [ { "run_id": "run-1", "suite_name": "RSpec", "suite_slug": "rspec", "name": "AuthService.validateToken handles expired tokens", "location": "src/auth.test.ts:89", "latest_fail": { "failure_reason": "Expected 'expired' but got 'invalid'" } } ] } }`)) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, Text: true} err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightCompletedFailure) { t.Fatalf("expected completed failure error, got %v", err) } }) if !includeLatestFail.Load() { t.Fatal("expected preflight summary to request latest_fail details") } if got := summaryRequests.Load(); got != 1 { t.Fatalf("expected one summary request without await flag, got %d", got) } if strings.Contains(stdout, "AuthService.validateToken handles expired tokens") { t.Fatalf("expected no endpoint failure name in final summary, got %q", stdout) } if strings.Contains(stdout, "Expected 'expired' but got 'invalid'") { t.Fatalf("expected no endpoint failure message in final summary, got %q", stdout) } }) t.Run("final summary tolerates transient build lookup failure without await flag", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var buildRequests atomic.Int32 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): if buildRequests.Add(1) == 1 { json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "failed", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", FinishedAt: &buildkite.Timestamp{Time: now}, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, Jobs: []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "RSpec shard 1", State: "failed", }}, }) return } w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message":"temporary failure"}`)) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/test-org/builds": json.NewEncoder(w).Encode([]buildkite.Build{{ ID: "build-id-123", Number: 1, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }}) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, Text: true} err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightCompletedFailure) { t.Fatalf("expected completed failure error, got %v", err) } }) if !strings.Contains(stdout, "❌ Preflight Failed") { t.Fatalf("expected final summary header, got %q", stdout) } if !strings.Contains(stdout, "RSpec shard 1") { t.Fatalf("expected failed job in final summary, got %q", stdout) } }) t.Run("await-test-results loads summary after timeout", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var includeLatestFail atomic.Bool var stateEnabled atomic.Bool var summaryRequests atomic.Int32 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "failed", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", FinishedAt: &buildkite.Timestamp{Time: now}, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, TestEngine: &buildkite.TestEngineProperty{ Runs: []buildkite.TestEngineRun{{ ID: "run-1", Suite: buildkite.TestEngineSuite{ Slug: "rspec", }, }}, }, Jobs: []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "RSpec shard 1", State: "failed", }}, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/test-org/builds": json.NewEncoder(w).Encode([]buildkite.Build{{ ID: "build-id-123", Number: 1, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/analytics/organizations/test-org/builds/build-id-123/preflight/v1": summaryRequests.Add(1) if r.URL.Query().Get("include") == "latest_fail" { includeLatestFail.Store(true) } if r.URL.Query().Get("state") == "enabled" { stateEnabled.Store(true) } _, _ = w.Write([]byte(`{ "tests": { "runs": { "run-1": { "suite": {"id": "suite-1", "slug": "rspec", "name": "RSpec"}, "passed": 47, "failed": 1, "skipped": 12 } }, "failures": [ { "run_id": "run-1", "suite_name": "RSpec", "suite_slug": "rspec", "name": "AuthService.validateToken handles expired tokens", "location": "src/auth.test.ts:89", "latest_fail": { "failure_reason": "Expected 'expired' but got 'invalid'" } } ] } }`)) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{ Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, Text: true, AwaitTestResults: awaitTestResultsFlag{Enabled: true, Duration: 35 * time.Millisecond}, } err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightCompletedFailure) { t.Fatalf("expected completed failure error, got %v", err) } }) if !includeLatestFail.Load() { t.Fatal("expected preflight summary to request latest_fail details") } if !stateEnabled.Load() { t.Fatal("expected preflight summary to request state=enabled") } if got := summaryRequests.Load(); got != 1 { t.Fatalf("expected one delayed summary request, got %d", got) } if !strings.Contains(stdout, "✗ RSpec 1 failed 47 passed 12 skipped") { t.Fatalf("expected suite name in final summary, got %q", stdout) } if !strings.Contains(stdout, "✗ [RSpec]") { t.Fatalf("expected suite name in failure label, got %q", stdout) } if !strings.Contains(stdout, "AuthService.validateToken handles expired tokens") { t.Fatalf("expected endpoint failure name in final summary, got %q", stdout) } if strings.Contains(stdout, "Expected 'expired' but got 'invalid'") { t.Fatalf("expected final summary to omit endpoint failure message, got %q", stdout) } }) t.Run("await-test-results timeout still renders final summary", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var summaryRequests atomic.Int32 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "failed", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", FinishedAt: &buildkite.Timestamp{Time: now}, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, TestEngine: &buildkite.TestEngineProperty{ Runs: []buildkite.TestEngineRun{{ ID: "run-1", Suite: buildkite.TestEngineSuite{ Slug: "rspec", }, }}, }, Jobs: []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "RSpec shard 1", State: "failed", }}, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/test-org/builds": json.NewEncoder(w).Encode([]buildkite.Build{{ ID: "build-id-123", Number: 1, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/analytics/organizations/test-org/builds/build-id-123/preflight/v1": summaryRequests.Add(1) w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"API::Error::NotFound"}`)) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } stdout := captureStdout(t, func() { cmd := &RunCmd{ Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, Text: true, AwaitTestResults: awaitTestResultsFlag{Enabled: true, Duration: 35 * time.Millisecond}, } err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightCompletedFailure) { t.Fatalf("expected completed failure error, got %v", err) } }) if got := summaryRequests.Load(); got != 1 { t.Fatalf("expected one delayed summary request during await timeout, got %d", got) } if !strings.Contains(stdout, "❌ Preflight Failed") { t.Fatalf("expected final summary header, got %q", stdout) } if strings.Contains(stdout, "AuthService.validateToken handles expired tokens") { t.Fatalf("expected no endpoint failure name in final summary, got %q", stdout) } }) t.Run("await-test-results does not wait when no test runs are expected", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var summaryRequests atomic.Int32 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/builds/1"): json.NewEncoder(w).Encode(buildkite.Build{ ID: "build-id-123", Number: 1, State: "failed", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", FinishedAt: &buildkite.Timestamp{Time: now}, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, Jobs: []buildkite.Job{{ ID: "job-failed", Type: "script", Name: "RSpec shard 1", State: "failed", }}, }) return case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tests"): json.NewEncoder(w).Encode([]buildkite.BuildTest{}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/test-org/builds": json.NewEncoder(w).Encode([]buildkite.Build{{ ID: "build-id-123", Number: 1, Pipeline: &buildkite.Pipeline{ Slug: "test-pipeline", }, }}) return case r.Method == http.MethodGet && r.URL.Path == "/v2/analytics/organizations/test-org/builds/build-id-123/preflight/v1": summaryRequests.Add(1) _, _ = w.Write([]byte(`{"tests":{"runs":{},"failures":[]}}`)) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{ Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, AwaitTestResults: awaitTestResultsFlag{Enabled: true, Duration: 35 * time.Millisecond}, } err := cmd.Run(nil, stubGlobals{}) var bkErr *bkErrors.Error if !errors.As(err, &bkErr) || !errors.Is(bkErr, bkErrors.ErrPreflightCompletedFailure) { t.Fatalf("expected completed failure error, got %v", err) } if got := summaryRequests.Load(); got != 0 { t.Fatalf("expected no summary requests when no test runs are expected, got %d", got) } }) t.Run("no-cleanup preserves remote branch", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "POST" && strings.Contains(r.URL.Path, "/builds") { json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return } if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1") { json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "passed", FinishedAt: &buildkite.Timestamp{Time: now}, }) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01, NoCleanup: true} err := cmd.Run(nil, stubGlobals{}) if err != nil { t.Fatalf("expected no error, got: %v", err) } // Verify the remote preflight branch still exists. refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if !strings.Contains(refs, "bk/preflight/") { t.Error("expected preflight branch to still exist with --no-cleanup") } }) t.Run("returns error when build fails", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "POST" && strings.Contains(r.URL.Path, "/builds") { json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return } if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1") { json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "failed", FinishedAt: &buildkite.Timestamp{Time: now}, }) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "preflight completed with failure: build is failed") { t.Errorf("expected completed failure error, got: %v", err) } }) t.Run("returns user aborted error when interrupted while watching", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") originalNotifyContext := notifyContext t.Cleanup(func() { notifyContext = originalNotifyContext }) watchCtx, cancelWatch := context.WithCancel(context.Background()) notifyContext = func(context.Context, ...os.Signal) (context.Context, context.CancelFunc) { return watchCtx, cancelWatch } var buildCancelRequests atomic.Int32 var pollCount atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == "PUT" && strings.Contains(r.URL.Path, "/builds/1/cancel"): buildCancelRequests.Add(1) json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "canceling"}) return case r.Method == "POST" && strings.Contains(r.URL.Path, "/builds"): json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return case r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1"): if pollCount.Add(1) == 1 { cancelWatch() } json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "running"}) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } if !bkErrors.IsUserAborted(err) { t.Fatalf("expected user aborted error, got %T: %v", err, err) } if code := bkErrors.GetExitCodeForError(err); code != bkErrors.ExitCodeUserAbortedError { t.Fatalf("expected exit code %d, got %d", bkErrors.ExitCodeUserAbortedError, code) } if pollCount.Load() == 0 { t.Fatal("expected at least one build poll before interrupt") } if buildCancelRequests.Load() != 1 { t.Fatalf("expected one build cancel request, got %d", buildCancelRequests.Load()) } }) t.Run("aborts after 10 consecutive polling errors", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") pollCount := 0 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "POST" && strings.Contains(r.URL.Path, "/builds") { json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "scheduled", WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1", }) return } if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1") { pollCount++ w.WriteHeader(http.StatusInternalServerError) return } http.NotFound(w, r) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "watching build failed") { t.Errorf("expected 'watching build failed', got: %v", err) } if pollCount < watch.DefaultMaxConsecutiveErrors { t.Errorf("expected at least %d polls, got %d", watch.DefaultMaxConsecutiveErrors, pollCount) } }) t.Run("returns error when build creation fails", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"message":"Pipeline not found"}`)) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Interval: 2} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "creating preflight build") { t.Fatalf("expected build creation error, got: %v", err) } }) t.Run("returns user aborted when canceled during snapshot push", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var cancel context.CancelFunc originalNotifyContext := notifyContext notifyContext = func(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) { ctx, stop := context.WithCancel(parent) cancel = stop return ctx, stop } t.Cleanup(func() { notifyContext = originalNotifyContext }) var apiRequests atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { apiRequests.Add(1) http.Error(w, "unexpected request after snapshot cancellation", http.StatusInternalServerError) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } gitPath, err := exec.LookPath("git") if err != nil { t.Fatalf("finding git: %v", err) } fakeBin := t.TempDir() pushStarted := filepath.Join(fakeBin, "push-started") fakeGit := filepath.Join(fakeBin, "git") if err := os.WriteFile(fakeGit, []byte(`#!/bin/sh if [ "$1" = "push" ]; then touch "$PUSH_STARTED" exec /bin/sleep 10 fi exec "$REAL_GIT" "$@" `), 0o755); err != nil { t.Fatalf("writing fake git: %v", err) } t.Setenv("REAL_GIT", gitPath) t.Setenv("PUSH_STARTED", pushStarted) t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Interval: 2} errCh := make(chan error, 1) go func() { errCh <- cmd.Run(nil, stubGlobals{}) }() deadline := time.After(2 * time.Second) for { if _, err := os.Stat(pushStarted); err == nil { break } else if !os.IsNotExist(err) { t.Fatalf("checking push marker: %v", err) } select { case err := <-errCh: t.Fatalf("Run returned before snapshot push was canceled: %v", err) case <-deadline: t.Fatal("timed out waiting for snapshot push to start") case <-time.After(10 * time.Millisecond): } } cancel() select { case err := <-errCh: if !errors.Is(err, bkErrors.ErrUserAborted) { t.Fatalf("expected user aborted error, got %T: %v", err, err) } if strings.Contains(err.Error(), "snapshot error") { t.Fatalf("expected cancellation error without snapshot context, got: %v", err) } case <-time.After(2 * time.Second): t.Fatal("Run did not return promptly after cancellation") } if got := apiRequests.Load(); got != 0 { t.Fatalf("expected no API requests after snapshot cancellation, got %d", got) } }) t.Run("returns user aborted and cleans up when canceled during build creation", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") var cancel context.CancelFunc originalNotifyContext := notifyContext notifyContext = func(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) { ctx, stop := context.WithCancel(parent) cancel = stop return ctx, stop } t.Cleanup(func() { notifyContext = originalNotifyContext }) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/builds") { cancel() time.Sleep(50 * time.Millisecond) json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "scheduled"}) return } http.Error(w, "unexpected request", http.StatusInternalServerError) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Interval: 2} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, bkErrors.ErrUserAborted) { t.Fatalf("expected user aborted error, got %T: %v", err, err) } if strings.Contains(err.Error(), "API error") || strings.Contains(err.Error(), "creating preflight build") { t.Fatalf("expected cancellation error without API build creation context, got: %v", err) } refs := runGit(t, worktree, "ls-remote", "--heads", "origin") if strings.Contains(refs, "bk/preflight/") { t.Errorf("expected preflight branch to be cleaned up, but found: %s", refs) } }) t.Run("closes renderer when build creation fails", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") originalRendererFactory := rendererFactory fakeRenderer := &recordingRenderer{} rendererFactory = func(io.Writer, bool, bool, context.CancelFunc) renderer { return fakeRenderer } t.Cleanup(func() { rendererFactory = originalRendererFactory }) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"message":"Authentication required"}`)) })) defer s.Close() t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) worktree := initTestRepo(t) t.Chdir(worktree) if err := os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } cmd := &RunCmd{Pipeline: "test-org/test-pipeline", Interval: 2} err := cmd.Run(nil, stubGlobals{}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "creating preflight build") { t.Fatalf("expected build creation error, got: %v", err) } if fakeRenderer.closeCalls != 1 { t.Fatalf("expected renderer to be closed once, got %d", fakeRenderer.closeCalls) } }) } type recordingRenderer struct { closeCalls int } func (r *recordingRenderer) Render(Event) error { return nil } func (r *recordingRenderer) Close() error { r.closeCalls++ return nil } func initTestRepo(t *testing.T) string { t.Helper() dir := t.TempDir() worktree := filepath.Join(dir, "work") bare := filepath.Join(dir, "origin.git") runGit(t, "", "init", "--bare", bare) runGit(t, "", "init", worktree) runGit(t, worktree, "config", "user.email", "test@test.com") runGit(t, worktree, "config", "user.name", "Test") runGit(t, worktree, "config", "commit.gpgsign", "false") initial := filepath.Join(worktree, "README.md") if err := os.WriteFile(initial, []byte("# test\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, worktree, "add", ".") runGit(t, worktree, "commit", "-m", "initial commit") runGit(t, worktree, "remote", "add", "origin", bare) return worktree } func runGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) } return strings.TrimSpace(string(out)) } func captureStdout(t *testing.T, fn func()) string { t.Helper() originalStdout := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatalf("creating stdout pipe: %v", err) } os.Stdout = w t.Cleanup(func() { os.Stdout = originalStdout }) fn() if err := w.Close(); err != nil { t.Fatalf("closing stdout writer: %v", err) } out, err := io.ReadAll(r) if err != nil { t.Fatalf("reading captured stdout: %v", err) } return string(out) } func decodeJSONLEvents(t *testing.T, output string) []Event { t.Helper() decoder := json.NewDecoder(strings.NewReader(output)) var events []Event for { var event Event if err := decoder.Decode(&event); err != nil { if errors.Is(err, io.EOF) { return events } t.Fatalf("decode JSONL event: %v\noutput:\n%s", err, output) } events = append(events, event) } } ================================================ FILE: cmd/preflight/render.go ================================================ package preflight import ( "context" "encoding/json" "fmt" "io" "os" "sort" "strconv" "strings" "time" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" ) type renderer interface { Render(Event) error Close() error } func newRenderer(stdout io.Writer, jsonMode bool, textMode bool, cancel context.CancelFunc) renderer { if jsonMode { return newJSONRenderer(stdout) } isTTY := false if f, ok := stdout.(*os.File); ok { isTTY = isatty.IsTerminal(f.Fd()) } if textMode || !isTTY { return newPlainRenderer(stdout) } return newTTYRenderer(cancel) } type plainRenderer struct { stdout io.Writer lastLine string } func newPlainRenderer(stdout io.Writer) *plainRenderer { return &plainRenderer{stdout: stdout} } func (r *plainRenderer) Render(e Event) error { prefix := timestampPrefix(e.Time) switch e.Type { case EventOperation: if e.Detail != "" { _, err := fmt.Fprintf(r.stdout, "%s\n", formatTimestampedDetail(e.Title, e.Detail, e.Time)) return err } _, err := fmt.Fprintf(r.stdout, "%s%s\n", prefix, e.Title) return err case EventBuildStatus: line := fmt.Sprintf("Build #%d %s", e.BuildNumber, e.BuildState) if e.Jobs != nil { if summary := e.Jobs.String(); summary != "" { line += " — " + summary } } if line != r.lastLine { _, err := fmt.Fprintf(r.stdout, "%s%s\n", prefix, line) r.lastLine = line return err } case EventJobFailure: if e.Job != nil { presenter := jobPresenter{pipeline: e.Pipeline, buildNumber: e.BuildNumber, buildURL: e.BuildURL} _, err := fmt.Fprintf(r.stdout, "%s%s\n", prefix, presenter.Line(*e.Job)) return err } case EventJobRetryPassed: if e.Job != nil { presenter := jobPresenter{pipeline: e.Pipeline, buildNumber: e.BuildNumber, buildURL: e.BuildURL} _, err := fmt.Fprintf(r.stdout, "%s%s\n", prefix, presenter.RetryPassedLine(*e.Job)) return err } case EventBuildSummary: header := summaryHeader(e) if _, err := fmt.Fprintf(r.stdout, "\n%s\n", header); err != nil { return err } if line := summaryBuildLine(e); line != "" { if _, err := fmt.Fprintf(r.stdout, " %s\n", line); err != nil { return err } } presenter := jobPresenter{pipeline: e.Pipeline, buildNumber: e.BuildNumber, buildURL: e.BuildURL} for _, j := range e.PassedJobs { if _, err := fmt.Fprintf(r.stdout, " %s\n", presenter.PassedLine(j)); err != nil { return err } } if _, err := fmt.Fprint(r.stdout, buildSummaryDetails(e, false, 0)); err != nil { return err } case EventTestFailure: if e.TestFailures != nil { presenter := testPresenter{} for _, t := range e.TestFailures { if _, err := fmt.Fprintf(r.stdout, "%s\n", formatTimestampedBlock(presenter.Line(t), e.Time)); err != nil { return err } } } } return nil } func (r *plainRenderer) Close() error { return nil } type jsonRenderer struct { encoder *json.Encoder } func newJSONRenderer(stdout io.Writer) *jsonRenderer { enc := json.NewEncoder(stdout) enc.SetEscapeHTML(false) return &jsonRenderer{encoder: enc} } func (r *jsonRenderer) Render(e Event) error { return r.encoder.Encode(e) } func (r *jsonRenderer) Close() error { return nil } func summaryHeader(e Event) string { verdict := "❌ Preflight Failed" if e.Incomplete { verdict = "❌ Preflight Incomplete" if reason := summaryStopReasonLabel(e.StopReason); reason != "" { verdict = fmt.Sprintf("%s (%s)", verdict, reason) } } else if e.BuildState == "passed" { verdict = "✅ Preflight Passed" } if e.Duration > 0 { return fmt.Sprintf("%s (%s)", verdict, formatDuration(e.Duration)) } return verdict } func summaryStopReasonLabel(reason string) string { if reason == "" { return "" } switch reason { case "build-failing": return "build failing" default: return strings.ReplaceAll(reason, "-", " ") } } func summaryBuildLine(e Event) string { label := summaryBuildLabel(e) if e.BuildURL == "" || label == "" { return "" } return fmt.Sprintf("%s: %s", label, e.BuildURL) } func summaryBuildLabel(e Event) string { if e.BuildNumber > 0 { return fmt.Sprintf("Build #%d", e.BuildNumber) } return "" } func formatDuration(d time.Duration) string { d = d.Round(time.Second) h := int(d.Hours()) m := int(d.Minutes()) % 60 s := int(d.Seconds()) % 60 switch { case h > 0 && m > 0: return fmt.Sprintf("%d %s %d %s", h, plural(h, "hour"), m, plural(m, "minute")) case h > 0: return fmt.Sprintf("%d %s", h, plural(h, "hour")) case m > 0 && s > 0: return fmt.Sprintf("%d %s %d %s", m, plural(m, "minute"), s, plural(s, "second")) case m > 0: return fmt.Sprintf("%d %s", m, plural(m, "minute")) default: return fmt.Sprintf("%d %s", s, plural(s, "second")) } } func plural(n int, word string) string { if n == 1 { return word } return word + "s" } const summaryTestFailureDisplayLimit = 10 func buildSummaryDetails(e Event, colored bool, width int) string { var sections []string if len(e.FailedJobs) > 0 { presenter := jobPresenter{pipeline: e.Pipeline, buildNumber: e.BuildNumber, buildURL: e.BuildURL} lines := []string{" Build Failures:"} for _, j := range e.FailedJobs { line := presenter.Line(j) if colored { line = presenter.ColoredLine(j) } lines = append(lines, " "+line) } sections = append(sections, strings.Join(lines, "\n")) } if testSection := summaryTestsSection(e.Tests.Runs, e.Tests.Failures, width); testSection != "" { sections = append(sections, testSection) } if len(sections) == 0 { return "" } return "\n\n" + strings.Join(sections, "\n\n") + "\n" } func summaryTestsSection(tests map[string]internalpreflight.SummaryTestRun, failures []internalpreflight.SummaryTestFailure, width int) string { if len(tests) == 0 && len(failures) == 0 { return "" } presenter := testPresenter{} summaries := orderedSummaryTestRuns(tests) header := " Tests Passed ✓" failedTests := 0 for _, summary := range summaries { failedTests += summary.Failed } totalFailed := max(failedTests, len(failures)) if totalFailed > 0 { header = " Tests Failed ✗" } lines := []string{header} if len(summaries) > 0 { widths := summarySuiteWidths(summaries) for _, summary := range summaries { lines = append(lines, " "+presenter.SummarySuiteLine(summary, widths)) } } displayed := min(len(failures), summaryTestFailureDisplayLimit) if displayed > 0 { lines = append(lines, "") for _, failure := range failures[:displayed] { lines = append(lines, presenter.SummaryFailureLine(failure, width, " ")) } } if remaining := totalFailed - displayed; remaining > 0 { lines = append(lines, fmt.Sprintf(" ... and %d more failed %s", remaining, plural(remaining, "test"))) } return strings.Join(lines, "\n") } func orderedSummaryTestRuns(tests map[string]internalpreflight.SummaryTestRun) []internalpreflight.SummaryTestRun { summaries := make([]internalpreflight.SummaryTestRun, 0, len(tests)) for _, summary := range tests { summaries = append(summaries, summary) } sort.SliceStable(summaries, func(i, j int) bool { leftFailed := summaries[i].Failed > 0 rightFailed := summaries[j].Failed > 0 if leftFailed != rightFailed { return leftFailed } leftLabel := strings.ToLower(summarySuiteLabel(summaries[i].SuiteName, summaries[i].SuiteSlug, "unknown")) rightLabel := strings.ToLower(summarySuiteLabel(summaries[j].SuiteName, summaries[j].SuiteSlug, "unknown")) return leftLabel < rightLabel }) return summaries } func summarySuiteWidths(tests []internalpreflight.SummaryTestRun) summarySuiteColumnWidths { widths := summarySuiteColumnWidths{Failed: 1, Passed: 1, Skipped: 1} for _, summary := range tests { widths.Label = max(widths.Label, runewidth.StringWidth(summarySuiteLabel(summary.SuiteName, summary.SuiteSlug, "unknown"))) widths.Failed = max(widths.Failed, len(strconv.Itoa(summary.Failed))) widths.Passed = max(widths.Passed, len(strconv.Itoa(summary.Passed))) widths.Skipped = max(widths.Skipped, len(strconv.Itoa(summary.Skipped))) } return widths } func summarySuiteLabel(name, slug, fallback string) string { if name = strings.TrimSpace(name); name != "" { return name } if slug = strings.TrimSpace(slug); slug != "" { return slug } return fallback } func jobLogCommand(pipeline string, buildNumber int, jobID string) string { return fmt.Sprintf("bk job log -b %d -p %s %s", buildNumber, pipeline, jobID) } func terminalHyperlink(label, url string) string { if url == "" { return label } return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label) } func timestampPrefix(t time.Time) string { return t.Format(time.TimeOnly) + " " } func formatTimestampedDetail(title, detail string, t time.Time) string { return formatTimestampedBlock(title+":\n"+detail, t) } func formatTimestampedBlock(text string, t time.Time) string { prefix := timestampPrefix(t) head, tail, ok := strings.Cut(text, "\n") if !ok { return prefix + head } return prefix + head + "\n" + indentAllLines(tail, len(prefix)) } func indentAllLines(text string, indentWidth int) string { lines := strings.Split(text, "\n") indent := strings.Repeat(" ", indentWidth) for i := range lines { lines[i] = indent + lines[i] } return strings.Join(lines, "\n") } ================================================ FILE: cmd/preflight/render_test.go ================================================ package preflight import ( "bytes" "encoding/json" "regexp" "strings" "testing" "time" "github.com/buildkite/cli/v3/internal/build/watch" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" buildkite "github.com/buildkite/go-buildkite/v4" ) var ansiCodesPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) func TestPlainRenderer_Render_Operation(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) r.Render(Event{ Type: EventOperation, Time: now, Title: "Creating snapshot of working tree...", }) got := out.String() if !strings.Contains(got, "10:30:00") { t.Fatalf("expected timestamp, got %q", got) } if !strings.Contains(got, "Creating snapshot of working tree...") { t.Fatalf("expected title text, got %q", got) } } func TestPlainRenderer_Render_OperationWithDetail(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) r.Render(Event{ Type: EventOperation, Time: now, Title: "Creating snapshot of working tree...", Detail: "Commit: abc1234567", }) got := out.String() indent := strings.Repeat(" ", len("10:30:00 ")) expected := "10:30:00 Creating snapshot of working tree...:\n" + indent + "Commit: abc1234567\n" if got != expected { t.Fatalf("expected:\n%s\ngot:\n%s", expected, got) } } func TestPlainRenderer_Render_OperationWithMultiLineDetail(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) r.Render(Event{ Type: EventOperation, Time: now, Title: "Created snapshot of working tree...", Detail: "Commit: abc1234567\nRef: refs/heads/bk/preflight/abc123\nFiles: 2 changed\n" + " ~ app/controllers/jobs_controller.rb\n ~ db/structure.sql", }) got := out.String() indent := strings.Repeat(" ", len("10:30:00 ")) expected := "10:30:00 Created snapshot of working tree...:\n" + indent + "Commit: abc1234567\n" + indent + "Ref: refs/heads/bk/preflight/abc123\n" + indent + "Files: 2 changed\n" + indent + " ~ app/controllers/jobs_controller.rb\n" + indent + " ~ db/structure.sql\n" if got != expected { t.Fatalf("expected:\n%s\ngot:\n%s", expected, got) } } func TestFormatTimestampedDetail_UsesLeftAlignedTimestampIndent(t *testing.T) { now := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) got := formatTimestampedDetail("Created snapshot of working tree...", "Commit: abc1234567\nRef: refs/heads/bk/preflight/abc123", now) indent := strings.Repeat(" ", len("10:30:00 ")) expected := "10:30:00 Created snapshot of working tree...:\n" + indent + "Commit: abc1234567\n" + indent + "Ref: refs/heads/bk/preflight/abc123" if got != expected { t.Fatalf("expected:\n%s\ngot:\n%s", expected, got) } } func TestFormatTimestampedBlock_IndentsContinuationLines(t *testing.T) { now := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) got := formatTimestampedBlock(" ❌ test: react/jsx-no-bind\n Location: .eslintrc.js:120\n Got 193 failures and 0 errors.", now) indent := strings.Repeat(" ", len("10:30:00 ")) expected := "10:30:00 ❌ test: react/jsx-no-bind\n" + indent + " Location: .eslintrc.js:120\n" + indent + " Got 193 failures and 0 errors." if got != expected { t.Fatalf("expected:\n%s\ngot:\n%s", expected, got) } } func TestPlainRenderer_Render_BuildStatus(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 5, 0, time.UTC) r.Render(Event{ Type: EventBuildStatus, Time: now, BuildNumber: 42, BuildState: "running", Jobs: &watch.JobSummary{Passed: 8, Running: 3}, }) got := out.String() if !strings.Contains(got, "Build #42 running") { t.Fatalf("expected build status line, got %q", got) } if !strings.Contains(got, "8 passed") { t.Fatalf("expected job summary, got %q", got) } } func TestPlainRenderer_Render_BuildStatusDeduplicates(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 5, 0, time.UTC) e := Event{ Type: EventBuildStatus, Time: now, BuildNumber: 42, BuildState: "running", Jobs: &watch.JobSummary{Running: 3}, } r.Render(e) r.Render(e) lines := strings.Split(strings.TrimSpace(out.String()), "\n") if len(lines) != 1 { t.Fatalf("expected 1 line (deduplicated), got %d: %v", len(lines), lines) } } func TestPlainRenderer_Render_JobFailure(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC) exitOne := 1 r.Render(Event{ Type: EventJobFailure, Time: now, Job: &buildkite.Job{ ID: "job-1", Name: "Lint", Type: "script", State: "failed", ExitStatus: &exitOne, }, }) got := out.String() if !strings.Contains(got, "10:31:00") { t.Fatalf("expected timestamp, got %q", got) } if !strings.Contains(got, "Lint") { t.Fatalf("expected job name, got %q", got) } if !strings.Contains(got, "job-1") { t.Fatalf("expected job ID, got %q", got) } } func TestPlainRenderer_Render_JobRetryPassed(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC) r.Render(Event{ Type: EventJobRetryPassed, Time: now, Job: &buildkite.Job{ ID: "retry-1", Name: "Lint", Type: "script", State: "passed", RetriesCount: 1, }, }) got := out.String() if !strings.Contains(got, "10:31:00") { t.Fatalf("expected timestamp, got %q", got) } if !strings.Contains(got, "✔ Lint") { t.Fatalf("expected check mark and job name, got %q", got) } if !strings.Contains(got, "passed on retry") { t.Fatalf("expected retry text, got %q", got) } if !strings.Contains(got, "attempt 2") { t.Fatalf("expected attempt count, got %q", got) } } func TestJSONRenderer_Render_JobRetryPassed(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) now := time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC) r.Render(Event{ Type: EventJobRetryPassed, Time: now, PreflightID: "pfid-123", Pipeline: "buildkite/cli", BuildNumber: 42, Job: &buildkite.Job{ ID: "retry-1", Name: "Lint", State: "passed", RetriesCount: 1, }, }) var got Event if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v", err) } if got.Type != EventJobRetryPassed { t.Fatalf("expected type %q, got %q", EventJobRetryPassed, got.Type) } if got.Job == nil { t.Fatal("expected job to be present") } if got.Job.RetriesCount != 1 { t.Fatalf("expected retries count 1, got %d", got.Job.RetriesCount) } } func TestPlainRenderer_Render_TestFailure(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) now := time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC) executionTime := buildkite.Timestamp{Time: now} r.Render(Event{ Type: EventTestFailure, Time: now, TestFailures: []buildkite.BuildTest{{ Name: "Test A", ExecutionsCount: 1, ExecutionsCountByResult: buildkite.BuildTestExecutionsCount{ Failed: 1, }, Executions: []buildkite.BuildTestExecution{{ Status: "failed", Location: "./spec/example_spec.rb:10", FailureReason: "Failure/Error: expect(false).to eq(true)", Timestamp: &executionTime, }}, }}, }) got := out.String() if ansiCodesPattern.MatchString(got) { t.Fatalf("expected plain output without ANSI codes, got %q", got) } indent := strings.Repeat(" ", len("10:31:00 ")) expected := "10:31:00 ✗ Test A\n" + indent + " 1 attempt (0 passed, 1 failed) — ./spec/example_spec.rb:10\n" + indent + " Failure/Error: expect(false).to eq(true)\n" if got != expected { t.Fatalf("expected:\n%s\ngot:\n%s", expected, got) } } func TestJSONRenderer_Render_Operation(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) r.Render(Event{ Type: EventOperation, Time: now, PreflightID: "pfid-123", Title: "Creating snapshot of working tree...", }) var got Event if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, out.String()) } if got.Type != EventOperation { t.Fatalf("expected type %q, got %q", EventOperation, got.Type) } if got.Title != "Creating snapshot of working tree..." { t.Fatalf("expected title text, got %q", got.Title) } if got.PreflightID != "pfid-123" { t.Fatalf("expected preflight ID, got %q", got.PreflightID) } } func TestJSONRenderer_Render_BuildStatus(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 5, 0, time.UTC) r.Render(Event{ Type: EventBuildStatus, Time: now, PreflightID: "pfid-123", Pipeline: "buildkite/cli", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", BuildState: "running", Jobs: &watch.JobSummary{Passed: 8, Running: 3}, }) var got Event if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v", err) } if got.BuildNumber != 42 { t.Fatalf("expected build number 42, got %d", got.BuildNumber) } if got.BuildState != "running" { t.Fatalf("expected build state running, got %q", got.BuildState) } if got.Jobs.Passed != 8 { t.Fatalf("expected 8 passed, got %d", got.Jobs.Passed) } } func TestJSONRenderer_Render_JobFailure(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) now := time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC) exitOne := 1 r.Render(Event{ Type: EventJobFailure, Time: now, PreflightID: "pfid-123", Pipeline: "buildkite/cli", BuildNumber: 42, Job: &buildkite.Job{ ID: "job-1", Name: "Lint", State: "failed", ExitStatus: &exitOne, }, }) var got Event if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v", err) } if got.Type != EventJobFailure { t.Fatalf("expected type %q, got %q", EventJobFailure, got.Type) } if got.Job == nil { t.Fatal("expected job to be present") } if got.Job.ID != "job-1" { t.Fatalf("expected job ID job-1, got %q", got.Job.ID) } if got.Job.Name != "Lint" { t.Fatalf("expected job name Lint, got %q", got.Job.Name) } if got.Job.ExitStatus == nil || *got.Job.ExitStatus != 1 { t.Fatalf("expected exit status 1, got %v", got.Job.ExitStatus) } } func TestJSONRenderer_Render_MultipleEvents_JSONL(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) now := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) r.Render(Event{Type: EventOperation, Time: now, Title: "step 1"}) r.Render(Event{Type: EventOperation, Time: now, Title: "step 2"}) lines := strings.Split(strings.TrimSpace(out.String()), "\n") if len(lines) != 2 { t.Fatalf("expected 2 JSONL lines, got %d", len(lines)) } for i, line := range lines { var e Event if err := json.Unmarshal([]byte(line), &e); err != nil { t.Fatalf("line %d: invalid JSON: %v", i, err) } } } func TestTestPresenter_Line_FailedAttemptIncludesSummaryAndFailureDetails(t *testing.T) { t.Parallel() older := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)} newer := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC)} line := testPresenter{}.Line(buildkite.BuildTest{ Name: "Pipelines::ShardMigration::DeleteOrganizationFromShardWorker with more than BATCH_SIZE records for a shard that needs cleaning", Location: "./spec/workers/pipelines/shard_migration/delete_organization_from_shard_worker_spec.rb:181", ExecutionsCount: 2, ExecutionsCountByResult: buildkite.BuildTestExecutionsCount{ Failed: 2, }, Executions: []buildkite.BuildTestExecution{ { Status: "failed", Location: "./spec/workers/pipelines/shard_migration/delete_organization_from_shard_worker_spec.rb:181", FailureReason: "Failure/Error: expect(empty_tables).to eq({})", Timestamp: &newer, }, { Status: "failed", Location: "./spec/workers/pipelines/shard_migration/delete_organization_from_shard_worker_spec.rb:182", FailureReason: "Failure/Error: expect(empty_tables).to eq({})", Timestamp: &older, }, }, }) if ansiCodesPattern.MatchString(line) { t.Fatalf("expected plain line without ANSI codes, got %q", line) } got := line if !strings.Contains(got, "✗ Pipelines::ShardMigration::DeleteOrgan...records for a shard that needs cleaning") { t.Fatalf("expected long name to preserve the start and end, got %q", got) } if !strings.Contains(got, "2 attempts (0 passed, 2 failed) — ./spec/workers/pipelines/shard_migration/delete_organization_from_shard_worker_spec.rb:181") { t.Fatalf("expected location detail, got %q", got) } if !strings.Contains(got, "Failure/Error: expect(empty_tables).to eq({})") { t.Fatalf("expected failure reason, got %q", got) } if strings.Contains(got, "BATCH_SIZE records for a shard that needs cleaning") { t.Fatalf("expected long name to be truncated, got %q", got) } } func TestFormatTestStatusIcon_UsesLatestExecution(t *testing.T) { t.Parallel() newest := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC)} execution := &buildkite.BuildTestExecution{Status: "passed", Timestamp: &newest} icon := formatTestStatusIcon(execution, false) if got, want := icon, "✓"; got != want { t.Fatalf("icon = %q, want %q", got, want) } } func TestFormatTestStatusIcon_NilExecution(t *testing.T) { t.Parallel() icon := formatTestStatusIcon(nil, false) if got, want := icon, "?"; got != want { t.Fatalf("icon = %q, want %q", got, want) } } func TestTestAttemptCounts_FormatsCorrectly(t *testing.T) { t.Parallel() counts := testAttemptCounts(buildkite.BuildTest{ ExecutionsCount: 5, ExecutionsCountByResult: buildkite.BuildTestExecutionsCount{ Passed: 3, Failed: 2, }, Executions: []buildkite.BuildTestExecution{{Status: "failed"}}, }) if got, want := counts, "5 attempts (3 passed, 2 failed)"; got != want { t.Fatalf("counts = %q, want %q", got, want) } } func TestTestPresenter_Line_PassedLatestAttemptOnlyShowsSummaryLine(t *testing.T) { t.Parallel() oldest := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 29, 0, 0, time.UTC)} middle := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)} newest := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC)} line := testPresenter{}.Line(buildkite.BuildTest{ Name: "Test A", Location: "./spec/example_spec.rb:10", ExecutionsCount: 3, ExecutionsCountByResult: buildkite.BuildTestExecutionsCount{ Passed: 1, Failed: 2, }, Executions: []buildkite.BuildTestExecution{ {Status: "passed", Location: "./spec/example_spec.rb:10", Timestamp: &newest}, {Status: "failed", FailureReason: "Failure/Error: expect(false).to eq(true)", Location: "./spec/example_spec.rb:10", Timestamp: &oldest}, {Status: "failed", FailureReason: "Failure/Error: expect(false).to eq(true)", Location: "./spec/example_spec.rb:10", Timestamp: &middle}, }, }) if ansiCodesPattern.MatchString(line) { t.Fatalf("expected plain line without ANSI codes, got %q", line) } got := line if strings.Contains(got, "./spec/example_spec.rb:10") { t.Fatalf("expected passed attempt to omit location detail, got %q", got) } if strings.Contains(got, "Failure/Error:") { t.Fatalf("expected passed attempt to omit failure detail, got %q", got) } } func TestLatestTestExecution_PicksNewestTimestamp(t *testing.T) { t.Parallel() older := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)} newer := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC)} execution := latestTestExecution(buildkite.BuildTest{ Executions: []buildkite.BuildTestExecution{ {Status: "failed", Location: "./spec/example_spec.rb:11", Timestamp: &older}, {Status: "passed", Location: "./spec/example_spec.rb:12", Timestamp: &newer}, {Status: "failed", Location: "./spec/example_spec.rb:10"}, }, }) if execution == nil { t.Fatal("expected execution to be present") return } if got, want := execution.Status, "passed"; got != want { t.Fatalf("status = %q, want %q", got, want) } if got, want := execution.Location, "./spec/example_spec.rb:12"; got != want { t.Fatalf("location = %q, want %q", got, want) } } func TestLatestTestExecution_IgnoresExecutionsWithoutTimestamps(t *testing.T) { t.Parallel() execution := latestTestExecution(buildkite.BuildTest{ Executions: []buildkite.BuildTestExecution{ {Status: "failed", Location: "./spec/example_spec.rb:10"}, {Status: "passed", Location: "./spec/example_spec.rb:11"}, }, }) if execution != nil { t.Fatalf("expected nil execution, got %#v", execution) } } func TestTestPresenter_ColoredLine_AddsANSIStyles(t *testing.T) { t.Parallel() executionTime := buildkite.Timestamp{Time: time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC)} line := testPresenter{}.ColoredLine(buildkite.BuildTest{ Name: "Test A", ExecutionsCount: 1, ExecutionsCountByResult: buildkite.BuildTestExecutionsCount{ Failed: 1, }, Executions: []buildkite.BuildTestExecution{{ Status: "failed", Location: "./spec/example_spec.rb:10", FailureReason: "Failure/Error: expect(false).to eq(true)", Timestamp: &executionTime, }}, }) if !ansiCodesPattern.MatchString(line) { t.Fatalf("expected colored line with ANSI codes, got %q", line) } got := stripANSI(line) if !strings.Contains(got, "✗ Test A") { t.Fatalf("expected colored line to preserve headline text content, got %q", got) } if !strings.Contains(got, "1 attempt (0 passed, 1 failed) — ./spec/example_spec.rb:10") { t.Fatalf("expected colored line to preserve text content, got %q", got) } } func stripANSI(s string) string { return ansiCodesPattern.ReplaceAllString(s, "") } func TestNewRenderer_NonFileWriterDefaultsToPlain(t *testing.T) { var out bytes.Buffer r := newRenderer(&out, false, false, func() {}) if _, ok := r.(*plainRenderer); !ok { t.Fatalf("expected *plainRenderer when stdout is a non-file io.Writer, got %T", r) } } func TestNewRenderer_TextModeForcesPlain(t *testing.T) { var out bytes.Buffer r := newRenderer(&out, false, true, func() {}) if _, ok := r.(*plainRenderer); !ok { t.Fatalf("expected *plainRenderer when --text is set, got %T", r) } } func TestNewRenderer_JSONModeReturnsJSON(t *testing.T) { var out bytes.Buffer r := newRenderer(&out, true, false, func() {}) if _, ok := r.(*jsonRenderer); !ok { t.Fatalf("expected *jsonRenderer when --json is set, got %T", r) } } func TestPlainRenderer_Render_BuildSummaryPassed(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), Pipeline: "buildkite/cli", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", BuildState: "passed", PassedJobs: []buildkite.Job{ {ID: "job-1", Name: "Lint", Type: "script", State: "passed"}, {ID: "job-2", Name: "Test", Type: "script", State: "passed"}, }, }); err != nil { t.Fatalf("Render() error: %v", err) } got := out.String() if !strings.Contains(got, "✅ Preflight Passed") { t.Fatalf("expected passed header, got %q", got) } if !strings.Contains(got, "Build #42: https://buildkite.com/buildkite/cli/builds/42") { t.Fatalf("expected build URL in summary, got %q", got) } if !strings.Contains(got, "Lint") { t.Fatalf("expected passed job name, got %q", got) } if !strings.Contains(got, "Test") { t.Fatalf("expected passed job name, got %q", got) } } func TestPlainRenderer_Render_BuildSummaryFailed(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) exitOne := 1 if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), Pipeline: "buildkite/cli", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", BuildState: "failed", FailedJobs: []buildkite.Job{ {ID: "job-1", Name: "Lint", Type: "script", State: "failed", ExitStatus: &exitOne}, }, }); err != nil { t.Fatalf("Render() error: %v", err) } got := out.String() if !strings.Contains(got, "❌ Preflight Failed") { t.Fatalf("expected failed header, got %q", got) } if !strings.Contains(got, "Build #42: https://buildkite.com/buildkite/cli/builds/42") { t.Fatalf("expected build URL in summary, got %q", got) } if !strings.Contains(got, "Build Failures:") { t.Fatalf("expected build failures section, got %q", got) } if !strings.Contains(got, "Lint") { t.Fatalf("expected hard-failed job name, got %q", got) } if strings.Contains(got, "Optional check") { t.Fatalf("soft-failed job should not appear in summary, got %q", got) } } func TestPlainRenderer_Render_BuildSummaryStoppedEarly(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) buildCanceled := false exitOne := 1 if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), Pipeline: "buildkite/cli", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", BuildState: "failing", Incomplete: true, StopReason: "build-failing", BuildCanceled: &buildCanceled, FailedJobs: []buildkite.Job{ {ID: "job-1", Name: "Lint", Type: "script", State: "failed", ExitStatus: &exitOne}, }, }); err != nil { t.Fatalf("Render() error: %v", err) } got := out.String() if !strings.Contains(got, "❌ Preflight Incomplete (build failing)") { t.Fatalf("expected early-stop header, got %q", got) } if !strings.Contains(got, "Build Failures:") { t.Fatalf("expected build failures section, got %q", got) } if !strings.Contains(got, "Lint") { t.Fatalf("expected failed job name, got %q", got) } } func TestPlainRenderer_Render_BuildSummaryIncludesTests(t *testing.T) { var out bytes.Buffer r := newPlainRenderer(&out) if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), BuildState: "failed", Tests: internalpreflight.SummaryTests{ Runs: map[string]internalpreflight.SummaryTestRun{ "run-go": {RunID: "run-go", SuiteName: "Go", SuiteSlug: "go", Passed: 12, Failed: 1, Skipped: 0}, "run-rspec": {RunID: "run-rspec", SuiteName: "RSpec", SuiteSlug: "rspec", Passed: 47, Failed: 2, Skipped: 3}, }, Failures: []internalpreflight.SummaryTestFailure{{ RunID: "run-rspec", SuiteName: "RSpec", SuiteSlug: "rspec", Name: "AuthService.validateToken handles expired tokens", Location: "src/auth.test.ts:89", Message: "Expected 'expired' but got 'invalid'", }}, }, }); err != nil { t.Fatalf("Render() error: %v", err) } got := out.String() if !strings.Contains(got, "Tests Failed ✗") { t.Fatalf("expected tests section, got %q", got) } if !strings.Contains(got, "✗ Go 1 failed 12 passed 0 skipped") { t.Fatalf("expected go test summary, got %q", got) } if !strings.Contains(got, "✗ RSpec 2 failed 47 passed 3 skipped") { t.Fatalf("expected rspec test summary, got %q", got) } if !strings.Contains(got, "✗ [RSpec] src/auth.test.ts:89 — AuthService.validateToken handles expired tokens") { t.Fatalf("expected failure header from endpoint summary, got %q", got) } if strings.Contains(got, "Expected 'expired' but got 'invalid'") { t.Fatalf("expected summary failure line to omit failure message, got %q", got) } } func TestJSONRenderer_Render_BuildSummaryPassed(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), PreflightID: "pfid-123", Pipeline: "buildkite/cli", BuildNumber: 42, BuildState: "passed", }); err != nil { t.Fatalf("Render() error: %v", err) } var got map[string]any if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, out.String()) } if got["type"] != "build_summary" { t.Fatalf("expected type build_summary, got %v", got["type"]) } if got["build_state"] != "passed" { t.Fatalf("expected build_state=passed, got %v", got["build_state"]) } if got["failed_jobs"] != nil { t.Fatalf("expected no failed_jobs for passing build, got %v", got["failed_jobs"]) } } func TestJSONRenderer_Render_BuildSummaryFailed(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) exitOne := 1 if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), PreflightID: "pfid-123", Pipeline: "buildkite/cli", BuildNumber: 42, BuildState: "failed", FailedJobs: []buildkite.Job{ {ID: "job-1", Name: "Lint", Type: "script", State: "failed", ExitStatus: &exitOne}, }, }); err != nil { t.Fatalf("Render() error: %v", err) } var got map[string]any if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, out.String()) } if got["build_state"] != "failed" { t.Fatalf("expected build_state=failed, got %v", got["build_state"]) } failedJobs, ok := got["failed_jobs"].([]any) if !ok || len(failedJobs) != 1 { t.Fatalf("expected 1 failed job, got %v", got["failed_jobs"]) } } func TestJSONRenderer_Render_BuildSummaryStoppedEarly(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) buildCanceled := false if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), PreflightID: "pfid-123", Pipeline: "buildkite/cli", BuildNumber: 42, BuildState: "failing", Incomplete: true, StopReason: "build-failing", BuildCanceled: &buildCanceled, }); err != nil { t.Fatalf("Render() error: %v", err) } var got map[string]any if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, out.String()) } if got["type"] != "build_summary" { t.Fatalf("expected type build_summary, got %v", got["type"]) } if got["incomplete"] != true { t.Fatalf("expected incomplete=true, got %v", got["incomplete"]) } if got["stop_reason"] != "build-failing" { t.Fatalf("expected stop_reason=build-failing, got %v", got["stop_reason"]) } if got["build_canceled"] != false { t.Fatalf("expected build_canceled=false, got %v", got["build_canceled"]) } } func TestJSONRenderer_Render_BuildSummaryIncludesTests(t *testing.T) { var out bytes.Buffer r := newJSONRenderer(&out) if err := r.Render(Event{ Type: EventBuildSummary, Time: time.Date(2025, 1, 15, 10, 32, 0, 0, time.UTC), PreflightID: "pfid-123", BuildState: "failed", Tests: internalpreflight.SummaryTests{ Runs: map[string]internalpreflight.SummaryTestRun{ "run-rspec": {RunID: "run-rspec", SuiteName: "RSpec", SuiteSlug: "rspec", Passed: 47, Failed: 2, Skipped: 3}, }, Failures: []internalpreflight.SummaryTestFailure{{ RunID: "run-rspec", SuiteName: "RSpec", SuiteSlug: "rspec", Name: "AuthService.validateToken handles expired tokens", Location: "src/auth.test.ts:89", Message: "Expected 'expired' but got 'invalid'", }}, }, }); err != nil { t.Fatalf("Render() error: %v", err) } var got map[string]any if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, out.String()) } tests, ok := got["tests"].(map[string]any) if !ok { t.Fatalf("expected tests object, got %v", got["tests"]) } runs, ok := tests["runs"].(map[string]any) if !ok { t.Fatalf("expected tests.runs object, got %v", tests["runs"]) } rspec, ok := runs["run-rspec"].(map[string]any) if !ok { t.Fatalf("expected rspec summary, got %v", runs["run-rspec"]) } if rspec["passed"] != float64(47) || rspec["failed"] != float64(2) || rspec["skipped"] != float64(3) { t.Fatalf("unexpected rspec summary: %v", rspec) } if rspec["suite_name"] != "RSpec" || rspec["suite_slug"] != "rspec" || rspec["run_id"] != "run-rspec" { t.Fatalf("unexpected rspec identifiers: %v", rspec) } failures, ok := tests["failures"].([]any) if !ok || len(failures) != 1 { t.Fatalf("expected one failure, got %v", tests["failures"]) } failure, ok := failures[0].(map[string]any) if !ok { t.Fatalf("expected failure object, got %v", failures[0]) } if failure["suite_name"] != "RSpec" || failure["suite_slug"] != "rspec" || failure["run_id"] != "run-rspec" { t.Fatalf("unexpected failure identifiers: %v", failure) } } func TestFormatDuration(t *testing.T) { tests := []struct { d time.Duration want string }{ {1 * time.Second, "1 second"}, {30 * time.Second, "30 seconds"}, {1 * time.Minute, "1 minute"}, {1*time.Minute + 1*time.Second, "1 minute 1 second"}, {1*time.Minute + 30*time.Second, "1 minute 30 seconds"}, {90 * time.Second, "1 minute 30 seconds"}, {10 * time.Minute, "10 minutes"}, {10*time.Minute + 23*time.Second, "10 minutes 23 seconds"}, {1 * time.Hour, "1 hour"}, {1*time.Hour + 1*time.Minute, "1 hour 1 minute"}, {2 * time.Hour, "2 hours"}, {2*time.Hour + 5*time.Minute, "2 hours 5 minutes"}, } for _, tt := range tests { if got := formatDuration(tt.d); got != tt.want { t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) } } } func scriptJob(id, name, state string, softFailed bool, startedAt, finishedAt *buildkite.Timestamp, exitStatus *int) buildkite.Job { return buildkite.Job{ ID: id, Name: name, Type: "script", State: state, SoftFailed: softFailed, StartedAt: startedAt, FinishedAt: finishedAt, ExitStatus: exitStatus, } } ================================================ FILE: cmd/preflight/result.go ================================================ package preflight import ( "fmt" buildstate "github.com/buildkite/cli/v3/internal/build/state" bkErrors "github.com/buildkite/cli/v3/internal/errors" buildkite "github.com/buildkite/go-buildkite/v4" ) type resultKind int const ( resultCompletedPass resultKind = iota resultCompletedFailure resultIncompleteFailure resultIncomplete resultUnknown ) type Result struct { kind resultKind buildState string } func NewResult(build buildkite.Build) Result { state := buildstate.State(build.State) if state == buildstate.Passed { return Result{kind: resultCompletedPass, buildState: build.State} } if buildstate.IsTerminal(state) { return Result{kind: resultCompletedFailure, buildState: build.State} } if state == buildstate.Failing { return Result{kind: resultIncompleteFailure, buildState: build.State} } if buildstate.IsIncomplete(state) { return Result{kind: resultIncomplete, buildState: build.State} } return Result{kind: resultUnknown, buildState: build.State} } // Passed reports whether the build completed successfully. func (r Result) Passed() bool { return r.kind == resultCompletedPass } func (r Result) Error() error { switch r.kind { case resultCompletedPass: return nil case resultCompletedFailure: return bkErrors.NewPreflightCompletedFailureError(nil, fmt.Sprintf("build is %s", r.buildState)) case resultIncompleteFailure: return bkErrors.NewPreflightIncompleteFailureError(nil, fmt.Sprintf("build is %s", r.buildState)) case resultIncomplete: return bkErrors.NewPreflightIncompleteError(nil, fmt.Sprintf("build is %s", r.buildState)) case resultUnknown: return bkErrors.NewPreflightUnknownError( nil, fmt.Sprintf("build is %s", r.buildState), ) default: return bkErrors.NewInternalError( nil, fmt.Sprintf("unexpected result kind %d for build state '%s', unable to coerce to error", r.kind, r.buildState), "This is likely a bug", "Report to Buildkite", ) } } ================================================ FILE: cmd/preflight/result_test.go ================================================ package preflight import ( "strings" "testing" bkErrors "github.com/buildkite/cli/v3/internal/errors" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestResult(t *testing.T) { tests := []struct { name string build buildkite.Build want Result }{ { name: "clean pass", build: buildkite.Build{State: "passed"}, want: Result{kind: resultCompletedPass, buildState: "passed"}, }, { name: "terminal failed build is completed failure", build: buildkite.Build{State: "failed"}, want: Result{kind: resultCompletedFailure, buildState: "failed"}, }, { name: "failing build is incomplete failure", build: buildkite.Build{State: "failing"}, want: Result{kind: resultIncompleteFailure, buildState: "failing"}, }, { name: "running build is incomplete", build: buildkite.Build{State: "running"}, want: Result{kind: resultIncomplete, buildState: "running"}, }, { name: "scheduled build is incomplete", build: buildkite.Build{State: "scheduled"}, want: Result{kind: resultIncomplete, buildState: "scheduled"}, }, { name: "blocked build is incomplete", build: buildkite.Build{State: "blocked"}, want: Result{kind: resultIncomplete, buildState: "blocked"}, }, { name: "canceling build is incomplete", build: buildkite.Build{State: "canceling"}, want: Result{kind: resultIncomplete, buildState: "canceling"}, }, { name: "unknown state is unknown result", build: buildkite.Build{State: "mystery"}, want: Result{kind: resultUnknown, buildState: "mystery"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := NewResult(tt.build); got != tt.want { t.Fatalf("NewResult() = %+v, want %+v", got, tt.want) } }) } } func TestResultPassed(t *testing.T) { tests := []struct { name string build buildkite.Build want bool }{ {name: "passed build", build: buildkite.Build{State: "passed"}, want: true}, {name: "failed build", build: buildkite.Build{State: "failed"}, want: false}, {name: "running build", build: buildkite.Build{State: "running"}, want: false}, {name: "canceled build", build: buildkite.Build{State: "canceled"}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := NewResult(tt.build).Passed(); got != tt.want { t.Fatalf("Passed() = %v, want %v", got, tt.want) } }) } } func TestResultError(t *testing.T) { tests := []struct { name string result Result wantCode int wantErr bool wantText string }{ {name: "clean pass", result: Result{kind: resultCompletedPass, buildState: "passed"}, wantCode: bkErrors.ExitCodeSuccess}, {name: "completed failure", result: Result{kind: resultCompletedFailure, buildState: "failed"}, wantCode: bkErrors.ExitCodePreflightCompletedFailure, wantErr: true, wantText: `preflight completed with failure: build is failed`}, {name: "incomplete failure", result: Result{kind: resultIncompleteFailure, buildState: "failing"}, wantCode: bkErrors.ExitCodePreflightIncompleteFailure, wantErr: true, wantText: `preflight incomplete (failing): build is failing`}, {name: "incomplete", result: Result{kind: resultIncomplete, buildState: "blocked"}, wantCode: bkErrors.ExitCodePreflightIncomplete, wantErr: true, wantText: `preflight incomplete: build is blocked`}, {name: "unknown state", result: Result{kind: resultUnknown, buildState: "passing"}, wantCode: bkErrors.ExitCodePreflightUnknown, wantErr: true, wantText: `preflight result unknown: build is passing`}, {name: "unknown result kind", result: Result{kind: resultKind(99), buildState: "passed"}, wantCode: bkErrors.ExitCodeInternalError, wantErr: true, wantText: `unexpected result kind 99 for build state 'passed', unable to coerce to error`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.result.Error() if (err != nil) != tt.wantErr { t.Fatalf("Result.Error() err presence = %v, wantErr %v", err != nil, tt.wantErr) } if err == nil { if tt.wantCode != bkErrors.ExitCodeSuccess { t.Fatalf("Result.Error() = nil, want exit code %d", tt.wantCode) } return } if code := bkErrors.GetExitCodeForError(err); code != tt.wantCode { t.Fatalf("Result.Error() exit code = %d, want %d", code, tt.wantCode) } if tt.wantText != "" && !strings.Contains(err.Error(), tt.wantText) { t.Fatalf("Result.Error() text = %q, want substring %q", err.Error(), tt.wantText) } }) } } ================================================ FILE: cmd/preflight/test_presenter.go ================================================ package preflight import ( "fmt" "sort" "strings" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" buildkite "github.com/buildkite/go-buildkite/v4" ) type testPresenter struct{} type summarySuiteColumnWidths struct { Label int Failed int Passed int Skipped int } func (p testPresenter) Line(t buildkite.BuildTest) string { return p.line(t, false) } func (p testPresenter) ColoredLine(t buildkite.BuildTest) string { return p.line(t, true) } func (p testPresenter) SummarySuiteLine(summary internalpreflight.SummaryTestRun, widths summarySuiteColumnWidths) string { label := padRightToWidth(summarySuiteLabel(summary.SuiteName, summary.SuiteSlug, "unknown"), widths.Label) icon := "✓" if summary.Failed > 0 { icon = "✗" } return fmt.Sprintf( "%s %s %*d failed %*d passed %*d skipped", icon, label, widths.Failed, summary.Failed, widths.Passed, summary.Passed, widths.Skipped, summary.Skipped, ) } func (p testPresenter) SummaryFailureLine(failure internalpreflight.SummaryTestFailure, width int, indent string) string { suite := summarySuiteLabel(failure.SuiteName, failure.SuiteSlug, "") parts := make([]string, 0, 2) if location := strings.TrimSpace(failure.Location); location != "" { parts = append(parts, location) } if name := strings.TrimSpace(failure.Name); name != "" { parts = append(parts, truncateToWidth(name, 80)) } line := "✗" if suite != "" { line += fmt.Sprintf(" [%s]", suite) } if len(parts) > 0 { line += " " + strings.Join(parts, " — ") } if indent == "" { if width <= 0 { return line } return ansi.Hardwrap(line, width, false) } if width <= runewidth.StringWidth(indent) { return indent + line } if width <= 0 { return indent + line } wrapped := ansi.Hardwrap(line, width-runewidth.StringWidth(indent), false) lines := strings.Split(wrapped, "\n") for i := range lines { lines[i] = indent + lines[i] } return strings.Join(lines, "\n") } func (p testPresenter) line(t buildkite.BuildTest, colored bool) string { name := t.Name if t.Scope != "" { name = t.Scope + " " + name } name = truncateToWidth(name, 80) latestExecution := latestTestExecution(t) statusIcon := formatTestStatusIcon(latestExecution, colored) line := fmt.Sprintf("%s %s", statusIcon, name) if !isFailedTestExecution(latestExecution) { return line } detailParts := make([]string, 0, 2) if attemptSummary := testAttemptCounts(t); attemptSummary != "" { detailParts = append(detailParts, attemptSummary) } if location := latestExecution.Location; location != "" { detailParts = append(detailParts, location) } else if t.Location != "" { detailParts = append(detailParts, t.Location) } if len(detailParts) > 0 { line += fmt.Sprintf("\n %s", formatTestDetail(strings.Join(detailParts, " — "), colored)) } if latestExecution.FailureReason != "" { line += fmt.Sprintf("\n %s", formatTestDetail(latestExecution.FailureReason, colored)) } return line } func testAttemptCounts(t buildkite.BuildTest) string { attempts := t.ExecutionsCount if attempts == 0 { return "" } passed := t.ExecutionsCountByResult.Passed failed := t.ExecutionsCountByResult.Failed return fmt.Sprintf("%d %s (%d passed, %d failed)", attempts, plural(attempts, "attempt"), passed, failed) } func latestTestExecution(t buildkite.BuildTest) *buildkite.BuildTestExecution { executions := testExecutionsInTimestampOrder(t.Executions) if len(executions) == 0 { return nil } latest := executions[len(executions)-1] if latest.Timestamp == nil { return nil } return &latest } func testExecutionsInTimestampOrder(executions []buildkite.BuildTestExecution) []buildkite.BuildTestExecution { ordered := append([]buildkite.BuildTestExecution(nil), executions...) sort.SliceStable(ordered, func(i, j int) bool { left := ordered[i] right := ordered[j] switch { case left.Timestamp == nil && right.Timestamp == nil: return false case left.Timestamp == nil: return true case right.Timestamp == nil: return false default: return left.Timestamp.Before(right.Timestamp.Time) } }) return ordered } func isFailedTestExecution(execution *buildkite.BuildTestExecution) bool { if execution == nil { return false } return strings.EqualFold(execution.Status, "failed") } func formatTestDetail(text string, colored bool) string { if !colored { return text } return "\033[2m" + text + "\033[0m" } func formatTestStatusIcon(execution *buildkite.BuildTestExecution, colored bool) string { status := "" if execution != nil { status = execution.Status } if !colored { switch { case strings.EqualFold(status, "passed"): return "✓" case strings.EqualFold(status, "failed"): return "✗" default: return "?" } } switch { case strings.EqualFold(status, "passed"): return "\033[32m✓\033[0m" case strings.EqualFold(status, "failed"): return "\033[31m✗\033[0m" default: return "\033[2m?\033[0m" } } func truncateToWidth(s string, width int) string { if width <= 0 { return "" } if runewidth.StringWidth(s) <= width { return s } ellipsis := "..." remaining := width - runewidth.StringWidth(ellipsis) if remaining <= 0 { return ellipsis } leftWidth := remaining / 2 rightWidth := remaining - leftWidth return trimLeftToWidth(s, leftWidth) + ellipsis + trimRightToWidth(s, rightWidth) } func trimLeftToWidth(s string, width int) string { var b strings.Builder currentWidth := 0 for _, r := range s { runeWidth := runewidth.RuneWidth(r) if currentWidth+runeWidth > width { break } b.WriteRune(r) currentWidth += runeWidth } return b.String() } func trimRightToWidth(s string, width int) string { runes := []rune(s) currentWidth := 0 start := len(runes) for start > 0 { runeWidth := runewidth.RuneWidth(runes[start-1]) if currentWidth+runeWidth > width { break } currentWidth += runeWidth start-- } return string(runes[start:]) } func padRightToWidth(s string, width int) string { padding := width - runewidth.StringWidth(s) if padding <= 0 { return s } return s + strings.Repeat(" ", padding) } ================================================ FILE: cmd/preflight/test_presenter_test.go ================================================ package preflight import ( "strings" "testing" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" ) func TestTestPresenter_SummarySuiteLine(t *testing.T) { got := testPresenter{}.SummarySuiteLine( internalpreflight.SummaryTestRun{SuiteName: "RSpec", Passed: 47, Failed: 2, Skipped: 3}, summarySuiteColumnWidths{Label: 7, Failed: 1, Passed: 2, Skipped: 1}, ) if got != "✗ RSpec 2 failed 47 passed 3 skipped" { t.Fatalf("unexpected suite summary line: %q", got) } } func TestTestPresenter_SummaryFailureLine_WrapsAndIndents(t *testing.T) { got := testPresenter{}.SummaryFailureLine(internalpreflight.SummaryTestFailure{ SuiteName: "RSpec", Location: "src/auth.test.ts:89", Name: "AuthService.validateToken handles expired tokens and reports the reason cleanly", Message: "Expected 'expired' but got 'invalid' while validating the response payload", }, 60, " ") lines := strings.Split(got, "\n") if len(lines) < 2 { t.Fatalf("expected wrapped failure line, got %q", got) } for _, line := range lines { if !strings.HasPrefix(line, " ") { t.Fatalf("expected indented wrapped line, got %q", line) } } if !strings.Contains(got, "✗ [RSpec] src/auth.test.ts:89") { t.Fatalf("expected suite-prefixed failure line, got %q", got) } if strings.Contains(got, "Expected 'expired' but got 'invalid'") { t.Fatalf("expected summary failure line to omit failure message, got %q", got) } } ================================================ FILE: cmd/preflight/tty.go ================================================ package preflight import ( "context" "fmt" "strings" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) var ( ttyDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) ttyStatusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFBA03")).Bold(true) ttyBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) ttyFailureStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) ttySoftFailureStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) ) type ttyModel struct { spinner spinner.Model latest Event summary *Event cancelFunc context.CancelFunc width int } func newTTYModel() ttyModel { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#DE8F0C")) return ttyModel{spinner: s} } func (m ttyModel) Init() tea.Cmd { return m.spinner.Tick } func (m ttyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": if m.cancelFunc != nil { m.cancelFunc() } return m, nil } case Event: switch msg.Type { case EventOperation: m.latest = msg timestamp := ttyDimStyle.Render(msg.Time.Format("15:04:05")) prefix := timestamp + " " line := prefix + msg.Title if msg.Detail != "" { detail := indentAllLines(msg.Detail, len("15:04:05 ")) line += ":\n" + detail } return m, tea.Printf("%s", m.hardwrapLine(line)) case EventBuildStatus: m.latest = msg return m, nil case EventJobFailure: if msg.Job != nil { presenter := jobPresenter{pipeline: msg.Pipeline, buildNumber: msg.BuildNumber, buildURL: msg.BuildURL} line := timestampPrefix(msg.Time) + presenter.ColoredLine(*msg.Job) return m, tea.Printf("%s", m.hardwrapLine(line)) } case EventJobRetryPassed: if msg.Job != nil { presenter := jobPresenter{pipeline: msg.Pipeline, buildNumber: msg.BuildNumber, buildURL: msg.BuildURL} line := timestampPrefix(msg.Time) + presenter.ColoredRetryPassedLine(*msg.Job) return m, tea.Printf("%s", m.hardwrapLine(line)) } case EventBuildSummary: // Print the summary via Printf (which scrolls it above the // view) instead of rendering it through View(). Inline-image // escape sequences from emoji.Render confuse Bubbletea's // cursor tracking, causing lines to vanish on re-render. m.summary = &msg return m, tea.Sequence( tea.Printf("%s", buildSummaryView(msg, m.width)), tea.Quit, ) case EventTestFailure: if len(msg.TestFailures) > 0 { presenter := testPresenter{} var cmds []tea.Cmd for _, t := range msg.TestFailures { line := formatTimestampedBlock(presenter.ColoredLine(t), msg.Time) cmds = append(cmds, tea.Printf("%s", m.hardwrapLine(line))) } return m, tea.Batch(cmds...) } } case tea.WindowSizeMsg: m.width = msg.Width return m, nil case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd } return m, nil } func (m ttyModel) statusText() string { switch { case m.latest.Title != "": return m.latest.Title case m.latest.BuildState != "": link := terminalHyperlink(fmt.Sprintf("build #%d", m.latest.BuildNumber), m.latest.BuildURL) return fmt.Sprintf("Watching %s (%s)", link, m.latest.BuildState) default: return "Starting..." } } // hardwrapLine pre-wraps text with explicit newlines at the terminal width so that // Bubbletea's line counting matches the physical rows the terminal will use. // This prevents cursor positioning errors that leave View() artifacts in the scrollback. func (m ttyModel) hardwrapLine(s string) string { if m.width <= 0 { return s } return ansi.Hardwrap(s, m.width, false) } func (m ttyModel) render() string { separator := ttyBorderStyle.Render("─────────────────────────────────────────────") statusLine := fmt.Sprintf(" %s %s", m.spinner.View(), ttyStatusStyle.Render(m.statusText())) if m.latest.Jobs == nil { return separator + "\n" + statusLine } parts := make([]string, 0, 6) appendPart := func(count int, text string) { if count > 0 { parts = append(parts, text) } } appendPart(m.latest.Jobs.Passed, fmt.Sprintf("%d passed", m.latest.Jobs.Passed)) appendPart(m.latest.Jobs.Failed, ttyFailureStyle.Render(fmt.Sprintf("%d failed", m.latest.Jobs.Failed))) appendPart(m.latest.Jobs.SoftFailed, ttySoftFailureStyle.Render(fmt.Sprintf("%d soft failed", m.latest.Jobs.SoftFailed))) appendPart(m.latest.Jobs.Running, fmt.Sprintf("%d running", m.latest.Jobs.Running)) appendPart(m.latest.Jobs.Scheduled, fmt.Sprintf("%d scheduled", m.latest.Jobs.Scheduled)) appendPart(m.latest.Jobs.Waiting, fmt.Sprintf("%d waiting", m.latest.Jobs.Waiting)) if len(parts) == 0 { return separator + "\n" + statusLine } summaryLine := fmt.Sprintf(" %s", ttyDimStyle.Render(strings.Join(parts, ", "))) return separator + "\n" + statusLine + "\n" + summaryLine } func (m ttyModel) View() string { if m.summary != nil { // Summary was already printed via tea.Printf; return empty // so Bubbletea clears the spinner area on exit. return "" } return m.hardwrapLine(m.render()) } // buildSummaryView renders the final build summary for TTY output. func buildSummaryView(e Event, width int) string { style := ttyFailureStyle if e.BuildState == "passed" { style = ttyStatusStyle } separator := ttyBorderStyle.Render("─────────────────────────────────────────────") out := separator + "\n" + style.Render(summaryHeader(e)) if label := summaryBuildLabel(e); label != "" && e.BuildURL != "" { out += "\n " + ttyDimStyle.Render(label) buildURL := " " + ttyDimStyle.Render(e.BuildURL) if width > 0 { buildURL = ansi.Hardwrap(buildURL, width, false) } out += "\n" + buildURL } presenter := jobPresenter{pipeline: e.Pipeline, buildNumber: e.BuildNumber, buildURL: e.BuildURL} for _, j := range e.PassedJobs { out += "\n " + presenter.ColoredPassedLine(j, ttyDimStyle) } out += buildSummaryDetails(e, true, width) return out } type ttyRenderer struct { program *tea.Program done chan struct{} err error } func newTTYRenderer(cancel context.CancelFunc) *ttyRenderer { model := newTTYModel() model.cancelFunc = cancel p := tea.NewProgram(model) r := &ttyRenderer{program: p, done: make(chan struct{})} go func() { if _, err := p.Run(); err != nil { r.err = err } close(r.done) }() return r } func (r *ttyRenderer) Render(e Event) error { r.program.Send(e) return nil } func (r *ttyRenderer) Close() error { r.program.Quit() <-r.done return r.err } ================================================ FILE: cmd/preflight/tty_test.go ================================================ package preflight import ( "strings" "testing" internalpreflight "github.com/buildkite/cli/v3/internal/preflight" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestBuildSummaryView_ReturnsOutput(t *testing.T) { tests := []struct { name string width int event Event contains []string }{ { name: "passed build no jobs", width: 0, event: Event{ Type: EventBuildSummary, BuildState: "passed", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", }, contains: []string{"─────", "Build #42", "https://buildkite.com/buildkite/cli/builds/42"}, }, { name: "passed build with jobs", width: 0, event: Event{ Type: EventBuildSummary, BuildState: "passed", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", PassedJobs: []buildkite.Job{ {ID: "j1", Name: "Lint", Type: "script", State: "passed"}, {ID: "j2", Name: "Test", Type: "script", State: "passed"}, }, }, contains: []string{"Build #42", "https://buildkite.com/buildkite/cli/builds/42", "✔ Lint", "✔ Test"}, }, { name: "failed build no jobs", width: 0, event: Event{ Type: EventBuildSummary, BuildState: "failed", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", }, contains: []string{"─────", "Build #42", "https://buildkite.com/buildkite/cli/builds/42"}, }, { name: "failed build with jobs", width: 0, event: Event{ Type: EventBuildSummary, BuildState: "failed", Pipeline: "buildkite/cli", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", FailedJobs: func() []buildkite.Job { exit := 1 return []buildkite.Job{ {ID: "j1", Name: "Lint", Type: "script", State: "failed", ExitStatus: &exit}, } }(), }, contains: []string{"Build #42", "https://buildkite.com/buildkite/cli/builds/42", "✗", "Lint", "failed with exit 1"}, }, { name: "build stopped early", width: 0, event: Event{ Type: EventBuildSummary, BuildState: "failing", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", Incomplete: true, StopReason: "build-failing", }, contains: []string{"Preflight Incomplete (build failing)", "Build #42", "https://buildkite.com/buildkite/cli/builds/42"}, }, { name: "wraps build url on narrow terminals", width: 24, event: Event{ Type: EventBuildSummary, BuildState: "passed", BuildNumber: 42, BuildURL: "https://buildkite.com/buildkite/cli/builds/42", }, contains: []string{"Build #42", "https://buildkite.com/", "buildkite/cli/builds/42"}, }, { name: "build with tests", event: Event{ Type: EventBuildSummary, BuildState: "failed", Tests: internalpreflight.SummaryTests{ Runs: map[string]internalpreflight.SummaryTestRun{ "run-rspec": {RunID: "run-rspec", SuiteName: "RSpec", SuiteSlug: "rspec", Passed: 47, Failed: 2, Skipped: 3}, }, Failures: []internalpreflight.SummaryTestFailure{{ RunID: "run-rspec", SuiteName: "RSpec", SuiteSlug: "rspec", Name: "AuthService.validateToken handles expired tokens", Location: "src/auth.test.ts:89", Message: "Expected 'expired' but got 'invalid'", }}, }, }, contains: []string{"Tests Failed ✗", "✗ RSpec 2 failed 47 passed 3 skipped", "✗ [RSpec] src/auth.test.ts:89 — AuthService.validateToken handles expired tokens"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := buildSummaryView(tt.event, tt.width) if got == "" { t.Fatal("expected non-empty summary view") } for _, want := range tt.contains { if !strings.Contains(got, want) { t.Errorf("missing %q in output:\n%s", want, got) } } }) } } ================================================ FILE: cmd/queue/create.go ================================================ package queue import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type CreateCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID to create the queue in" name:"cluster-uuid"` Key string `help:"A unique key for the queue" required:""` Description string `help:"A description of the queue" optional:""` RetryAgentAffinity string `help:"Retry agent affinity setting (prefer-warmest or prefer-different)" optional:"" name:"retry-agent-affinity"` output.OutputFlags } func (c *CreateCmd) Validate() error { switch buildkite.RetryAgentAffinity(c.RetryAgentAffinity) { case "", buildkite.RetryAgentAffinityPreferWarmest, buildkite.RetryAgentAffinityPreferDifferent: return nil default: return fmt.Errorf( "invalid --retry-agent-affinity value %q: must be %s or %s", c.RetryAgentAffinity, buildkite.RetryAgentAffinityPreferWarmest, buildkite.RetryAgentAffinityPreferDifferent, ) } } func (c *CreateCmd) Help() string { return ` Examples: # Create a queue with just a key $ bk queue create my-cluster-uuid --key my-queue # Create a queue with a description $ bk queue create my-cluster-uuid --key my-queue --description "My new queue" # Create a queue with retry agent affinity set $ bk queue create my-cluster-uuid --key my-queue --retry-agent-affinity prefer-different # Create a queue and output as JSON $ bk queue create my-cluster-uuid --key my-queue -o json ` } func (c *CreateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() input := buildkite.ClusterQueueCreate{ Key: c.Key, Description: c.Description, } if c.RetryAgentAffinity != "" { input.RetryAgentAffinity = buildkite.RetryAgentAffinity(c.RetryAgentAffinity) } var queue buildkite.ClusterQueue if err = bkIO.SpinWhile(f, "Creating cluster queue", func() error { var apiErr error queue, _, apiErr = f.RestAPIClient.ClusterQueues.Create(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, input) return apiErr }); err != nil { return fmt.Errorf("error creating cluster queue: %w", err) } queueView := output.Viewable[buildkite.ClusterQueue]{ Data: queue, Render: renderQueueText, } if format != output.FormatText { return output.Write(os.Stdout, queueView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "Queue %s created successfully\n\n", queue.Key) return output.Write(writer, queueView, format) } ================================================ FILE: cmd/queue/delete.go ================================================ package queue import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type DeleteCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID the queue belongs to" name:"cluster-uuid"` QueueUUID string `arg:"" help:"Queue UUID to delete" name:"queue-uuid"` } func (c *DeleteCmd) Help() string { return ` You will be prompted to confirm deletion unless --yes is set. Examples: # Delete a queue $ bk queue delete my-cluster-uuid my-queue-uuid # Delete a queue without confirmation $ bk queue delete my-cluster-uuid my-queue-uuid --yes ` } func (c *DeleteCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() confirmed, err := bkIO.Confirm(f, fmt.Sprintf("Are you sure you want to delete queue %s?", c.QueueUUID)) if err != nil { return err } if !confirmed { fmt.Fprintln(os.Stderr, "Deletion cancelled.") return nil } if err = bkIO.SpinWhile(f, "Deleting cluster queue", func() error { _, apiErr := f.RestAPIClient.ClusterQueues.Delete(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.QueueUUID) return apiErr }); err != nil { return fmt.Errorf("error deleting cluster queue: %w", err) } fmt.Fprintf(os.Stderr, "Queue %s deleted successfully.\n", c.QueueUUID) return nil } ================================================ FILE: cmd/queue/list.go ================================================ package queue import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ListCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID to list queues for" name:"cluster-uuid"` PerPage int `help:"Number of queues per page" default:"30"` Limit int `help:"Maximum number of queues to return" default:"100"` output.OutputFlags } func (c *ListCmd) Validate() error { if c.PerPage < 1 { return fmt.Errorf("invalid --per-page %d: must be greater than 0", c.PerPage) } if c.Limit < 0 { return fmt.Errorf("invalid --limit %d: must be greater than or equal to 0", c.Limit) } return nil } func (c *ListCmd) Help() string { return ` Examples: # List all queues for a cluster $ bk queue list my-cluster-uuid # Return more queues $ bk queue list my-cluster-uuid --limit 200 # List in JSON format $ bk queue list my-cluster-uuid -o json ` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var queues []buildkite.ClusterQueue page := 1 var previousFirstQueueID string for len(queues) < c.Limit { opts := &buildkite.ClusterQueuesListOptions{ ListOptions: buildkite.ListOptions{ Page: page, PerPage: c.PerPage, }, } var pageQueues []buildkite.ClusterQueue if err := bkIO.SpinWhile(f, "Fetching cluster queues", func() error { var apiErr error pageQueues, _, apiErr = f.RestAPIClient.ClusterQueues.List(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, opts) return apiErr }); err != nil { return fmt.Errorf("error fetching cluster queues: %w", err) } if len(pageQueues) == 0 { break } if page > 1 && pageQueues[0].ID == previousFirstQueueID { return fmt.Errorf("API returned duplicate page content at page %d, stopping pagination to prevent infinite loop", page) } previousFirstQueueID = pageQueues[0].ID queues = append(queues, pageQueues...) if len(pageQueues) < c.PerPage { break } if len(queues) >= c.Limit { break } page++ } if len(queues) > c.Limit { queues = queues[:c.Limit] } if format != output.FormatText { return output.Write(os.Stdout, queues, format) } if len(queues) == 0 { fmt.Fprintln(os.Stdout, "No queues found") return nil } rows := make([][]string, 0, len(queues)) for _, q := range queues { paused := "No" if q.DispatchPaused { paused = "Yes" } rows = append(rows, []string{q.Key, output.ValueOrDash(q.Description), paused, q.ID}) } table := output.Table( []string{"Key", "Description", "Paused", "ID"}, rows, map[string]string{"key": "bold"}, ) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() _, err = fmt.Fprintf(writer, "Queues (%d)\n\n%s\n", len(queues), table) return err } ================================================ FILE: cmd/queue/pause.go ================================================ package queue import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type PauseCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID the queue belongs to" name:"cluster-uuid"` QueueUUID string `arg:"" help:"Queue UUID to pause" name:"queue-uuid"` Note string `help:"Optional note explaining why the queue is being paused" optional:"" name:"note"` output.OutputFlags } func (c *PauseCmd) Help() string { return ` The queue remains paused until it is resumed with "bk queue resume". Examples: # Pause a queue $ bk queue pause my-cluster-uuid my-queue-uuid # Pause a queue with a note $ bk queue pause my-cluster-uuid my-queue-uuid --note "Pausing for maintenance" # Output the paused queue as JSON $ bk queue pause my-cluster-uuid my-queue-uuid --note "Maintenance" -o json ` } func (c *PauseCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() input := buildkite.ClusterQueuePause{ Note: c.Note, } var queue buildkite.ClusterQueue if err = bkIO.SpinWhile(f, "Pausing cluster queue", func() error { var apiErr error queue, _, apiErr = f.RestAPIClient.ClusterQueues.Pause(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.QueueUUID, input) return apiErr }); err != nil { return fmt.Errorf("error pausing cluster queue: %w", err) } queueView := output.Viewable[buildkite.ClusterQueue]{ Data: queue, Render: renderQueueText, } if format != output.FormatText { return output.Write(os.Stdout, queueView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "Queue %s paused successfully\n\n", queue.Key) return output.Write(writer, queueView, format) } ================================================ FILE: cmd/queue/queue_test.go ================================================ package queue import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" buildkite "github.com/buildkite/go-buildkite/v4" ) func newTestQueue(key, id string, paused bool) buildkite.ClusterQueue { return buildkite.ClusterQueue{ ID: id, Key: key, Description: "Test queue", DispatchPaused: paused, CreatedAt: &buildkite.Timestamp{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, } } func TestCmdQueueList(t *testing.T) { t.Parallel() t.Run("validates per-page and limit", func(t *testing.T) { t.Parallel() tests := []struct { name string perPage int limit int wantErr bool }{ {"valid defaults", 30, 100, false}, {"per-page zero invalid", 0, 100, true}, {"per-page negative invalid", -1, 100, true}, {"limit zero valid", 30, 0, false}, {"limit negative invalid", 30, -1, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() cmd := &ListCmd{PerPage: tt.perPage, Limit: tt.limit} err := cmd.Validate() if tt.wantErr && err == nil { t.Error("expected error, got nil") } if !tt.wantErr && err != nil { t.Errorf("unexpected error: %v", err) } }) } }) t.Run("fetches queues through API", func(t *testing.T) { t.Parallel() queues := []buildkite.ClusterQueue{ newTestQueue("default", "queue-1", false), newTestQueue("deploy", "queue-2", true), } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page := r.URL.Query().Get("page") if page == "" || page == "1" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(queues) } else { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]buildkite.ClusterQueue{}) } })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() got, _, err := client.ClusterQueues.List(ctx, "test-org", "cluster-1", &buildkite.ClusterQueuesListOptions{ ListOptions: buildkite.ListOptions{Page: 1, PerPage: 30}, }) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("expected 2 queues, got %d", len(got)) } if got[0].Key != "default" { t.Errorf("expected first key 'default', got %q", got[0].Key) } if !got[1].DispatchPaused { t.Error("expected second queue to be paused") } }) t.Run("empty result returns empty slice", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]buildkite.ClusterQueue{}) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() got, _, err := client.ClusterQueues.List(ctx, "test-org", "cluster-1", nil) if err != nil { t.Fatal(err) } if len(got) != 0 { t.Errorf("expected 0 queues, got %d", len(got)) } }) } func TestCmdQueueCreate(t *testing.T) { t.Parallel() t.Run("validates retry agent affinity", func(t *testing.T) { t.Parallel() tests := []struct { name string affinity string wantErr bool }{ {"empty affinity valid", "", false}, {"prefer-warmest valid", string(buildkite.RetryAgentAffinityPreferWarmest), false}, {"prefer-different valid", string(buildkite.RetryAgentAffinityPreferDifferent), false}, {"invalid affinity", "prefer-random", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() cmd := &CreateCmd{ClusterUUID: "cluster-1", Key: "my-queue", RetryAgentAffinity: tt.affinity} err := cmd.Validate() if tt.wantErr && err == nil { t.Error("expected error, got nil") } if !tt.wantErr && err != nil { t.Errorf("unexpected error: %v", err) } }) } }) t.Run("creates queue through API", func(t *testing.T) { t.Parallel() queue := newTestQueue("my-queue", "queue-abc", false) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(queue) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() got, _, err := client.ClusterQueues.Create(ctx, "test-org", "cluster-1", buildkite.ClusterQueueCreate{ Key: "my-queue", Description: "Test queue", }) if err != nil { t.Fatal(err) } if got.Key != "my-queue" { t.Errorf("expected key 'my-queue', got %q", got.Key) } }) } func TestCmdQueueUpdate(t *testing.T) { t.Parallel() t.Run("requires at least one field", func(t *testing.T) { t.Parallel() tests := []struct { name string description string affinity string wantErr bool }{ {"no fields provided", "", "", true}, {"description only", "new desc", "", false}, {"affinity only valid", string(buildkite.RetryAgentAffinityPreferWarmest), "", false}, {"both fields", "new desc", string(buildkite.RetryAgentAffinityPreferDifferent), false}, {"invalid affinity", "", "bad-value", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() cmd := &UpdateCmd{ ClusterUUID: "cluster-1", QueueUUID: "queue-1", Description: tt.description, RetryAgentAffinity: tt.affinity, } err := cmd.Validate() if tt.wantErr && err == nil { t.Error("expected error, got nil") } if !tt.wantErr && err != nil { t.Errorf("unexpected error: %v", err) } }) } }) t.Run("updates queue through API", func(t *testing.T) { t.Parallel() updated := newTestQueue("my-queue", "queue-abc", false) updated.Description = "Updated description" s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(updated) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() got, _, err := client.ClusterQueues.Update(ctx, "test-org", "cluster-1", "queue-abc", buildkite.ClusterQueueUpdate{ Description: "Updated description", }) if err != nil { t.Fatal(err) } if got.Description != "Updated description" { t.Errorf("expected description 'Updated description', got %q", got.Description) } }) } func TestCmdQueueDelete(t *testing.T) { t.Parallel() t.Run("sends DELETE request", func(t *testing.T) { t.Parallel() called := false s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) } w.WriteHeader(http.StatusNoContent) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() _, err = client.ClusterQueues.Delete(ctx, "test-org", "cluster-1", "queue-abc") if err != nil { t.Fatal(err) } if !called { t.Error("expected DELETE to be called") } }) } func TestCmdQueuePause(t *testing.T) { t.Parallel() t.Run("sends POST to pause_dispatch with note", func(t *testing.T) { t.Parallel() queue := newTestQueue("my-queue", "queue-abc", true) queue.DispatchPausedNote = "maintenance" s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if !strings.HasSuffix(r.URL.Path, "/pause_dispatch") { t.Errorf("expected path to end in /pause_dispatch, got %q", r.URL.Path) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(queue) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() got, _, err := client.ClusterQueues.Pause(ctx, "test-org", "cluster-1", "queue-abc", buildkite.ClusterQueuePause{ Note: "maintenance", }) if err != nil { t.Fatal(err) } if !got.DispatchPaused { t.Error("expected queue to be paused") } if got.DispatchPausedNote != "maintenance" { t.Errorf("expected note 'maintenance', got %q", got.DispatchPausedNote) } }) } func TestCmdQueueResume(t *testing.T) { t.Parallel() t.Run("sends POST to resume_dispatch", func(t *testing.T) { t.Parallel() called := false s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if !strings.HasSuffix(r.URL.Path, "/resume_dispatch") { t.Errorf("expected path to end in /resume_dispatch, got %q", r.URL.Path) } w.WriteHeader(http.StatusOK) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } ctx := context.Background() _, err = client.ClusterQueues.Resume(ctx, "test-org", "cluster-1", "queue-abc") if err != nil { t.Fatal(err) } if !called { t.Error("expected resume_dispatch to be called") } }) } func TestRenderQueueText(t *testing.T) { t.Parallel() t.Run("unpaused queue", func(t *testing.T) { t.Parallel() q := newTestQueue("my-queue", "queue-abc", false) out := renderQueueText(q) if !strings.Contains(out, "my-queue") { t.Error("expected output to contain queue key") } if !strings.Contains(out, "No") { t.Error("expected output to show paused as 'No'") } }) t.Run("paused queue with note", func(t *testing.T) { t.Parallel() q := newTestQueue("my-queue", "queue-abc", true) q.DispatchPausedNote = "maintenance window" out := renderQueueText(q) if !strings.Contains(out, "Yes") { t.Error("expected output to show paused as 'Yes'") } if !strings.Contains(out, "maintenance window") { t.Error("expected output to contain pause note") } }) } ================================================ FILE: cmd/queue/resume.go ================================================ package queue import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ResumeCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID the queue belongs to" name:"cluster-uuid"` QueueUUID string `arg:"" help:"Queue UUID to resume" name:"queue-uuid"` output.OutputFlags } func (c *ResumeCmd) Help() string { return ` Resumes dispatch for a paused cluster queue. Examples: # Resume a queue $ bk queue resume my-cluster-uuid my-queue-uuid # Output the resumed queue as JSON $ bk queue resume my-cluster-uuid my-queue-uuid -o json ` } func (c *ResumeCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err = bkIO.SpinWhile(f, "Resuming cluster queue", func() error { _, apiErr := f.RestAPIClient.ClusterQueues.Resume(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.QueueUUID) return apiErr }); err != nil { return fmt.Errorf("error resuming cluster queue: %w", err) } var queue buildkite.ClusterQueue if err = bkIO.SpinWhile(f, "Loading cluster queue", func() error { var apiErr error queue, _, apiErr = f.RestAPIClient.ClusterQueues.Get(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.QueueUUID) return apiErr }); err != nil { return fmt.Errorf("error fetching cluster queue: %w", err) } queueView := output.Viewable[buildkite.ClusterQueue]{ Data: queue, Render: renderQueueText, } if format != output.FormatText { return output.Write(os.Stdout, queueView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "Queue %s resumed successfully\n\n", queue.Key) return output.Write(writer, queueView, format) } ================================================ FILE: cmd/queue/update.go ================================================ package queue import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type UpdateCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID the queue belongs to" name:"cluster-uuid"` QueueUUID string `arg:"" help:"Queue UUID to update" name:"queue-uuid"` Description string `help:"New description for the queue" optional:""` RetryAgentAffinity string `help:"Retry agent affinity (prefer-warmest or prefer-different)" optional:"" name:"retry-agent-affinity"` output.OutputFlags } func (c *UpdateCmd) Validate() error { if c.Description == "" && c.RetryAgentAffinity == "" { return fmt.Errorf("at least one of --description or --retry-agent-affinity must be provided") } switch buildkite.RetryAgentAffinity(c.RetryAgentAffinity) { case "", buildkite.RetryAgentAffinityPreferWarmest, buildkite.RetryAgentAffinityPreferDifferent: return nil default: return fmt.Errorf( "invalid --retry-agent-affinity value %q: must be %s or %s", c.RetryAgentAffinity, buildkite.RetryAgentAffinityPreferWarmest, buildkite.RetryAgentAffinityPreferDifferent, ) } } func (c *UpdateCmd) Help() string { return ` At least one of --description or --retry-agent-affinity must be provided. Examples: # Update a queue's description $ bk queue update my-cluster-uuid my-queue-uuid --description "New description" # Update retry agent affinity $ bk queue update my-cluster-uuid my-queue-uuid --retry-agent-affinity prefer-different # Update both settings $ bk queue update my-cluster-uuid my-queue-uuid --description "New description" --retry-agent-affinity prefer-warmest # Output the updated queue as JSON $ bk queue update my-cluster-uuid my-queue-uuid --description "New description" -o json ` } func (c *UpdateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() input := buildkite.ClusterQueueUpdate{ Description: c.Description, } if c.RetryAgentAffinity != "" { input.RetryAgentAffinity = buildkite.RetryAgentAffinity(c.RetryAgentAffinity) } var queue buildkite.ClusterQueue if err = bkIO.SpinWhile(f, "Updating cluster queue", func() error { var apiErr error queue, _, apiErr = f.RestAPIClient.ClusterQueues.Update(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.QueueUUID, input) return apiErr }); err != nil { return fmt.Errorf("error updating cluster queue: %w", err) } queueView := output.Viewable[buildkite.ClusterQueue]{ Data: queue, Render: renderQueueText, } if format != output.FormatText { return output.Write(os.Stdout, queueView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "Queue %s updated successfully\n\n", queue.Key) return output.Write(writer, queueView, format) } ================================================ FILE: cmd/queue/view.go ================================================ package queue import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "time" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ViewCmd struct { ClusterUUID string `arg:"" help:"Cluster UUID the queue belongs to" name:"cluster-uuid"` QueueUUID string `arg:"" help:"Queue UUID to view" name:"queue-uuid"` output.OutputFlags } func (c *ViewCmd) Help() string { return ` Examples: # View a queue $ bk queue view my-cluster-uuid my-queue-uuid # View a queue in JSON format $ bk queue view my-cluster-uuid my-queue-uuid -o json ` } func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var queue buildkite.ClusterQueue if err = bkIO.SpinWhile(f, "Loading cluster queue", func() error { var apiErr error queue, _, apiErr = f.RestAPIClient.ClusterQueues.Get(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.QueueUUID) return apiErr }); err != nil { return fmt.Errorf("error loading cluster queue: %w", err) } queueView := output.Viewable[buildkite.ClusterQueue]{ Data: queue, Render: renderQueueText, } if format != output.FormatText { return output.Write(os.Stdout, queueView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() return output.Write(writer, queueView, format) } func renderQueueText(q buildkite.ClusterQueue) string { paused := "No" if q.DispatchPaused { paused = "Yes" } rows := [][]string{ {"Key", output.ValueOrDash(q.Key)}, {"Description", output.ValueOrDash(q.Description)}, {"ID", output.ValueOrDash(q.ID)}, {"GraphQL ID", output.ValueOrDash(q.GraphQLID)}, {"Retry Agent Affinity", output.ValueOrDash(string(q.RetryAgentAffinity))}, {"Dispatch Paused", paused}, {"Web URL", output.ValueOrDash(q.WebURL)}, {"API URL", output.ValueOrDash(q.URL)}, {"Cluster URL", output.ValueOrDash(q.ClusterURL)}, } if q.DispatchPaused { rows = append(rows, []string{"Dispatch Paused Note", output.ValueOrDash(q.DispatchPausedNote)}) if q.DispatchPausedAt != nil { rows = append(rows, []string{"Dispatch Paused At", q.DispatchPausedAt.Format(time.RFC3339)}) } if q.DispatchPausedBy != nil { rows = append( rows, []string{"Dispatch Paused By Name", output.ValueOrDash(q.DispatchPausedBy.Name)}, []string{"Dispatch Paused By Email", output.ValueOrDash(q.DispatchPausedBy.Email)}, ) } } if q.CreatedBy.ID != "" { rows = append( rows, []string{"Created By Name", output.ValueOrDash(q.CreatedBy.Name)}, []string{"Created By Email", output.ValueOrDash(q.CreatedBy.Email)}, []string{"Created By ID", output.ValueOrDash(q.CreatedBy.ID)}, ) } if q.CreatedAt != nil { rows = append(rows, []string{"Created At", q.CreatedAt.Format(time.RFC3339)}) } var sb strings.Builder fmt.Fprintf(&sb, "Queue: %s\n\n", output.ValueOrDash(q.Key)) table := output.Table( []string{"Field", "Value"}, rows, map[string]string{"field": "dim", "value": "italic"}, ) sb.WriteString(table) return sb.String() } ================================================ FILE: cmd/secret/create.go ================================================ package secret import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type CreateCmd struct { ClusterUUID string `help:"The UUID of the cluster" required:"" name:"cluster-uuid"` Key string `help:"The key name for the secret (e.g. MY_SECRET)" required:""` Value string `help:"The secret value. If not provided, you will be prompted to enter it." optional:""` Description string `help:"A description of the secret" optional:""` Policy string `help:"The access policy for the secret (YAML format)" optional:""` output.OutputFlags } func (c *CreateCmd) Help() string { return ` Create a new secret in a cluster. If --value is not provided, you will be prompted to enter the secret value interactively (input will be masked). Examples: # Create a secret with interactive value input $ bk secret create --cluster-uuid my-cluster-uuid --key MY_SECRET # Create a secret with the value provided inline $ bk secret create --cluster-uuid my-cluster-uuid --key MY_SECRET --value "s3cr3t" # Create a secret with a description $ bk secret create --cluster-uuid my-cluster-uuid --key MY_SECRET --description "My secret description" ` } func (c *CreateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } value := c.Value if value == "" { if f.NoInput { return fmt.Errorf("--value is required when --no-input is set") } fmt.Fprint(os.Stderr, "Enter secret value: ") value, err = bkIO.ReadPassword() fmt.Fprintln(os.Stderr) if err != nil { return fmt.Errorf("error reading secret value: %v", err) } if value == "" { return fmt.Errorf("secret value cannot be empty") } } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() input := buildkite.ClusterSecretCreate{ Key: c.Key, Value: value, Description: c.Description, Policy: c.Policy, } var secret buildkite.ClusterSecret if err = bkIO.SpinWhile(f, "Creating secret", func() error { var apiErr error secret, _, apiErr = f.RestAPIClient.ClusterSecrets.Create(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, input) return apiErr }); err != nil { return fmt.Errorf("error creating secret: %v", err) } secretView := output.Viewable[buildkite.ClusterSecret]{ Data: secret, Render: renderSecretText, } if format != output.FormatText { return output.Write(os.Stdout, secretView, format) } fmt.Fprintf(os.Stdout, "Secret %s created successfully\n\n", secret.Key) return output.Write(os.Stdout, secretView, format) } ================================================ FILE: cmd/secret/delete.go ================================================ package secret import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type DeleteCmd struct { ClusterUUID string `help:"The UUID of the cluster" required:"" name:"cluster-uuid"` SecretID string `help:"The UUID of the secret to delete" required:"" name:"secret-id"` } func (c *DeleteCmd) Help() string { return ` Delete a secret from a cluster. You will be prompted to confirm deletion unless --yes is set. Examples: # Delete a secret (with confirmation prompt) $ bk secret delete --cluster-uuid my-cluster-uuid --secret-id my-secret-id # Delete a secret without confirmation $ bk secret delete --cluster-uuid my-cluster-uuid --secret-id my-secret-id --yes ` } func (c *DeleteCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() confirmed, err := bkIO.Confirm(f, fmt.Sprintf("Are you sure you want to delete secret %s?", c.SecretID)) if err != nil { return err } if !confirmed { fmt.Fprintln(os.Stderr, "Deletion cancelled.") return nil } if err = bkIO.SpinWhile(f, "Deleting secret", func() error { _, err = f.RestAPIClient.ClusterSecrets.Delete(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.SecretID) return err }); err != nil { return fmt.Errorf("error deleting secret: %v", err) } fmt.Fprintln(os.Stderr, "Secret deleted successfully.") return nil } ================================================ FILE: cmd/secret/get.go ================================================ package secret import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "time" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type GetCmd struct { ClusterUUID string `help:"The UUID of the cluster" required:"" name:"cluster-uuid"` SecretID string `help:"The UUID of the secret to view" required:"" name:"secret-id"` output.OutputFlags } func (c *GetCmd) Help() string { return ` View details of a cluster secret. Examples: # View a secret $ bk secret get --cluster-uuid my-cluster-uuid --secret-id my-secret-id # View a secret in JSON format $ bk secret get --cluster-uuid my-cluster-uuid --secret-id my-secret-id -o json ` } func (c *GetCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var secret buildkite.ClusterSecret if err = bkIO.SpinWhile(f, "Loading secret", func() error { var apiErr error secret, _, apiErr = f.RestAPIClient.ClusterSecrets.Get(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, c.SecretID) return apiErr }); err != nil { return fmt.Errorf("error fetching secret: %v", err) } secretView := output.Viewable[buildkite.ClusterSecret]{ Data: secret, Render: renderSecretText, } if format != output.FormatText { return output.Write(os.Stdout, secretView, format) } writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() return output.Write(writer, secretView, format) } func renderSecretText(s buildkite.ClusterSecret) string { rows := [][]string{ {"Key", output.ValueOrDash(s.Key)}, {"ID", output.ValueOrDash(s.ID)}, {"Description", output.ValueOrDash(s.Description)}, {"Policy", output.ValueOrDash(s.Policy)}, } if s.CreatedBy.ID != "" { rows = append( rows, []string{"Created By", output.ValueOrDash(s.CreatedBy.Name)}, ) } if s.CreatedAt != nil { rows = append(rows, []string{"Created At", s.CreatedAt.Format(time.RFC3339)}) } if s.UpdatedBy != nil && s.UpdatedBy.ID != "" { rows = append( rows, []string{"Updated By", output.ValueOrDash(s.UpdatedBy.Name)}, ) } if s.UpdatedAt != nil { rows = append(rows, []string{"Updated At", s.UpdatedAt.Format(time.RFC3339)}) } if s.LastReadAt != nil { rows = append(rows, []string{"Last Read At", s.LastReadAt.Format(time.RFC3339)}) } var sb strings.Builder fmt.Fprintf(&sb, "Viewing secret %s\n\n", output.ValueOrDash(s.Key)) table := output.Table( []string{"Field", "Value"}, rows, map[string]string{"field": "dim", "value": "italic"}, ) sb.WriteString(table) return sb.String() } ================================================ FILE: cmd/secret/list.go ================================================ package secret import ( "context" "errors" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" internalSecret "github.com/buildkite/cli/v3/internal/secret" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type ListCmd struct { ClusterUUID string `help:"The UUID of the cluster to list secrets for" required:"" name:"cluster-uuid"` output.OutputFlags } func (c *ListCmd) Help() string { return ` List secrets for a cluster. Examples: # List all secrets in a cluster $ bk secret list --cluster-uuid my-cluster-uuid # List secrets in JSON format $ bk secret list --cluster-uuid my-cluster-uuid -o json ` } func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var secrets []buildkite.ClusterSecret if err = bkIO.SpinWhile(f, "Loading secrets", func() error { var apiErr error secrets, _, apiErr = f.RestAPIClient.ClusterSecrets.List(ctx, f.Config.OrganizationSlug(), c.ClusterUUID, nil) return apiErr }); err != nil { return fmt.Errorf("error fetching secrets: %v", err) } if len(secrets) == 0 { return errors.New("no secrets found for cluster") } if format != output.FormatText { return output.Write(os.Stdout, secrets, format) } summary := internalSecret.SecretViewTable(secrets...) writer, cleanup := bkIO.Pager(f.NoPager, f.Config.Pager()) defer func() { _ = cleanup() }() fmt.Fprintf(writer, "%v\n", summary) return nil } ================================================ FILE: cmd/secret/secret_test.go ================================================ package secret import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestListSecrets(t *testing.T) { t.Parallel() t.Run("fetches secrets through API", func(t *testing.T) { t.Parallel() secrets := []buildkite.ClusterSecret{ { ID: "secret-1", Key: "MY_SECRET", Description: "A test secret", }, { ID: "secret-2", Key: "ANOTHER_SECRET", Description: "Another test secret", }, } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/clusters/cluster-123/secrets") { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(secrets) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterSecrets.List(context.Background(), "test-org", "cluster-123", nil) if err != nil { t.Fatal(err) } if len(result) != 2 { t.Fatalf("expected 2 secrets, got %d", len(result)) } if result[0].Key != "MY_SECRET" { t.Errorf("expected key 'MY_SECRET', got %q", result[0].Key) } if result[1].ID != "secret-2" { t.Errorf("expected ID 'secret-2', got %q", result[1].ID) } }) t.Run("empty result returns empty slice", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]buildkite.ClusterSecret{}) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterSecrets.List(context.Background(), "test-org", "cluster-123", nil) if err != nil { t.Fatal(err) } if len(result) != 0 { t.Errorf("expected 0 secrets, got %d", len(result)) } }) } func TestGetSecret(t *testing.T) { t.Parallel() secret := buildkite.ClusterSecret{ ID: "secret-1", Key: "MY_SECRET", Description: "A test secret", Policy: "- pipeline_slug: my-pipeline", } s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/secrets/secret-1") { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(secret) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterSecrets.Get(context.Background(), "test-org", "cluster-123", "secret-1") if err != nil { t.Fatal(err) } if result.Key != "MY_SECRET" { t.Errorf("expected key 'MY_SECRET', got %q", result.Key) } if result.Policy != "- pipeline_slug: my-pipeline" { t.Errorf("expected policy, got %q", result.Policy) } } func TestCreateSecret(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } var input buildkite.ClusterSecretCreate if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Fatal(err) } if input.Key != "NEW_SECRET" { t.Errorf("expected key 'NEW_SECRET', got %q", input.Key) } if input.Value != "s3cr3t" { t.Errorf("expected value 's3cr3t', got %q", input.Value) } if input.Description != "A new secret" { t.Errorf("expected description 'A new secret', got %q", input.Description) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.ClusterSecret{ ID: "new-secret-id", Key: input.Key, Description: input.Description, }) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterSecrets.Create(context.Background(), "test-org", "cluster-123", buildkite.ClusterSecretCreate{ Key: "NEW_SECRET", Value: "s3cr3t", Description: "A new secret", }) if err != nil { t.Fatal(err) } if result.ID != "new-secret-id" { t.Errorf("expected ID 'new-secret-id', got %q", result.ID) } if result.Key != "NEW_SECRET" { t.Errorf("expected key 'NEW_SECRET', got %q", result.Key) } } func TestDeleteSecret(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { t.Errorf("expected DELETE, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/secrets/secret-to-delete") { t.Errorf("unexpected path: %s", r.URL.Path) } w.WriteHeader(http.StatusNoContent) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } _, err = client.ClusterSecrets.Delete(context.Background(), "test-org", "cluster-123", "secret-to-delete") if err != nil { t.Fatal(err) } } func TestUpdateSecret(t *testing.T) { t.Parallel() t.Run("updates metadata", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { t.Errorf("expected PUT, got %s", r.Method) } var input buildkite.ClusterSecretUpdate if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Fatal(err) } if input.Description != "Updated description" { t.Errorf("expected description 'Updated description', got %q", input.Description) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.ClusterSecret{ ID: "secret-1", Key: "MY_SECRET", Description: input.Description, }) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } result, _, err := client.ClusterSecrets.Update(context.Background(), "test-org", "cluster-123", "secret-1", buildkite.ClusterSecretUpdate{ Description: "Updated description", }) if err != nil { t.Fatal(err) } if result.Description != "Updated description" { t.Errorf("expected description 'Updated description', got %q", result.Description) } }) t.Run("updates value", func(t *testing.T) { t.Parallel() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { t.Errorf("expected PUT, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/secrets/secret-1/value") { t.Errorf("unexpected path: %s", r.URL.Path) } var input buildkite.ClusterSecretValueUpdate if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Fatal(err) } if input.Value != "new-value" { t.Errorf("expected value 'new-value', got %q", input.Value) } w.WriteHeader(http.StatusOK) })) defer s.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) if err != nil { t.Fatal(err) } _, err = client.ClusterSecrets.UpdateValue(context.Background(), "test-org", "cluster-123", "secret-1", buildkite.ClusterSecretValueUpdate{ Value: "new-value", }) if err != nil { t.Fatal(err) } }) } func TestUpdateCmdValidate(t *testing.T) { t.Parallel() tests := []struct { name string cmd UpdateCmd wantErr bool }{ { name: "no flags set", cmd: UpdateCmd{ClusterUUID: "c", SecretID: "s"}, wantErr: true, }, { name: "only description", cmd: UpdateCmd{ClusterUUID: "c", SecretID: "s", Description: "new desc"}, wantErr: false, }, { name: "only policy", cmd: UpdateCmd{ClusterUUID: "c", SecretID: "s", Policy: "new policy"}, wantErr: false, }, { name: "only update-value", cmd: UpdateCmd{ClusterUUID: "c", SecretID: "s", UpdateValue: true}, wantErr: false, }, { name: "description and update-value", cmd: UpdateCmd{ClusterUUID: "c", SecretID: "s", Description: "desc", UpdateValue: true}, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() err := tt.cmd.Validate() if (err != nil) != tt.wantErr { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestRenderSecretText(t *testing.T) { t.Parallel() secret := buildkite.ClusterSecret{ ID: "secret-123", Key: "MY_SECRET", Description: "Test description", Policy: "- pipeline_slug: test", CreatedBy: buildkite.SecretCreator{ ID: "user-1", Name: "Test User", }, } result := renderSecretText(secret) expectedStrings := []string{ "Viewing secret MY_SECRET", "secret-123", "Test description", "- pipeline_slug: test", "Test User", } for _, expected := range expectedStrings { if !strings.Contains(result, expected) { t.Errorf("expected output to contain %q, got:\n%s", expected, result) } } } ================================================ FILE: cmd/secret/update.go ================================================ package secret import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) type UpdateCmd struct { ClusterUUID string `help:"The UUID of the cluster" required:"" name:"cluster-uuid"` SecretID string `help:"The UUID of the secret to update" required:"" name:"secret-id"` Description string `help:"Update the description of the secret" optional:""` Policy string `help:"Update the access policy for the secret (YAML format)" optional:""` UpdateValue bool `help:"Prompt to update the secret value" optional:"" name:"update-value"` output.OutputFlags } func (c *UpdateCmd) Help() string { return ` Update a cluster secret's description, policy, or value. Use --update-value to be prompted for a new secret value (input will be masked). Examples: # Update a secret's description $ bk secret update --cluster-uuid my-cluster-uuid --secret-id my-secret-id --description "New description" # Update a secret's value $ bk secret update --cluster-uuid my-cluster-uuid --secret-id my-secret-id --update-value # Update both description and value $ bk secret update --cluster-uuid my-cluster-uuid --secret-id my-secret-id --description "New description" --update-value ` } func (c *UpdateCmd) Validate() error { if c.Description == "" && c.Policy == "" && !c.UpdateValue { return fmt.Errorf("at least one of --description, --policy, or --update-value must be provided") } return nil } func (c *UpdateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() f.NoPager = f.NoPager || globals.DisablePager() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() org := f.Config.OrganizationSlug() if c.UpdateValue { if f.NoInput { return fmt.Errorf("--update-value requires interactive input but --no-input is set") } fmt.Fprint(os.Stderr, "Enter new secret value: ") value, err := bkIO.ReadPassword() fmt.Fprintln(os.Stderr) if err != nil { return fmt.Errorf("error reading secret value: %v", err) } if value == "" { return fmt.Errorf("secret value cannot be empty") } if err = bkIO.SpinWhile(f, "Updating secret value", func() error { _, err = f.RestAPIClient.ClusterSecrets.UpdateValue(ctx, org, c.ClusterUUID, c.SecretID, buildkite.ClusterSecretValueUpdate{ Value: value, }) return err }); err != nil { return fmt.Errorf("error updating secret value: %v", err) } } var secret buildkite.ClusterSecret if c.Description != "" || c.Policy != "" { if err = bkIO.SpinWhile(f, "Updating secret", func() error { var apiErr error secret, _, apiErr = f.RestAPIClient.ClusterSecrets.Update(ctx, org, c.ClusterUUID, c.SecretID, buildkite.ClusterSecretUpdate{ Description: c.Description, Policy: c.Policy, }) return apiErr }); err != nil { return fmt.Errorf("error updating secret: %v", err) } } else { // Fetch the secret to display current state if err = bkIO.SpinWhile(f, "Loading secret", func() error { var apiErr error secret, _, apiErr = f.RestAPIClient.ClusterSecrets.Get(ctx, org, c.ClusterUUID, c.SecretID) return apiErr }); err != nil { return fmt.Errorf("error fetching secret: %v", err) } } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) secretView := output.Viewable[buildkite.ClusterSecret]{ Data: secret, Render: renderSecretText, } if format != output.FormatText { return output.Write(os.Stdout, secretView, format) } fmt.Fprintln(os.Stderr, "Secret updated successfully.") fmt.Fprintln(os.Stdout) return output.Write(os.Stdout, secretView, format) } ================================================ FILE: cmd/skill/skill.go ================================================ package skill import ( "archive/zip" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) const ( defaultRepo = "buildkite/skills" defaultBranch = "main" ) type AddCmd struct { Name string `arg:"" help:"Name of the Buildkite skill to install (for example, buildkite-api)."` Agent string `help:"Agent/harness to install for (claude or cursor). Auto-detected from .claude or .cursor by default." optional:""` Global bool `help:"Install globally in your home directory instead of the current project."` Path string `help:"Custom skills directory to install into, for agents such as Amp or Pi." type:"path" optional:"" aliases:"to,location"` Force bool `help:"Overwrite an existing installed skill."` Repo string `help:"GitHub repository to install skills from." default:"${skill_repo}" hidden:""` Branch string `help:"Git branch to install skills from." default:"${skill_branch}" hidden:""` } func (c *AddCmd) Help() string { return `Install a Buildkite skill from github.com/buildkite/skills. By default, the target is auto-detected from a project .claude or .cursor folder. Use --agent to choose a target explicitly, --global to install to all existing global agent directories (~/.claude and/or ~/.cursor), or --path for another agent's skills directory. Examples: # Install buildkite-api into the detected project agent $ bk skill add buildkite-api # Install for Claude Code in this project $ bk skill add buildkite-api --agent claude # Install globally for Cursor $ bk skill add buildkite-api --agent cursor --global # Install into a custom skills directory, such as Amp or Pi $ bk skill add buildkite-api --path ~/.amp/skills ` } func (c *AddCmd) Run() error { if err := validateSkillName(c.Name); err != nil { return err } return installSkill(c.Name, c.Agent, c.Global, c.Path, c.Force, c.Repo, c.Branch) } type UpdateCmd struct { Name string `arg:"" help:"Name of the installed Buildkite skill to update. If omitted, all installed skills are updated." optional:""` Agent string `help:"Agent/harness to update for (claude or cursor). Auto-detected from .claude or .cursor by default." optional:""` Global bool `help:"Update the globally installed skill instead of the current project."` Path string `help:"Custom skills directory to update, for agents such as Amp or Pi." type:"path" optional:""` Repo string `help:"GitHub repository to install skills from." default:"${skill_repo}" hidden:""` Branch string `help:"Git branch to install skills from." default:"${skill_branch}" hidden:""` } func (c *UpdateCmd) Help() string { return `Update installed Buildkite skills from github.com/buildkite/skills. If no skill name is provided, all currently installed skills for the target agent are updated. Examples: $ bk skill update $ bk skill update buildkite-api $ bk skill update buildkite-api --agent claude --global $ bk skill update --path ~/.amp/skills ` } func (c *UpdateCmd) Run() error { if c.Name != "" { if err := validateSkillName(c.Name); err != nil { return err } } targets, err := resolveTargets(c.Agent, c.Global, c.Path) if err != nil { return err } if c.Name != "" { var installedTargets []target for _, target := range targets { if info, err := os.Stat(filepath.Join(target.SkillsDir(), c.Name)); err == nil && info.IsDir() { installedTargets = append(installedTargets, target) } else if err != nil && !os.IsNotExist(err) { return err } } if len(installedTargets) == 0 { return fmt.Errorf("skill %q is not installed for any selected target", c.Name) } return installSkillToTargets(c.Name, installedTargets, true, c.Repo, c.Branch) } plan := map[string][]target{} for _, target := range targets { entries, err := os.ReadDir(target.SkillsDir()) if err != nil { if os.IsNotExist(err) { continue } return err } for _, entry := range entries { if entry.IsDir() { plan[entry.Name()] = append(plan[entry.Name()], target) } } } if len(plan) == 0 { return fmt.Errorf("no skills are installed for any selected target") } return installSkillsToTargets(plan, true, c.Repo, c.Branch) } type DeleteCmd struct { Name string `arg:"" help:"Name of the installed Buildkite skill to delete."` Agent string `help:"Agent/harness to delete from (claude or cursor). Auto-detected from .claude or .cursor by default." optional:""` Global bool `help:"Delete the globally installed skill instead of the current project."` Path string `help:"Custom skills directory to delete from, for agents such as Amp or Pi." type:"path" optional:""` } func (c *DeleteCmd) Help() string { return `Delete an installed Buildkite skill. Examples: $ bk skill delete buildkite-api $ bk skill delete buildkite-api --agent cursor --global $ bk skill delete buildkite-api --path ~/.amp/skills ` } func (c *DeleteCmd) Run() error { if err := validateSkillName(c.Name); err != nil { return err } targets, err := resolveTargets(c.Agent, c.Global, c.Path) if err != nil { return err } var installedTargets []target for _, target := range targets { dest := filepath.Join(target.SkillsDir(), c.Name) if info, err := os.Stat(dest); err == nil && info.IsDir() { installedTargets = append(installedTargets, target) } else if err != nil && !os.IsNotExist(err) { return err } } if len(installedTargets) == 0 { return fmt.Errorf("skill %q is not installed for any selected target", c.Name) } for _, target := range installedTargets { dest := filepath.Join(target.SkillsDir(), c.Name) if err := os.RemoveAll(dest); err != nil { return fmt.Errorf("deleting skill %q: %w", c.Name, err) } fmt.Printf("Deleted %s skill %q from %s\n", target.agent, c.Name, dest) } return nil } type target struct { agent string root string skillsDir string } func (t target) SkillsDir() string { if t.skillsDir != "" { return t.skillsDir } return filepath.Join(t.root, "skills") } func resolveTarget(agent string, global bool, customPath string) (target, error) { targets, err := resolveTargets(agent, global, customPath) if err != nil { return target{}, err } return targets[0], nil } func resolveTargets(agent string, global bool, customPath string) ([]target, error) { if global && customPath != "" { return nil, fmt.Errorf("--global and --path cannot be used together") } if customPath == "" && agent != "" && agent != "claude" && agent != "cursor" { return nil, fmt.Errorf("unsupported --agent %q (expected claude or cursor, or use --path for a custom agent)", agent) } if customPath != "" { abs, err := filepath.Abs(customPath) if err != nil { return nil, err } if agent == "" { agent = "custom" } return []target{{agent: agent, skillsDir: abs}}, nil } if global { home, err := os.UserHomeDir() if err != nil { return nil, err } if agent != "" { root := filepath.Join(home, "."+agent) if !dirExists(root) { return nil, fmt.Errorf("global %s directory does not exist at %s", agent, root) } return []target{{agent: agent, root: root}}, nil } var targets []target for _, candidate := range []string{"claude", "cursor"} { root := filepath.Join(home, "."+candidate) if dirExists(root) { targets = append(targets, target{agent: candidate, root: root}) } } if len(targets) == 0 { return nil, fmt.Errorf("no global agent directories found at ~/.claude or ~/.cursor") } return targets, nil } wd, err := os.Getwd() if err != nil { return nil, err } if agent == "" { if dirExists(filepath.Join(wd, ".claude")) { agent = "claude" } else if dirExists(filepath.Join(wd, ".cursor")) { agent = "cursor" } else { return nil, fmt.Errorf("could not detect an agent target: create .claude or .cursor, or pass --agent claude|cursor") } } return []target{{agent: agent, root: filepath.Join(wd, "."+agent)}}, nil } func dirExists(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() } func installSkill(name, agent string, global bool, customPath string, force bool, repo, branch string) error { targets, err := resolveTargets(agent, global, customPath) if err != nil { return err } return installSkillToTargets(name, targets, force, repo, branch) } func installSkillToTargets(name string, targets []target, force bool, repo, branch string) error { return installSkillsToTargets(map[string][]target{name: targets}, force, repo, branch) } func installSkillsToTargets(plan map[string][]target, force bool, repo, branch string) error { for name, targets := range plan { if err := validateSkillName(name); err != nil { return err } for _, target := range targets { dest := filepath.Join(target.SkillsDir(), name) if !force { if _, err := os.Stat(dest); err == nil { return fmt.Errorf("skill %q is already installed at %s (use --force or bk skill update)", name, dest) } else if !os.IsNotExist(err) { return err } } } } tmpDir, err := os.MkdirTemp("", "bk-skill-*") if err != nil { return err } defer os.RemoveAll(tmpDir) archive := filepath.Join(tmpDir, "skills.zip") if err := downloadRepoArchive(repo, branch, archive); err != nil { return err } counter := 0 for name, targets := range plan { for _, target := range targets { dest := filepath.Join(target.SkillsDir(), name) extracted := filepath.Join(tmpDir, fmt.Sprintf("%s-%d", name, counter)) counter++ if err := extractSkill(archive, name, extracted); err != nil { return err } if err := os.MkdirAll(target.SkillsDir(), 0o755); err != nil { return err } if err := os.RemoveAll(dest); err != nil { return err } if err := os.Rename(extracted, dest); err != nil { return err } fmt.Printf("Installed %s skill %q to %s\n", target.agent, name, dest) } } return nil } func validateSkillName(name string) error { if name == "" || name == "." || name == ".." { return fmt.Errorf("invalid skill name %q", name) } for _, r := range name { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { continue } return fmt.Errorf("invalid skill name %q: use a literal skill name, not a path, URL, or pattern", name) } return nil } func downloadRepoArchive(repo, branch, dest string) error { url := fmt.Sprintf("https://codeload.github.com/%s/zip/refs/heads/%s", repo, branch) client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Get(url) if err != nil { return fmt.Errorf("downloading %s: %w", url, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("downloading %s: unexpected HTTP status %s", url, resp.Status) } out, err := os.Create(dest) if err != nil { return err } defer out.Close() _, err = io.Copy(out, resp.Body) return err } func extractSkill(archive, skillName, dest string) error { r, err := zip.OpenReader(archive) if err != nil { return fmt.Errorf("opening downloaded skills archive: %w", err) } defer r.Close() found := false for _, f := range r.File { parts := strings.SplitN(f.Name, "/", 4) var rel string switch { case len(parts) >= 3 && parts[1] == skillName: rel = parts[2] case len(parts) >= 4 && parts[1] == "skills" && parts[2] == skillName: rel = parts[3] default: continue } if rel == "" { continue } found = true path := filepath.Join(dest, filepath.FromSlash(rel)) if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("archive contains invalid path %q", f.Name) } if f.FileInfo().IsDir() { if err := os.MkdirAll(path, 0o755); err != nil { return err } continue } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } rc, err := f.Open() if err != nil { return err } out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { rc.Close() return err } _, copyErr := io.Copy(out, rc) closeErr := out.Close() rc.Close() if copyErr != nil { return copyErr } if closeErr != nil { return closeErr } } if !found { return fmt.Errorf("skill %q not found in github.com/%s", skillName, defaultRepo) } return nil } ================================================ FILE: cmd/skill/skill_test.go ================================================ package skill import ( "archive/zip" "os" "path/filepath" "testing" ) func TestResolveTargetDetectsProjectAgent(t *testing.T) { dir := t.TempDir() oldWD, err := os.Getwd() if err != nil { t.Fatal(err) } defer os.Chdir(oldWD) if err := os.Chdir(dir); err != nil { t.Fatal(err) } wd, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Mkdir(".cursor", 0o755); err != nil { t.Fatal(err) } target, err := resolveTarget("", false, "") if err != nil { t.Fatal(err) } if target.agent != "cursor" { t.Fatalf("agent = %q, want cursor", target.agent) } if want := filepath.Join(wd, ".cursor", "skills"); target.SkillsDir() != want { t.Fatalf("skills dir = %q, want %q", target.SkillsDir(), want) } } func TestResolveTargetErrorsWithoutProjectAgent(t *testing.T) { dir := t.TempDir() oldWD, err := os.Getwd() if err != nil { t.Fatal(err) } defer os.Chdir(oldWD) if err := os.Chdir(dir); err != nil { t.Fatal(err) } if _, err := resolveTarget("", false, ""); err == nil { t.Fatal("expected error") } } func TestResolveTargetUsesCustomPath(t *testing.T) { dir := filepath.Join(t.TempDir(), "amp-skills") target, err := resolveTarget("", false, dir) if err != nil { t.Fatal(err) } if target.agent != "custom" { t.Fatalf("agent = %q, want custom", target.agent) } want, err := filepath.Abs(dir) if err != nil { t.Fatal(err) } if target.SkillsDir() != want { t.Fatalf("skills dir = %q, want %q", target.SkillsDir(), want) } } func TestResolveTargetsGlobalUsesAllExistingAgentDirs(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) if err := os.Mkdir(filepath.Join(home, ".claude"), 0o755); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(home, ".cursor"), 0o755); err != nil { t.Fatal(err) } targets, err := resolveTargets("", true, "") if err != nil { t.Fatal(err) } if len(targets) != 2 { t.Fatalf("got %d targets, want 2", len(targets)) } if targets[0].agent != "claude" || targets[1].agent != "cursor" { t.Fatalf("targets = %#v, want claude then cursor", targets) } } func TestResolveTargetsGlobalDoesNotCreateAgentDirs(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) if _, err := resolveTargets("", true, ""); err == nil { t.Fatal("expected error") } if dirExists(filepath.Join(home, ".claude")) || dirExists(filepath.Join(home, ".cursor")) { t.Fatal("global detection created agent directories") } } func TestValidateSkillNameRejectsPathsURLsAndPatterns(t *testing.T) { for _, name := range []string{"../skill", "skills/buildkite-api", "https://example.com/skill", "buildkite-*", ""} { if err := validateSkillName(name); err == nil { t.Fatalf("validateSkillName(%q) succeeded, want error", name) } } } func TestDeleteErrorsWhenSkillIsNotInstalled(t *testing.T) { dir := t.TempDir() cmd := DeleteCmd{Name: "missing", Path: dir} if err := cmd.Run(); err == nil { t.Fatal("expected error") } } func TestExtractSkill(t *testing.T) { archive := filepath.Join(t.TempDir(), "skills.zip") createZip(t, archive, map[string]string{ "skills-main/skills/buildkite-api/SKILL.md": "# Buildkite API", "skills-main/skills/buildkite-api/docs/ref.md": "reference", "skills-main/skills/other/SKILL.md": "# Other", }) dest := filepath.Join(t.TempDir(), "buildkite-api") if err := extractSkill(archive, "buildkite-api", dest); err != nil { t.Fatal(err) } got, err := os.ReadFile(filepath.Join(dest, "SKILL.md")) if err != nil { t.Fatal(err) } if string(got) != "# Buildkite API" { t.Fatalf("SKILL.md = %q", got) } if _, err := os.Stat(filepath.Join(dest, "docs", "ref.md")); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dest, "..", "other")); err == nil { t.Fatal("extracted another skill") } } func createZip(t *testing.T, path string, files map[string]string) { t.Helper() out, err := os.Create(path) if err != nil { t.Fatal(err) } defer out.Close() zw := zip.NewWriter(out) for name, content := range files { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(content)); err != nil { t.Fatal(err) } } if err := zw.Close(); err != nil { t.Fatal(err) } } ================================================ FILE: cmd/use/use.go ================================================ package use import ( "fmt" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) type UseCmd struct { OrganizationSlug string `arg:"" optional:"" help:"Organization slug to use"` } func (c *UseCmd) Help() string { return `Select a configured organization. Examples: # Use the 'my-cool-org' configuration $ bk use my-cool-org # Interactively select an organization $ bk use ` } func (c *UseCmd) Run(globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.NoInput = globals.DisableInput() var org *string if c.OrganizationSlug != "" { org = &c.OrganizationSlug } return useRun(org, f.Config, f.GitRepository != nil, f.NoInput) } func useRun(org *string, conf *config.Config, inGitRepo bool, noInput bool) error { var selected string // prompt to choose from configured orgs if one is not already selected if org == nil { var err error selected, err = io.PromptForOne("organization", conf.ConfiguredOrganizations(), noInput) if err != nil { return err } } else { selected = *org } // if already selected, do nothing if conf.OrganizationSlug() == selected { fmt.Printf("Using configuration for `%s`\n", selected) return nil } // if the selected org exists, use it if conf.HasConfiguredOrganization(selected) { fmt.Printf("Using configuration for `%s`\n", selected) return conf.SelectOrganization(selected, inGitRepo) } // If token exists in keychain or config but org marker is missing (selected_org in .bk.yaml), register it // so org switching/listing works going forward. if conf.HasStoredTokenForOrg(selected) { if err := conf.EnsureOrganization(selected); err != nil { return fmt.Errorf("failed to register configuration for `%s`: %w", selected, err) } fmt.Printf("Using configuration for `%s`\n", selected) return conf.SelectOrganization(selected, inGitRepo) } // if the selected org doesnt exist, recommend configuring it and error out return fmt.Errorf("no configuration found for `%s`. run `bk configure` to add it", selected) } ================================================ FILE: cmd/user/invite.go ================================================ package user import ( "context" "fmt" "sync" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/graphql" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" ) type InviteCmd struct { Emails []string `arg:"" required:"" help:"Email addresses to invite"` } func (c *InviteCmd) Help() string { return ` Examples: # Invite a single user to your organization $ bk user invite bob@supercoolorg.com # Invite multiple users to your organization $ bk user invite bob@supercoolorg.com bobs_mate@supercoolorg.com ` } func (c *InviteCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } f.SkipConfirm = globals.SkipConfirmation() f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() orgID, err := graphql.GetOrganizationID(ctx, f.GraphQLClient, f.Config.OrganizationSlug()) if err != nil { return err } return createInvite(ctx, f, orgID.Organization.GetId(), c.Emails...) } func createInvite(ctx context.Context, f *factory.Factory, orgID string, emails ...string) error { if len(emails) == 0 { return nil } errChan := make(chan error, len(emails)) var wg sync.WaitGroup for _, email := range emails { wg.Add(1) go func(email string) { defer wg.Done() _, err := graphql.InviteUser(ctx, f.GraphQLClient, orgID, []string{email}) if err != nil { errChan <- fmt.Errorf("error creating user invite for %s: %w", email, err) } }(email) } go func() { wg.Wait() close(errChan) }() var errs []error for err := range errChan { errs = append(errs, err) } if len(errs) > 0 { return fmt.Errorf("errors creating user invites: %v", errs) } message := "Invite sent to" if len(emails) > 1 { message = "Invites sent to" } fmt.Printf("%s: %v\n", message, emails) return nil } ================================================ FILE: cmd/version/update_check.go ================================================ package version import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" ) var releaseURL = "https://api.github.com/repos/buildkite/cli/releases/latest" type githubRelease struct { TagName string `json:"tag_name"` } // CheckForUpdate checks GitHub for the latest release and returns the latest // version string and whether it is newer than currentVersion. // Returns ("", false) silently on any error or if no update is available. func CheckForUpdate(currentVersion string) (string, bool) { current := strings.TrimPrefix(currentVersion, "v") if parseVersion(current) == nil { return "", false } client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get(releaseURL) if err != nil { return "", false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", false } var release githubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return "", false } latest := strings.TrimPrefix(release.TagName, "v") if isNewer(latest, current) { return latest, true } return "", false } // isNewer returns true if version a is strictly newer than version b. // Both versions are expected to be in "major.minor.patch" format. func isNewer(a, b string) bool { aParts := parseVersion(a) bParts := parseVersion(b) if aParts == nil || bParts == nil { return false } for i := range 3 { if aParts[i] > bParts[i] { return true } if aParts[i] < bParts[i] { return false } } return false } // parseVersion splits a "major.minor.patch" string into three integers. // Returns nil if the format is invalid. func parseVersion(v string) []int { parts := strings.SplitN(v, ".", 3) if len(parts) != 3 { return nil } nums := make([]int, 3) for i, p := range parts { n, err := strconv.Atoi(p) if err != nil { return nil } nums[i] = n } return nums } // FormatUpdateNudge returns the nudge message for display. func FormatUpdateNudge(latestVersion string) string { return fmt.Sprintf("A new version of bk is available: %s\n", latestVersion) } ================================================ FILE: cmd/version/update_check_test.go ================================================ package version import ( "fmt" "net/http" "net/http/httptest" "testing" ) func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"tag_name": "v2.0.0"}`) })) defer server.Close() releaseURL = server.URL latest, hasUpdate := CheckForUpdate("1.0.0") if !hasUpdate { t.Fatal("expected hasUpdate to be true") } if latest != "2.0.0" { t.Fatalf("expected latest to be 2.0.0, got %s", latest) } } func TestCheckForUpdate_SameVersion(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"tag_name": "v1.0.0"}`) })) defer server.Close() releaseURL = server.URL _, hasUpdate := CheckForUpdate("1.0.0") if hasUpdate { t.Fatal("expected hasUpdate to be false for same version") } } func TestCheckForUpdate_OlderVersion(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"tag_name": "v0.9.0"}`) })) defer server.Close() releaseURL = server.URL _, hasUpdate := CheckForUpdate("1.0.0") if hasUpdate { t.Fatal("expected hasUpdate to be false for older version") } } func TestCheckForUpdate_DevVersion(t *testing.T) { _, hasUpdate := CheckForUpdate("DEV") if hasUpdate { t.Fatal("expected hasUpdate to be false for DEV version") } } func TestCheckForUpdate_NonReleaseVersionSkipsLookup(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ fmt.Fprint(w, `{"tag_name": "v9.9.9"}`) })) defer server.Close() releaseURL = server.URL _, hasUpdate := CheckForUpdate("v3.1.0-12-gabc1234") if hasUpdate { t.Fatal("expected hasUpdate to be false for non-release version") } if requestCount != 0 { t.Fatalf("expected no release lookup for non-release version, got %d requests", requestCount) } } func TestCheckForUpdate_ServerError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() releaseURL = server.URL _, hasUpdate := CheckForUpdate("1.0.0") if hasUpdate { t.Fatal("expected hasUpdate to be false on server error") } } func TestCheckForUpdate_InvalidJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `not json`) })) defer server.Close() releaseURL = server.URL _, hasUpdate := CheckForUpdate("1.0.0") if hasUpdate { t.Fatal("expected hasUpdate to be false on invalid JSON") } } func TestCheckForUpdate_ServerDown(t *testing.T) { releaseURL = "http://127.0.0.1:1" // nothing listening _, hasUpdate := CheckForUpdate("1.0.0") if hasUpdate { t.Fatal("expected hasUpdate to be false when server is unreachable") } } func TestIsNewer(t *testing.T) { tests := []struct { a, b string want bool }{ {"2.0.0", "1.0.0", true}, {"1.1.0", "1.0.0", true}, {"1.0.1", "1.0.0", true}, {"1.0.0", "1.0.0", false}, {"0.9.0", "1.0.0", false}, {"1.0.0", "1.0.1", false}, {"10.0.0", "9.0.0", true}, {"1.10.0", "1.9.0", true}, } for _, tt := range tests { t.Run(fmt.Sprintf("%s_vs_%s", tt.a, tt.b), func(t *testing.T) { got := isNewer(tt.a, tt.b) if got != tt.want { t.Errorf("isNewer(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) } }) } } func TestParseVersion(t *testing.T) { tests := []struct { input string valid bool }{ {"1.2.3", true}, {"0.0.0", true}, {"10.20.30", true}, {"1.2", false}, {"1.2.3.4", false}, {"a.b.c", false}, {"", false}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := parseVersion(tt.input) if tt.valid && result == nil { t.Errorf("parseVersion(%q) returned nil, expected valid", tt.input) } if !tt.valid && result != nil { t.Errorf("parseVersion(%q) returned %v, expected nil", tt.input, result) } }) } } ================================================ FILE: cmd/version/version.go ================================================ package version import ( "fmt" "os" "strings" ) var Version = "DEV" type VersionCmd struct{} func (c *VersionCmd) Run() error { fmt.Fprintf(os.Stdout, "%s\n", Format(Version)) if latest, ok := CheckForUpdate(Version); ok { fmt.Fprint(os.Stderr, FormatUpdateNudge(latest)) } return nil } func Format(ver string) string { ver = strings.TrimPrefix(ver, "v") return fmt.Sprintf("bk version %s\n", ver) } ================================================ FILE: cmd/whoami/whoami.go ================================================ package whoami import ( "context" "fmt" "os" "os/signal" "strings" "syscall" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/buildkite/cli/v3/pkg/output" "github.com/buildkite/go-buildkite/v4" ) type WhoAmIOutput struct { OrganizationSlug string `json:"organization_slug"` Token buildkite.AccessToken `json:"token"` } func (w WhoAmIOutput) TextOutput() string { b := strings.Builder{} fmt.Fprintf(&b, "Current organization: %s\n", w.OrganizationSlug) b.WriteRune('\n') fmt.Fprintf(&b, "API Token UUID: %s\n", w.Token.UUID) fmt.Fprintf(&b, "API Token Description: %s\n", w.Token.Description) fmt.Fprintf(&b, "API Token Scopes: %v\n", w.Token.Scopes) b.WriteRune('\n') fmt.Fprintf(&b, "API Token user name: %s\n", w.Token.User.Name) fmt.Fprintf(&b, "API Token user email: %s\n", w.Token.User.Email) return b.String() } type WhoAmICmd struct { output.OutputFlags } func (c *WhoAmICmd) Help() string { return ` It returns information on the current session. Examples: # List the current token session $ bk whoami # List the current token session in JSON format $ bk whoami -o json ` } func (c *WhoAmICmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err } if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } format := output.ResolveFormat(c.Output, f.Config.OutputFormat()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() orgSlug := f.Config.OrganizationSlug() if orgSlug == "" { orgSlug = "<None>" } token, _, err := f.RestAPIClient.AccessTokens.Get(ctx) if err != nil { return fmt.Errorf("failed to get access token: %w", err) } w := WhoAmIOutput{ OrganizationSlug: orgSlug, Token: token, } err = output.Write(os.Stdout, w, format) if err != nil { return fmt.Errorf("failed to write output: %w", err) } return nil } ================================================ FILE: docs/shell-prompt-integration.md ================================================ ### Shell Prompt Integration The Buildkite CLI offers a shell prompt integration that displays your current Buildkite organization directly in your prompt. ![bk cli prompt](./images/prompt.png) #### Zsh (Vanilla) 1. Create a prompt function in `~/.buildkite/zsh_prompt.zsh`: ```zsh _buildkite_ps1() { local org=$(bk use 2>&1 | grep "Using configuration for" | sed -E "s/Using configuration for \`(.*)\`/\1/") if [[ -n "$org" ]]; then echo -n " (bk:$org)" fi } # Modify your existing prompt to include the Buildkite organization PROMPT='%n@%m %1~$(_buildkite_ps1)%# ' ``` 2. Source the script in your `.zshrc`: ```zsh source $HOME/.buildkite/zsh_prompt.zsh ``` #### Zsh (Powerlevel10k) 1. Add the following function to `~/.buildkite/zsh_prompt.zsh`: ```zsh _buildkite_ps1() { # Cache the prompt output for 5 seconds to avoid running bk too frequently if [[ -z "$BK_PROMPT_CACHE" ]] || [[ $(($EPOCHSECONDS % 5)) -eq 0 ]]; then local org=$(bk use 2>&1 | grep "Using configuration for" | sed -E "s/Using configuration for \`(.*)\`/\1/") if [[ -n "$org" ]]; then BK_PROMPT_CACHE="%F{magenta}(bk:$org)%f" else BK_PROMPT_CACHE="%F{yellow}(bk:not configured)%f" fi fi echo -n "$BK_PROMPT_CACHE" } # Wrap the bk command to clear prompt cache when switching orgs bk() { command bk "$@" if [[ "$1" == "use" ]]; then unset BK_PROMPT_CACHE fi } ``` 2. Source this script in your `.zshrc`: ```zsh source $HOME/.buildkite/zsh_prompt.zsh ``` 3. Add the Buildkite organization to your prompt elements in `~/.p10k.zsh`: ```zsh typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( # ... other existing elements buildkite_org ) ``` #### Bash 1. Create a prompt function in `~/.buildkite/bash_prompt.sh`: ```bash _buildkite_ps1() { local org=$(bk use 2>&1 | grep "Using configuration for" | sed -E "s/Using configuration for \`(.*)\`/\1/") if [[ -n "$org" ]]; then echo -n " (bk:$org)" fi } # Modify your PS1 to include the Buildkite organization export PS1='\u@\h \w$(_buildkite_ps1)\$ ' ``` 2. Source the script in your `.bashrc`: ```bash source $HOME/.buildkite/bash_prompt.sh ``` #### Features - Displays current Buildkite organization in your shell prompt - Caches organization info to minimize performance impact - Works across different projects and directories - Supports quick organization switching with `bk use` #### Troubleshooting - Ensure you've run `bk configure` to set up your organization - Verify the `.bk.yaml` in your project's root directory - Check that you're using a locally built `bk` binary in development projects #### Performance Considerations The prompt integration uses a lightweight method to retrieve the current organization. However, to minimize any potential performance impact: - The script caches the organization name - The command is only run periodically or when switching organizations - You can customize the caching mechanism if needed ================================================ FILE: fixtures/build.json ================================================ [ { "id": "018f7ee5-xxxx-429f-83ad-89e3b6c6c3ef", "graphql_id": "QnVpbGQtLS0wxxxxxxxxxxxxNTkyLTQyOWYtODNhZC04OWUzYjZjNmMzZWY=", "url": "https://api.buildkite.com/v2/organizations/buildkite/pipelines/buildkite-cli/builds/xx", "web_url": "https://buildkite.com/buildkite/buildkite-cli/builds/xx", "number": 584, "state": "passed", "cancel_reason": null, "blocked": false, "blocked_state": "", "message": "", "commit": "", "branch": "", "tag": null, "env": {}, "source": "webhook", "author": {}, "creator": { "id": "0183c4e6-c88c-xxxx-b15e-7801077a9181", "graphql_id": "VXNlci0tLTAxODNjNGU2LWM4OGxxxxxxxxxiMTVlLTc4MDEwNzdhOTE4MQ==" }, "created_at": "2024-05-16T00:55:26.401Z", "scheduled_at": "2024-05-16T00:55:26.256Z", "started_at": "2024-05-16T00:55:30.670Z", "finished_at": "2024-05-16T00:58:58.887Z", "meta_data": {}, "pull_request": {}, "rebuilt_from": null, "pipeline": { "id": "26e6a5b3-xxxx-4be3-8f2a-db21faf06597", "graphql_id": "UGlwZWxpxxxxxxxxxxU2YTViMy1hZDExLTRiZTMtOGYyYS1kYjIxZmFmMDY1OTc=", "url": "https://api.buildkite.com/v2/organizations/buildkite/pipelines/buildkite-cli", "web_url": "https://buildkite.com/buildkite/buildkite-cli", "name": "Buildkite CLI", "description": "A command line interface for Buildkite.", "slug": "buildkite-cli", "repository": "https://github.com/buildkite/cli.git", "cluster_id": "5f0748b7", "pipeline_template_uuid": null, "branch_configuration": null, "default_branch": "3.x", "skip_queued_branch_builds": true, "skip_queued_branch_builds_filter": "", "cancel_running_branch_builds": true, "cancel_running_branch_builds_filter": "", "allow_rebuilds": true, "provider": {}, "builds_url": "https://api.buildkite.com/v2/organizations/buildkite/pipelines/buildkite-cli/builds", "badge_url": "https://badge.buildkite.com/01e74a00a1f107ee6ee736731541ebb4982b923ff872b46f41.svg", "created_by": {}, "created_at": "2020-07-06T05:40:03.165Z", "archived_at": null, "env": null, "scheduled_builds_count": 0, "running_builds_count": 0, "scheduled_jobs_count": 0, "running_jobs_count": 1, "waiting_jobs_count": 0, "visibility": "public", "tags": [ ":console:", ":golang:" ], "emoji": ":console:", "color": "#CAD3F5", "configuration": "", "steps": [], "cluster_url": "" }, "jobs": [], "cluster_id": "", "cluster_url": "" } ] ================================================ FILE: fixtures/config/local.basic.yaml ================================================ selected_org: buildkite-test organizations: buildkite-test: api_token: test-token-1234 pipelines: - first-pipeline - second-pipeline ================================================ FILE: fixtures/config/user.basic.yaml ================================================ selected_org: buildkite-org organizations: buildkite-test: api_token: test-token-abcd ================================================ FILE: genqlient.yaml ================================================ schema: schema.graphql operations: - internal/**/*.graphql - cmd/**/*.graphql optional: pointer generated: internal/graphql/generated.go bindings: JSON: type: string YAML: type: string DateTime: type: time.Time ================================================ FILE: go.mod ================================================ module github.com/buildkite/cli/v3 go 1.25.0 require ( github.com/alecthomas/kong v1.15.0 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/buildkite/go-buildkite/v4 v4.22.0 github.com/buildkite/termoji v0.0.0-20260330080310-c0aa4ebee0d1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/go-git/go-git/v5 v5.19.0 github.com/goccy/go-yaml v1.19.2 github.com/google/uuid v1.6.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/posthog/posthog-go v1.12.5 github.com/vektah/gqlparser/v2 v2.5.33 github.com/xeipuuv/gojsonschema v1.2.0 github.com/zalando/go-keyring v0.2.8 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/alexflint/go-arg v1.5.1 // indirect github.com/alexflint/go-scalar v1.2.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kyokomi/emoji/v2 v2.2.13 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.20.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Khan/genqlient v0.8.1 github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-runewidth v0.0.23 github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/afero v1.15.0 github.com/suessflorian/gqlfetch v0.7.0 github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/term v0.43.0 golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y= github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs= github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buildkite/go-buildkite/v4 v4.22.0 h1:S0jDYBh4iUAx0J5VYrJe+VdVTgvZVCrD9rqOs2l0xCk= github.com/buildkite/go-buildkite/v4 v4.22.0/go.mod h1:t/M4DUcs7qyebtzm3nkyZ1zUB/svWnKtR+uRU2Ca8tQ= github.com/buildkite/termoji v0.0.0-20260330080310-c0aa4ebee0d1 h1:aaEl0QZURcwC+KOfFTzSp66xknw5eTmFZ1NgB87s2xk= github.com/buildkite/termoji v0.0.0-20260330080310-c0aa4ebee0d1/go.mod h1:ZTEvQlMN3+qzjROvjRb1p0X+xDQxxKpkMFhMSnaTrpw= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc= github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQraJ8o= github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/suessflorian/gqlfetch v0.7.0 h1:lh33oml4koA2xzIqeW8hxBCPCHC5c25K1VEP4LD5gGg= github.com/suessflorian/gqlfetch v0.7.0/go.mod h1:Q6tGWULnU3Lj5yBWVZSoabHAmIftaGrw1BuboWqrnf8= github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E= github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/agent/download.go ================================================ package agent import ( "archive/tar" "archive/zip" "compress/gzip" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" ) // ExistingInstall describes a buildkite-agent binary already present on the system. type ExistingInstall struct { Path string Version string } // FindExisting looks for buildkite-agent in PATH and returns info about it. // Returns nil if no existing installation is found. func FindExisting(targetOS string) *ExistingInstall { name := BinaryName(targetOS) path, err := exec.LookPath(name) if err != nil { return nil } install := &ExistingInstall{Path: path} out, err := exec.Command(path, "--version").Output() if err == nil { version := strings.TrimSpace(string(out)) // Output is like "buildkite-agent version 3.119.2+11755.abc123..." // Extract just the semver portion. version = strings.TrimPrefix(version, "buildkite-agent version ") if plusIdx := strings.Index(version, "+"); plusIdx != -1 { version = version[:plusIdx] } install.Version = version } return install } // ResolveLatestVersion queries the GitHub API for the latest buildkite-agent release tag. func ResolveLatestVersion() (string, error) { req, err := http.NewRequest("GET", "https://api.github.com/repos/buildkite/agent/releases/latest", nil) if err != nil { return "", err } req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } var release struct { TagName string `json:"tag_name"` } if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return "", err } return strings.TrimPrefix(release.TagName, "v"), nil } // BuildDownloadURL returns the GitHub releases URL for the given agent version, OS, and arch. func BuildDownloadURL(version, os, arch string) string { var extension string switch os { case "windows": extension = "zip" default: extension = "tar.gz" } return fmt.Sprintf( "https://github.com/buildkite/agent/releases/download/v%s/buildkite-agent-%s-%s-%s.%s", version, os, arch, version, extension, ) } // BuildSHA256SumsURL returns the URL for the SHA256SUMS file for a given agent version. func BuildSHA256SumsURL(version string) string { return fmt.Sprintf( "https://github.com/buildkite/agent/releases/download/v%s/buildkite-agent-%s.SHA256SUMS", version, version, ) } // FetchExpectedSHA256 downloads the SHA256SUMS file and returns the expected hash // for the given archive filename. func FetchExpectedSHA256(sumsURL, archiveFilename string) (string, error) { resp, err := http.Get(sumsURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("fetching SHA256SUMS failed with status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") { // Format: "<hash> <filename>" parts := strings.SplitN(line, " ", 2) if len(parts) == 2 && parts[1] == archiveFilename { return parts[0], nil } } return "", fmt.Errorf("no SHA256 checksum found for %s", archiveFilename) } // VerifySHA256 computes the SHA256 hash of the file at path and compares it // to the expected hex-encoded hash. Returns an error if they don't match. func VerifySHA256(path, expected string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return err } actual := hex.EncodeToString(h.Sum(nil)) if actual != expected { return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expected, actual) } return nil } // DownloadToTemp downloads the given URL to a temporary file and returns its path. // The caller is responsible for removing the file when done. func DownloadToTemp(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download failed with status %d", resp.StatusCode) } tmpFile, err := os.CreateTemp("", "buildkite-agent-*") if err != nil { return "", err } defer tmpFile.Close() if _, err := io.Copy(tmpFile, resp.Body); err != nil { os.Remove(tmpFile.Name()) return "", err } return tmpFile.Name(), nil } // ExtractBinary extracts the buildkite-agent binary from the given archive to dest. func ExtractBinary(archive, dest, targetOS string) error { if targetOS == "windows" { return extractZip(archive, dest) } return extractTarGz(archive, dest) } // BinaryName returns the platform-appropriate binary name. func BinaryName(targetOS string) string { if targetOS == "windows" { return "buildkite-agent.exe" } return "buildkite-agent" } func extractTarGz(archive, dest string) error { f, err := os.Open(archive) if err != nil { return err } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { return err } defer gz.Close() tr := tar.NewReader(gz) for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return err } if filepath.Base(header.Name) != "buildkite-agent" { continue } outPath := filepath.Join(dest, "buildkite-agent") out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, tr); err != nil { return err } return nil } return fmt.Errorf("buildkite-agent binary not found in archive") } func extractZip(archive, dest string) error { r, err := zip.OpenReader(archive) if err != nil { return err } defer r.Close() for _, f := range r.File { if filepath.Base(f.Name) != "buildkite-agent.exe" { continue } rc, err := f.Open() if err != nil { return err } defer rc.Close() outPath := filepath.Join(dest, "buildkite-agent.exe") out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, rc); err != nil { return err } return nil } return fmt.Errorf("buildkite-agent.exe not found in archive") } ================================================ FILE: internal/agent/download_test.go ================================================ package agent import ( "archive/tar" "archive/zip" "compress/gzip" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func TestBuildDownloadURL(t *testing.T) { t.Parallel() tests := []struct { name string version string os string arch string want string }{ { name: "linux amd64", version: "3.120.0", os: "linux", arch: "amd64", want: "https://github.com/buildkite/agent/releases/download/v3.120.0/buildkite-agent-linux-amd64-3.120.0.tar.gz", }, { name: "darwin arm64", version: "3.120.0", os: "darwin", arch: "arm64", want: "https://github.com/buildkite/agent/releases/download/v3.120.0/buildkite-agent-darwin-arm64-3.120.0.tar.gz", }, { name: "windows amd64", version: "3.120.0", os: "windows", arch: "amd64", want: "https://github.com/buildkite/agent/releases/download/v3.120.0/buildkite-agent-windows-amd64-3.120.0.zip", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := BuildDownloadURL(tt.version, tt.os, tt.arch) if got != tt.want { t.Errorf("BuildDownloadURL() = %q, want %q", got, tt.want) } }) } } func TestBuildSHA256SumsURL(t *testing.T) { t.Parallel() got := BuildSHA256SumsURL("3.120.0") want := "https://github.com/buildkite/agent/releases/download/v3.120.0/buildkite-agent-3.120.0.SHA256SUMS" if got != want { t.Errorf("BuildSHA256SumsURL() = %q, want %q", got, want) } } func TestFetchExpectedSHA256(t *testing.T) { t.Parallel() sumsBody := "abc123 buildkite-agent-linux-amd64-3.120.0.tar.gz\ndef456 buildkite-agent-darwin-arm64-3.120.0.tar.gz\n" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sumsBody) })) t.Cleanup(server.Close) tests := []struct { name string filename string want string wantErr bool }{ {"found linux", "buildkite-agent-linux-amd64-3.120.0.tar.gz", "abc123", false}, {"found darwin", "buildkite-agent-darwin-arm64-3.120.0.tar.gz", "def456", false}, {"not found", "buildkite-agent-windows-amd64-3.120.0.zip", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := FetchExpectedSHA256(server.URL, tt.filename) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tt.want { t.Errorf("FetchExpectedSHA256() = %q, want %q", got, tt.want) } }) } } func TestVerifySHA256(t *testing.T) { t.Parallel() content := []byte("hello buildkite agent") hash := sha256.Sum256(content) expectedHex := hex.EncodeToString(hash[:]) tmpFile := filepath.Join(t.TempDir(), "testfile") if err := os.WriteFile(tmpFile, content, 0o644); err != nil { t.Fatal(err) } // Should pass with correct hash if err := VerifySHA256(tmpFile, expectedHex); err != nil { t.Errorf("VerifySHA256() with correct hash: unexpected error: %v", err) } // Should fail with wrong hash err := VerifySHA256(tmpFile, "0000000000000000000000000000000000000000000000000000000000000000") if err == nil { t.Fatal("VerifySHA256() with wrong hash: expected error, got nil") } if got := err.Error(); !strings.Contains(got, "SHA256 mismatch") { t.Errorf("unexpected error message: %s", got) } } func TestBinaryName(t *testing.T) { t.Parallel() tests := []struct { os string want string }{ {"linux", "buildkite-agent"}, {"darwin", "buildkite-agent"}, {"windows", "buildkite-agent.exe"}, } for _, tt := range tests { t.Run(tt.os, func(t *testing.T) { t.Parallel() got := BinaryName(tt.os) if got != tt.want { t.Errorf("BinaryName(%q) = %q, want %q", tt.os, got, tt.want) } }) } } func TestExtractTarGz(t *testing.T) { t.Parallel() // Create a tar.gz archive containing a fake buildkite-agent binary archivePath := filepath.Join(t.TempDir(), "agent.tar.gz") binaryContent := []byte("#!/bin/sh\necho hello\n") f, err := os.Create(archivePath) if err != nil { t.Fatal(err) } gw := gzip.NewWriter(f) tw := tar.NewWriter(gw) // Add a non-matching file first if err := tw.WriteHeader(&tar.Header{Name: "README.md", Size: 5, Mode: 0o644}); err != nil { t.Fatal(err) } if _, err := tw.Write([]byte("hello")); err != nil { t.Fatal(err) } // Add the buildkite-agent binary if err := tw.WriteHeader(&tar.Header{Name: "buildkite-agent", Size: int64(len(binaryContent)), Mode: 0o755}); err != nil { t.Fatal(err) } if _, err := tw.Write(binaryContent); err != nil { t.Fatal(err) } tw.Close() gw.Close() f.Close() // Extract to a temp dir dest := t.TempDir() if err := extractTarGz(archivePath, dest); err != nil { t.Fatalf("extractTarGz() error: %v", err) } // Verify the binary was extracted extracted, err := os.ReadFile(filepath.Join(dest, "buildkite-agent")) if err != nil { t.Fatalf("reading extracted binary: %v", err) } if string(extracted) != string(binaryContent) { t.Errorf("extracted content = %q, want %q", extracted, binaryContent) } // Verify README.md was NOT extracted if _, err := os.Stat(filepath.Join(dest, "README.md")); !os.IsNotExist(err) { t.Error("README.md should not have been extracted") } } func TestExtractTarGz_MissingBinary(t *testing.T) { t.Parallel() // Create a tar.gz with no buildkite-agent file archivePath := filepath.Join(t.TempDir(), "agent.tar.gz") f, err := os.Create(archivePath) if err != nil { t.Fatal(err) } gw := gzip.NewWriter(f) tw := tar.NewWriter(gw) if err := tw.WriteHeader(&tar.Header{Name: "other-file", Size: 5, Mode: 0o644}); err != nil { t.Fatal(err) } if _, err := tw.Write([]byte("hello")); err != nil { t.Fatal(err) } tw.Close() gw.Close() f.Close() dest := t.TempDir() err = extractTarGz(archivePath, dest) if err == nil { t.Fatal("expected error for missing binary, got nil") } if err.Error() != "buildkite-agent binary not found in archive" { t.Errorf("unexpected error: %v", err) } } func TestExtractZip(t *testing.T) { t.Parallel() archivePath := filepath.Join(t.TempDir(), "agent.zip") binaryContent := []byte("fake-exe-content") f, err := os.Create(archivePath) if err != nil { t.Fatal(err) } zw := zip.NewWriter(f) // Add a non-matching file w, err := zw.Create("README.md") if err != nil { t.Fatal(err) } if _, err := w.Write([]byte("hello")); err != nil { t.Fatal(err) } // Add the binary w, err = zw.Create("buildkite-agent.exe") if err != nil { t.Fatal(err) } if _, err := w.Write(binaryContent); err != nil { t.Fatal(err) } zw.Close() f.Close() dest := t.TempDir() if err := extractZip(archivePath, dest); err != nil { t.Fatalf("extractZip() error: %v", err) } extracted, err := os.ReadFile(filepath.Join(dest, "buildkite-agent.exe")) if err != nil { t.Fatalf("reading extracted binary: %v", err) } if string(extracted) != string(binaryContent) { t.Errorf("extracted content = %q, want %q", extracted, binaryContent) } if _, err := os.Stat(filepath.Join(dest, "README.md")); !os.IsNotExist(err) { t.Error("README.md should not have been extracted") } } func TestExtractZip_MissingBinary(t *testing.T) { t.Parallel() archivePath := filepath.Join(t.TempDir(), "agent.zip") f, err := os.Create(archivePath) if err != nil { t.Fatal(err) } zw := zip.NewWriter(f) w, err := zw.Create("other-file") if err != nil { t.Fatal(err) } if _, err := w.Write([]byte("hello")); err != nil { t.Fatal(err) } zw.Close() f.Close() dest := t.TempDir() err = extractZip(archivePath, dest) if err == nil { t.Fatal("expected error for missing binary, got nil") } if err.Error() != "buildkite-agent.exe not found in archive" { t.Errorf("unexpected error: %v", err) } } ================================================ FILE: internal/agent/platform.go ================================================ package agent import ( "os" "path/filepath" ) // DefaultBinDir returns the platform-appropriate default directory for the agent binary. func DefaultBinDir(targetOS string) string { switch targetOS { case "windows": if appData := os.Getenv("LOCALAPPDATA"); appData != "" { return filepath.Join(appData, "Buildkite", "bin") } return filepath.Join("C:\\", "Program Files", "buildkite", "bin") default: home, err := os.UserHomeDir() if err != nil { return "/usr/local/bin" } return filepath.Join(home, ".buildkite-agent", "bin") } } // DefaultBuildPath returns the platform-appropriate default directory for agent builds. func DefaultBuildPath(targetOS string) string { switch targetOS { case "windows": if appData := os.Getenv("LOCALAPPDATA"); appData != "" { return filepath.Join(appData, "Buildkite", "builds") } return filepath.Join("C:\\", "Program Files", "buildkite", "builds") default: home, err := os.UserHomeDir() if err != nil { return "/var/lib/buildkite-agent/builds" } return filepath.Join(home, ".buildkite-agent", "builds") } } // DefaultConfigPath returns the platform-appropriate default path for the agent config file. func DefaultConfigPath(targetOS string) string { switch targetOS { case "windows": if appData := os.Getenv("LOCALAPPDATA"); appData != "" { return filepath.Join(appData, "Buildkite", "buildkite-agent.cfg") } return filepath.Join("C:\\", "Program Files", "buildkite", "buildkite-agent.cfg") default: home, err := os.UserHomeDir() if err != nil { return "/etc/buildkite-agent/buildkite-agent.cfg" } return filepath.Join(home, ".buildkite-agent", "buildkite-agent.cfg") } } ================================================ FILE: internal/agent/platform_test.go ================================================ package agent import ( "strings" "testing" ) func TestDefaultBinDir(t *testing.T) { t.Parallel() tests := []struct { name string os string contains string }{ {"linux uses .buildkite-agent", "linux", ".buildkite-agent/bin"}, {"darwin uses .buildkite-agent", "darwin", ".buildkite-agent/bin"}, {"windows uses buildkite", "windows", "buildkite"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := DefaultBinDir(tt.os) if !strings.Contains(got, tt.contains) { t.Errorf("DefaultBinDir(%q) = %q, expected to contain %q", tt.os, got, tt.contains) } }) } } func TestDefaultBuildPath(t *testing.T) { t.Parallel() tests := []struct { name string os string contains string }{ {"linux uses .buildkite-agent", "linux", ".buildkite-agent/builds"}, {"darwin uses .buildkite-agent", "darwin", ".buildkite-agent/builds"}, {"windows uses buildkite", "windows", "buildkite"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := DefaultBuildPath(tt.os) if !strings.Contains(got, tt.contains) { t.Errorf("DefaultBuildPath(%q) = %q, expected to contain %q", tt.os, got, tt.contains) } }) } } func TestDefaultConfigPath(t *testing.T) { t.Parallel() tests := []struct { name string os string contains string }{ {"linux uses .buildkite-agent", "linux", ".buildkite-agent/buildkite-agent.cfg"}, {"darwin uses .buildkite-agent", "darwin", ".buildkite-agent/buildkite-agent.cfg"}, {"windows uses buildkite", "windows", "buildkite"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := DefaultConfigPath(tt.os) if !strings.Contains(got, tt.contains) { t.Errorf("DefaultConfigPath(%q) = %q, expected to contain %q", tt.os, got, tt.contains) } }) } } ================================================ FILE: internal/agent/token.go ================================================ package agent import ( "context" "fmt" "os" "path/filepath" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" ) // FindCluster resolves a cluster for the given org. If clusterID is provided, // it is returned directly. Otherwise it looks up the "Default" cluster. func FindCluster(ctx context.Context, f *factory.Factory, org, clusterID string) (string, error) { if clusterID != "" { return clusterID, nil } clusters, _, err := f.RestAPIClient.Clusters.List(ctx, org, nil) if err != nil { return "", err } for _, c := range clusters { if c.Name == "Default" { return c.ID, nil } } return "", fmt.Errorf("no cluster named \"Default\" found in organization %q", org) } // CreateAgentToken creates an agent token on the given cluster and returns the token string. func CreateAgentToken(ctx context.Context, f *factory.Factory, org, clusterID, description string) (string, error) { token, _, err := f.RestAPIClient.ClusterTokens.Create(ctx, org, clusterID, buildkite.ClusterTokenCreateUpdate{ Description: description, }) if err != nil { return "", err } return token.Token, nil } // WriteAgentConfig writes a minimal agent config file with the given token, build path, // and optional tags. The file is created with 0600 permissions. func WriteAgentConfig(path, token, buildPath string, tags []string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } content := fmt.Sprintf("token=%q\nbuild-path=%q\n", token, buildPath) for _, tag := range tags { content += fmt.Sprintf("tags=%q\n", tag) } return os.WriteFile(path, []byte(content), 0o600) } ================================================ FILE: internal/agent/token_test.go ================================================ package agent import ( "os" "path/filepath" "testing" ) func TestWriteAgentConfig(t *testing.T) { t.Parallel() dir := t.TempDir() configPath := filepath.Join(dir, "subdir", "buildkite-agent.cfg") err := WriteAgentConfig(configPath, "test-token-123", "/tmp/builds", []string{"queue=default"}) if err != nil { t.Fatalf("WriteAgentConfig() error: %v", err) } content, err := os.ReadFile(configPath) if err != nil { t.Fatalf("reading config: %v", err) } expected := "token=\"test-token-123\"\nbuild-path=\"/tmp/builds\"\ntags=\"queue=default\"\n" if string(content) != expected { t.Errorf("config content = %q, want %q", content, expected) } // Verify file permissions are restrictive info, err := os.Stat(configPath) if err != nil { t.Fatalf("stat config: %v", err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("config permissions = %o, want 600", perm) } } func TestWriteAgentConfig_CreatesParentDirs(t *testing.T) { t.Parallel() dir := t.TempDir() configPath := filepath.Join(dir, "a", "b", "c", "buildkite-agent.cfg") err := WriteAgentConfig(configPath, "token", "/builds", nil) if err != nil { t.Fatalf("WriteAgentConfig() error: %v", err) } if _, err := os.Stat(configPath); os.IsNotExist(err) { t.Error("config file was not created") } } ================================================ FILE: internal/annotation/annotation.go ================================================ package annotation import "regexp" // StripTags removes HTML tags from a string func StripTags(html string) string { re := regexp.MustCompile(`</[^>]+>`) html = re.ReplaceAllString(html, "") re = regexp.MustCompile(`<[^>]*>`) return re.ReplaceAllString(html, "") } ================================================ FILE: internal/annotation/list.go ================================================ package annotation import ( "strings" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) // AnnotationSummary renders a summary of a build annotation func AnnotationSummary(annotation *buildkite.Annotation) string { if annotation == nil { return "" } body := StripTags(annotation.BodyHTML) const maxBody = 160 bodyRunes := []rune(body) if len(bodyRunes) > maxBody { body = string(bodyRunes[:maxBody]) + "..." } rows := [][]string{ {"Style", output.ValueOrDash(annotation.Style)}, {"Context", output.ValueOrDash(annotation.Context)}, {"Body", output.ValueOrDash(strings.TrimSpace(body))}, } return output.Table( []string{"Field", "Value"}, rows, map[string]string{"field": "dim", "value": "italic"}, ) } ================================================ FILE: internal/artifact/artifact.go ================================================ package artifact import "fmt" // FormatBytes formats bytes into human-readable format (KB, MB, GB, etc.) func FormatBytes(bytes int64) string { const ( KB = 1024 MB = 1024 * KB GB = 1024 * MB TB = 1024 * GB ) switch { case bytes >= TB: return fmt.Sprintf("%.1fTB", float64(bytes)/TB) case bytes >= GB: return fmt.Sprintf("%.1fGB", float64(bytes)/GB) case bytes >= MB: return fmt.Sprintf("%.1fMB", float64(bytes)/MB) case bytes >= KB: return fmt.Sprintf("%.1fKB", float64(bytes)/KB) default: return fmt.Sprintf("%dB", bytes) } } ================================================ FILE: internal/artifact/view.go ================================================ package artifact import ( "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) // ArtifactSummary renders a summary of a build artifact func ArtifactSummary(artifact *buildkite.Artifact) string { if artifact == nil { return "" } rows := [][]string{{artifact.ID, artifact.Path, FormatBytes(artifact.FileSize)}} return output.Table( []string{"ID", "Path", "Size"}, rows, map[string]string{"id": "dim", "path": "bold", "size": "dim"}, ) } ================================================ FILE: internal/build/build.go ================================================ package build type Build struct { Organization string Pipeline string BuildNumber int } ================================================ FILE: internal/build/resolver/cli.go ================================================ package resolver import ( "context" "fmt" "strconv" "strings" "github.com/buildkite/cli/v3/internal/build" "github.com/buildkite/cli/v3/internal/config" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" ) func ResolveFromPositionalArgument(args []string, index int, pipeline pipelineResolver.PipelineResolverFn, conf *config.Config) BuildResolverFn { return func(ctx context.Context) (*build.Build, error) { // if args does not have values, skip this resolver if len(args) < 1 { return nil, nil } // if the index is out of bounds if (len(args) - 1) < index { return nil, nil } build := parseBuildArg(ctx, args[index], pipeline) // if we get here, we should be able to parse the value and return an error if not // this is because a user has explicitly given an input value for us to use - we shouldnt ignore it on error if build == nil { return nil, fmt.Errorf("unable to parse the input build argument: \"%s\"", args[index]) } return build, nil } } func parseBuildArg(ctx context.Context, arg string, pipeline pipelineResolver.PipelineResolverFn) *build.Build { buildIsURL := strings.Contains(arg, ":") buildIsSlug := !buildIsURL && strings.Contains(arg, "/") if buildIsURL { return splitBuildURL(arg) } else if buildIsSlug { part := strings.Split(arg, "/") if len(part) < 3 { return nil } num, err := strconv.Atoi(part[2]) if err != nil { return nil } return &build.Build{ Organization: part[0], Pipeline: part[1], BuildNumber: num, } } num, err := strconv.Atoi(arg) if err != nil { return nil } p, err := pipeline(ctx) if err != nil || p == nil { return nil } return &build.Build{ Organization: p.Org, Pipeline: p.Name, BuildNumber: num, } } ================================================ FILE: internal/build/resolver/cli_test.go ================================================ package resolver_test import ( "context" "testing" "github.com/buildkite/cli/v3/internal/build/resolver" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/spf13/afero" ) func TestParseBuildArg(t *testing.T) { t.Parallel() testcases := map[string]struct { url, org, pipeline string num int }{ "org_pipeline_slug": { url: "buildkite/cli/34", org: "buildkite", pipeline: "cli", num: 34, }, "pipeline_slug": { url: "42", org: "testing", pipeline: "abcd", num: 42, }, "url": { url: "https://buildkite.com/buildkite/buildkite-cli/builds/99", org: "buildkite", pipeline: "buildkite-cli", num: 99, }, } for name, testcase := range testcases { testcase := testcase t.Run(name, func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) res := func(context.Context) (*pipeline.Pipeline, error) { return &pipeline.Pipeline{ Name: testcase.pipeline, Org: testcase.org, }, nil } f := resolver.ResolveFromPositionalArgument([]string{testcase.url}, 0, res, conf) build, err := f(context.Background()) if err != nil { t.Error(err) } if build.Organization != testcase.org { t.Error("parsed organization slug did not match expected") } if build.Pipeline != testcase.pipeline { t.Error("parsed pipeline name did not match expected") } if build.BuildNumber != testcase.num { t.Error("parsed build number did not match expected") } }) } t.Run("Returns error if failed parsing", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) f := resolver.ResolveFromPositionalArgument([]string{"https://buildkite.com/"}, 0, nil, conf) build, err := f(context.Background()) if err == nil { t.Error("should have failed parsing build") } if build != nil { t.Error("no build should be returned") } }) } ================================================ FILE: internal/build/resolver/options/options.go ================================================ package options import ( "context" "errors" "os/exec" "strings" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" git "github.com/go-git/go-git/v5" ) // OptionsFn is a function to apply modifications to the list builds API request ie. for adding additional filters type OptionsFn func(*buildkite.BuildsListOptions) error type AggregateResolver []OptionsFn func (ar AggregateResolver) WithResolverWhen(condition bool, resovler OptionsFn) AggregateResolver { if condition { return append(ar, resovler) } return ar } // ResolveBranchFromFlag returns a function that is used to add a branch filter to a build list options func ResolveBranchFromFlag(branch string) OptionsFn { return func(options *buildkite.BuildsListOptions) error { if branch != "" && len(options.Branch) == 0 { options.Branch = append(options.Branch, branch) } return nil } } // ResolveBranchFromRepository returns a function that is used to add a branch filter to a build list options func ResolveBranchFromRepository(repo *git.Repository) OptionsFn { return func(options *buildkite.BuildsListOptions) error { if len(options.Branch) > 0 { return nil } if repo != nil { head, err := repo.Head() if err != nil { return err } options.Branch = append(options.Branch, head.Name().Short()) return nil } branch, err := getBranchFromGit() if err != nil { return err } if branch != "" { options.Branch = append(options.Branch, branch) } return nil } } func getBranchFromGit() (string, error) { cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD") output, err := cmd.Output() if err != nil { var exitErr *exec.ExitError var execErr *exec.Error if errors.As(err, &exitErr) || errors.As(err, &execErr) { return "", nil } return "", err } return strings.TrimSpace(string(output)), nil } // ResolveUserFromFlag returns a function that is used to add a user filter to a build list options func ResolveUserFromFlag(user string) OptionsFn { return func(options *buildkite.BuildsListOptions) error { // set the user filter if the given user exists and a filter is not already set if user != "" && options.Creator == "" { options.Creator = user } return nil } } // ResolveCurrentUser returns a function that is used to add a user filter to a build list options func ResolveCurrentUser(ctx context.Context, f *factory.Factory) OptionsFn { return func(options *buildkite.BuildsListOptions) error { // if creator filter already applied, dont apply another if options.Creator != "" { return nil } user, _, err := f.RestAPIClient.User.CurrentUser(ctx) if err != nil { return err } // set the user filter if the given user exists and a filter is not already set options.Creator = user.ID return nil } } ================================================ FILE: internal/build/resolver/options/options_test.go ================================================ package options import ( "os" "os/exec" "path/filepath" "testing" buildkite "github.com/buildkite/go-buildkite/v4" git "github.com/go-git/go-git/v5" gitconfig "github.com/go-git/go-git/v5/config" ) func TestResolveBranchFromGitFallback(t *testing.T) { repo := testRepository(t, "https://github.com/buildkite/cli.git") wt, err := repo.Worktree() if err != nil { t.Fatalf("Worktree returned error: %v", err) } root := wt.Filesystem.Root() t.Chdir(root) commitFile := filepath.Join(root, "README.md") if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644); err != nil { t.Fatalf("creating file returned error: %v", err) } if err := exec.Command("git", "add", filepath.Base(commitFile)).Run(); err != nil { t.Fatalf("git add returned error: %v", err) } if err := exec.Command("git", "-c", "user.name=Person Example", "-c", "user.email=person@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "initial").Run(); err != nil { t.Fatalf("git commit returned error: %v", err) } if err := exec.Command("git", "checkout", "-b", "feature/test").Run(); err != nil { t.Fatalf("git checkout returned error: %v", err) } options := &buildkite.BuildsListOptions{} err = ResolveBranchFromRepository(nil)(options) if err != nil { t.Fatalf("ResolveBranchFromRepository returned error: %v", err) } if len(options.Branch) != 1 { t.Fatalf("expected 1 branch, got %d", len(options.Branch)) } if options.Branch[0] != "feature/test" { t.Fatalf("expected branch feature/test, got %q", options.Branch[0]) } } func testRepository(t *testing.T, remoteURLs ...string) *git.Repository { t.Helper() repo, err := git.PlainInit(t.TempDir(), false) if err != nil { t.Fatalf("PlainInit returned error: %v", err) } if len(remoteURLs) == 0 { return repo } _, err = repo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: remoteURLs}) if err != nil { t.Fatalf("CreateRemote returned error: %v", err) } return repo } ================================================ FILE: internal/build/resolver/resolver.go ================================================ package resolver import ( "context" "github.com/buildkite/cli/v3/internal/build" ) // BuildResolverFn is a function for finding a build. It returns an error if an irrecoverable scenario happens and // should halt execution. Otherwise, if the resolver does not find a build, it should return (nil, nil) to indicate // this. ie. no error occurred, but no build was found either type BuildResolverFn func(context.Context) (*build.Build, error) type AggregateResolver []BuildResolverFn // Resolve is a BuildResolverFn that wraps up a list of resolvers to loop through and try find a build. The first build // to be found will be returned. If none are found, it won't return an error to match the expectation of a // BuildResolverFn // // This is safe to call multiple times, the same result will be returned func (ar AggregateResolver) Resolve(ctx context.Context) (*build.Build, error) { for _, resolve := range ar { b, err := resolve(ctx) if err != nil { return nil, err } if b != nil { return b, nil } } return nil, nil } func (ar AggregateResolver) WithResolverWhen(condition bool, resovler BuildResolverFn) AggregateResolver { if condition { return append(ar, resovler) } return ar } // NewAggregateResolver creates an AggregateResolver from a list of BuildResolverFn, appending a final resolver for // capturing the case that no build is found by any resolver func NewAggregateResolver(resolvers ...BuildResolverFn) AggregateResolver { return resolvers } ================================================ FILE: internal/build/resolver/url.go ================================================ package resolver import ( "context" "fmt" "regexp" "strconv" "github.com/buildkite/cli/v3/internal/build" ) func ResolveFromURL(args []string) BuildResolverFn { return func(context.Context) (*build.Build, error) { if len(args) != 1 { return nil, fmt.Errorf("incorrect number of arguments, expected 1, got %d", len(args)) } resolvedBuild := splitBuildURL(args[0]) if resolvedBuild == nil { return nil, fmt.Errorf("unable to resolve build from URL: %s", args[0]) } return resolvedBuild, nil } } func splitBuildURL(url string) *build.Build { re := regexp.MustCompile(`https://buildkite.com/([^/]+)/([^/]+)/builds/(\d+)$`) matches := re.FindStringSubmatch(url) if matches == nil || len(matches) != 4 { return nil } num, err := strconv.Atoi(matches[3]) if err != nil { return nil } return &build.Build{ Organization: matches[1], Pipeline: matches[2], BuildNumber: num, } } ================================================ FILE: internal/build/resolver/with_options.go ================================================ package resolver import ( "context" "fmt" "github.com/buildkite/cli/v3/internal/build" "github.com/buildkite/cli/v3/internal/build/resolver/options" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" ) func ResolveBuildWithOpts(f *factory.Factory, pipelineResolver pipelineResolver.PipelineResolverFn, listOpts ...options.OptionsFn) BuildResolverFn { return func(ctx context.Context) (*build.Build, error) { pipeline, err := pipelineResolver(ctx) if err != nil { return nil, err } if pipeline == nil { return nil, fmt.Errorf("failed to resolve a pipeline to query builds on") } opts := &buildkite.BuildsListOptions{ ListOptions: buildkite.ListOptions{ PerPage: 1, }, } for _, opt := range listOpts { err = opt(opts) if err != nil { return nil, err } } builds, _, err := f.RestAPIClient.Builds.ListByPipeline(ctx, f.Config.OrganizationSlug(), pipeline.Name, opts) if err != nil { return nil, err } if len(builds) == 0 { return nil, nil } return &build.Build{ Organization: f.Config.OrganizationSlug(), Pipeline: pipeline.Name, BuildNumber: builds[0].Number, }, nil } } ================================================ FILE: internal/build/state/state.go ================================================ package state type State string const ( Scheduled State = "scheduled" Running State = "running" Blocked State = "blocked" Canceling State = "canceling" Failing State = "failing" Passed State = "passed" Failed State = "failed" Canceled State = "canceled" Skipped State = "skipped" NotRun State = "not_run" ) func IsTerminal(state State) bool { switch state { case Passed, Failed, Canceled, Skipped, NotRun: return true default: return false } } func IsIncomplete(state State) bool { switch state { case Scheduled, Running, Blocked, Canceling, Failing: return true default: return false } } ================================================ FILE: internal/build/state/state_test.go ================================================ package state import "testing" func TestIsTerminal(t *testing.T) { tests := []struct { name string state State want bool }{ {name: "scheduled", state: Scheduled, want: false}, {name: "running", state: Running, want: false}, {name: "blocked", state: Blocked, want: false}, {name: "canceling", state: Canceling, want: false}, {name: "failing", state: Failing, want: false}, {name: "passed", state: Passed, want: true}, {name: "failed", state: Failed, want: true}, {name: "canceled", state: Canceled, want: true}, {name: "skipped", state: Skipped, want: true}, {name: "not run", state: NotRun, want: true}, {name: "unknown", state: State("mystery"), want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsTerminal(tt.state); got != tt.want { t.Fatalf("IsTerminal(%q) = %v, want %v", tt.state, got, tt.want) } }) } } func TestIsIncomplete(t *testing.T) { tests := []struct { name string state State want bool }{ {name: "scheduled", state: Scheduled, want: true}, {name: "running", state: Running, want: true}, {name: "blocked", state: Blocked, want: true}, {name: "canceling", state: Canceling, want: true}, {name: "failing", state: Failing, want: true}, {name: "passed", state: Passed, want: false}, {name: "failed", state: Failed, want: false}, {name: "canceled", state: Canceled, want: false}, {name: "skipped", state: Skipped, want: false}, {name: "not run", state: NotRun, want: false}, {name: "unknown", state: State("mystery"), want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsIncomplete(tt.state); got != tt.want { t.Fatalf("IsIncomplete(%q) = %v, want %v", tt.state, got, tt.want) } }) } } ================================================ FILE: internal/build/view/shared/summary.go ================================================ package shared import ( "github.com/buildkite/cli/v3/internal/build/view" buildkite "github.com/buildkite/go-buildkite/v4" ) // BuildSummary renders a build summary that can be used by multiple commands func BuildSummary(b *buildkite.Build, organization, pipeline string) string { return view.BuildSummary(b, organization, pipeline) } // BuildSummaryWithJobs renders a build summary with jobs, used by watch command func BuildSummaryWithJobs(b *buildkite.Build, organization, pipeline string) string { return view.BuildSummaryWithJobs(b, organization, pipeline) } // RenderJobSummary renders a job summary that can be used by multiple commands func RenderJobSummary(j buildkite.Job) string { return view.RenderJobSummary(j) } ================================================ FILE: internal/build/view/view.go ================================================ package view import ( "fmt" "strings" "time" "github.com/buildkite/cli/v3/internal/artifact" "github.com/buildkite/cli/v3/internal/emoji" "github.com/buildkite/cli/v3/internal/validation" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) // ViewOptions represents options for viewing a build type ViewOptions struct { Organization string Pipeline string BuildNumber int Web bool } func (o *ViewOptions) Validate() error { v := validation.New() v.AddRule("Organization", validation.Required) v.AddRule("Organization", validation.Slug) v.AddRule("Pipeline", validation.Required) v.AddRule("Pipeline", validation.Slug) return v.Validate(map[string]interface{}{ "Organization": o.Organization, "Pipeline": o.Pipeline, }) } // BuildView encapsulates the build view functionality type BuildView struct { Build *buildkite.Build Artifacts []buildkite.Artifact Annotations []buildkite.Annotation Organization string Pipeline string } // NewBuildView creates a new BuildView instance func NewBuildView(build *buildkite.Build, artifacts []buildkite.Artifact, annotations []buildkite.Annotation, organization, pipeline string) *BuildView { return &BuildView{ Build: build, Artifacts: artifacts, Annotations: annotations, Organization: organization, Pipeline: pipeline, } } func BuildSummary(b *buildkite.Build, organization, pipeline string) string { return buildSummary(b, organization, pipeline) } func BuildSummaryWithJobs(b *buildkite.Build, organization, pipeline string) string { var sb strings.Builder sb.WriteString(buildSummary(b, organization, pipeline)) if b != nil { if jobs := renderJobs(b.Jobs); jobs != "" { sb.WriteString("\n\n") sb.WriteString(jobs) } } return sb.String() } // RenderJobSummary renders a single job's summary func RenderJobSummary(j buildkite.Job) string { return renderJobs([]buildkite.Job{j}) } // Render returns the complete build view func (v *BuildView) Render() string { var sb strings.Builder sb.WriteString(buildSummary(v.Build, v.Organization, v.Pipeline)) if v.Build == nil { return sb.String() } if jobs := renderJobs(v.Build.Jobs); jobs != "" { sb.WriteString("\n\n") sb.WriteString(jobs) } // Add artifacts if present if artifacts := renderArtifacts(v.Artifacts); artifacts != "" { sb.WriteString("\n\n") sb.WriteString(artifacts) } if annotations := renderAnnotations(v.Annotations); annotations != "" { sb.WriteString("\n\n") sb.WriteString(annotations) } return sb.String() } func buildSummary(b *buildkite.Build, organization, pipeline string) string { if b == nil { return fmt.Sprintf("Build %s/%s (no data available)\n", output.ValueOrDash(organization), output.ValueOrDash(pipeline)) } var sb strings.Builder fmt.Fprintf(&sb, "Build %s/%s #%d (%s)\n\n", output.ValueOrDash(organization), output.ValueOrDash(pipeline), b.Number, b.State) summary := output.Table( []string{"Field", "Value"}, [][]string{ {"Message", output.ValueOrDash(truncateText(b.Message, 140))}, {"Source", output.ValueOrDash(b.Source)}, {"Creator", creatorName(b)}, {"Branch", output.ValueOrDash(b.Branch)}, {"Commit", shortenCommit(b.Commit)}, {"URL", output.ValueOrDash(b.WebURL)}, }, map[string]string{"field": "bold", "value": "dim"}, ) sb.WriteString(summary) return sb.String() } func renderJobs(jobs []buildkite.Job) string { scriptJobs := filterScriptJobs(jobs) if len(scriptJobs) == 0 { return "" } headers := []string{"State", "Name", "Duration"} rows := make([][]string, 0, len(scriptJobs)) for _, job := range scriptJobs { name := job.Name if name == "" { name = job.Label } if name == "" { parts := strings.Split(job.Command, "\n") if len(parts) > 0 { name = parts[0] } } if name == "" { name = "-" } name = truncateText(name, 72) name = emoji.Render(name) rows = append(rows, []string{ job.State, name, formatJobDuration(job), }) } table := output.Table(headers, rows, map[string]string{"state": "bold", "name": "italic", "duration": "dim"}) return fmt.Sprintf("Jobs (%d)\n\n%s", len(scriptJobs), table) } func renderArtifacts(artifacts []buildkite.Artifact) string { if len(artifacts) == 0 { return "" } headers := []string{"ID", "Path", "Size"} rows := make([][]string, 0, len(artifacts)) for _, a := range artifacts { size := artifact.FormatBytes(a.FileSize) rows = append(rows, []string{a.ID, a.Path, size}) } table := output.Table(headers, rows, map[string]string{"id": "dim", "path": "bold", "size": "dim"}) return fmt.Sprintf("Artifacts (%d)\n\n%s", len(artifacts), table) } func renderAnnotations(annotations []buildkite.Annotation) string { if len(annotations) == 0 { return "" } headers := []string{"Style", "Context"} rows := make([][]string, 0, len(annotations)) for _, ann := range annotations { rows = append(rows, []string{ann.Style, ann.Context}) } table := output.Table(headers, rows, map[string]string{"style": "bold", "context": "italic"}) return fmt.Sprintf("Annotations (%d)\n\n%s", len(annotations), table) } func filterScriptJobs(jobs []buildkite.Job) []buildkite.Job { result := make([]buildkite.Job, 0, len(jobs)) for _, job := range jobs { if job.Type == "script" { result = append(result, job) } } return result } func creatorName(build *buildkite.Build) string { if build == nil { return "Unknown" } if build.Creator.ID != "" { return build.Creator.Name } if build.Author.Username != "" { return build.Author.Name } return "Unknown" } func formatJobDuration(job buildkite.Job) string { if job.StartedAt == nil { return "-" } if job.FinishedAt != nil { d := job.FinishedAt.Sub(job.StartedAt.Time) return formatDuration(d) } return formatDuration(time.Since(job.StartedAt.Time)) + " (running)" } const ellipsis = "…" func truncateText(text string, maxLength int) string { runes := []rune(text) if len(runes) <= maxLength { return string(runes) } return string(runes[:maxLength]) + ellipsis } func formatDuration(d time.Duration) string { if d == 0 { return "" } return d.String() } func shortenCommit(commit string) string { if strings.TrimSpace(commit) == "" { return "-" } if len(commit) <= 12 { return commit } return commit[:12] } ================================================ FILE: internal/build/view/view_test.go ================================================ package view import ( "strings" "testing" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestBuildSummary_NilBuild(t *testing.T) { result := BuildSummary(nil, "my-org", "my-pipeline") if !strings.Contains(result, "my-org") { t.Errorf("Expected result to contain organization, got: %s", result) } if !strings.Contains(result, "my-pipeline") { t.Errorf("Expected result to contain pipeline, got: %s", result) } if !strings.Contains(result, "no data available") { t.Errorf("Expected result to indicate no data available, got: %s", result) } } func TestBuildSummary_ValidBuild(t *testing.T) { build := &buildkite.Build{ Number: 123, State: "passed", Message: "Test build", Branch: "main", } result := BuildSummary(build, "my-org", "my-pipeline") if !strings.Contains(result, "#123") { t.Errorf("Expected result to contain build number, got: %s", result) } if !strings.Contains(result, "passed") { t.Errorf("Expected result to contain state, got: %s", result) } } func TestBuildSummaryWithJobs_NilBuild(t *testing.T) { result := BuildSummaryWithJobs(nil, "my-org", "my-pipeline") if !strings.Contains(result, "my-org") { t.Errorf("Expected result to contain organization, got: %s", result) } if !strings.Contains(result, "no data available") { t.Errorf("Expected result to indicate no data available, got: %s", result) } } func TestBuildSummaryWithJobs_ValidBuild(t *testing.T) { build := &buildkite.Build{ Number: 456, State: "running", Message: "Test build with jobs", Jobs: []buildkite.Job{ {Type: "script", Name: "Test Job", State: "passed"}, }, } result := BuildSummaryWithJobs(build, "my-org", "my-pipeline") if !strings.Contains(result, "#456") { t.Errorf("Expected result to contain build number, got: %s", result) } if !strings.Contains(result, "Jobs") { t.Errorf("Expected result to contain jobs section, got: %s", result) } } func TestBuildView_Render_NilBuild(t *testing.T) { view := NewBuildView(nil, nil, nil, "my-org", "my-pipeline") result := view.Render() if !strings.Contains(result, "my-org") { t.Errorf("Expected result to contain organization, got: %s", result) } if !strings.Contains(result, "no data available") { t.Errorf("Expected result to indicate no data available, got: %s", result) } } func TestBuildView_Render_ValidBuild(t *testing.T) { build := &buildkite.Build{ Number: 789, State: "passed", Message: "Test build", Jobs: []buildkite.Job{ {Type: "script", Name: "Build", State: "passed"}, }, } artifacts := []buildkite.Artifact{ {ID: "art-1", Path: "dist/app.js", FileSize: 1024}, } annotations := []buildkite.Annotation{ {Style: "info", Context: "test-context"}, } view := NewBuildView(build, artifacts, annotations, "my-org", "my-pipeline") result := view.Render() if !strings.Contains(result, "#789") { t.Errorf("Expected result to contain build number, got: %s", result) } if !strings.Contains(result, "Jobs") { t.Errorf("Expected result to contain jobs section, got: %s", result) } if !strings.Contains(result, "Artifacts") { t.Errorf("Expected result to contain artifacts section, got: %s", result) } if !strings.Contains(result, "Annotations") { t.Errorf("Expected result to contain annotations section, got: %s", result) } } func TestCreatorName_NilBuild(t *testing.T) { result := creatorName(nil) if result != "Unknown" { t.Errorf("Expected 'Unknown' for nil build, got: %s", result) } } func TestCreatorName_WithCreator(t *testing.T) { build := &buildkite.Build{ Creator: buildkite.Creator{ ID: "user-123", Name: "John Doe", }, } result := creatorName(build) if result != "John Doe" { t.Errorf("Expected 'John Doe', got: %s", result) } } func TestCreatorName_WithAuthor(t *testing.T) { build := &buildkite.Build{ Author: buildkite.Author{ Username: "janedoe", Name: "Jane Doe", }, } result := creatorName(build) if result != "Jane Doe" { t.Errorf("Expected 'Jane Doe', got: %s", result) } } func TestCreatorName_NoCreatorOrAuthor(t *testing.T) { build := &buildkite.Build{} result := creatorName(build) if result != "Unknown" { t.Errorf("Expected 'Unknown', got: %s", result) } } ================================================ FILE: internal/build/watch/job.go ================================================ package watch import ( "time" buildkite "github.com/buildkite/go-buildkite/v4" ) // FormattedJob wraps a Buildkite job with watch-specific formatting and classification helpers. type FormattedJob struct { buildkite.Job } // NewFormattedJob wraps a Buildkite job. func NewFormattedJob(j buildkite.Job) FormattedJob { return FormattedJob{Job: j} } // DisplayName returns a human-readable name for a job. func (j FormattedJob) DisplayName() string { if j.Name != "" { return j.Name } if j.Label != "" { return j.Label } return j.Type + " step" } // Duration returns the elapsed duration for a job. func (j FormattedJob) Duration() time.Duration { if j.StartedAt == nil { return 0 } end := time.Now() if j.FinishedAt != nil { end = j.FinishedAt.Time } return end.Sub(j.StartedAt.Time).Truncate(time.Second) } func (j FormattedJob) IsTerminalFailureState() bool { return j.State == "failed" || j.State == "timed_out" || j.State == "canceled" || j.State == "expired" } func (j FormattedJob) IsSoftFailed() bool { return j.SoftFailed } func (j FormattedJob) IsFailed() bool { return j.IsTerminalFailureState() } ================================================ FILE: internal/build/watch/test_tracker.go ================================================ package watch import ( buildkite "github.com/buildkite/go-buildkite/v4" ) // TestTracker tracks which test executions have already been reported, // so that each test change is only surfaced once across polling iterations. type TestTracker struct { seenExecutions map[string]bool // keyed by execution ID } // NewTestTracker creates a new TestTracker. func NewTestTracker() *TestTracker { return &TestTracker{ seenExecutions: make(map[string]bool), } } // Update processes a list of build tests and returns only those with // at least one execution that has not been seen before. func (t *TestTracker) Update(tests []buildkite.BuildTest) []buildkite.BuildTest { var newTestChanges []buildkite.BuildTest for _, test := range tests { if len(test.Executions) == 0 { continue } hasNewExecution := false for _, execution := range test.Executions { if execution.ID != "" && !t.seenExecutions[execution.ID] { t.seenExecutions[execution.ID] = true hasNewExecution = true } } if hasNewExecution { newTestChanges = append(newTestChanges, test) } } return newTestChanges } ================================================ FILE: internal/build/watch/test_tracker_test.go ================================================ package watch import ( "testing" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestTestTracker_Update(t *testing.T) { t.Run("reports new test changes", func(t *testing.T) { tracker := NewTestTracker() tests := []buildkite.BuildTest{ { ID: "test-1", Name: "flaky test", Executions: []buildkite.BuildTestExecution{{ ID: "exec-1", Status: "failed", FailureReason: "expected 3, got 2", }}, }, } newTestChanges := tracker.Update(tests) if len(newTestChanges) != 1 { t.Fatalf("expected 1 new test change, got %d", len(newTestChanges)) } if newTestChanges[0].Name != "flaky test" { t.Errorf("expected 'flaky test', got %q", newTestChanges[0].Name) } }) t.Run("does not re-report same execution", func(t *testing.T) { tracker := NewTestTracker() tests := []buildkite.BuildTest{ { ID: "test-1", Name: "flaky test", Executions: []buildkite.BuildTestExecution{{ ID: "exec-1", Status: "failed", FailureReason: "expected 3, got 2", }}, }, } tracker.Update(tests) newTestChanges := tracker.Update(tests) if len(newTestChanges) != 0 { t.Errorf("expected 0 new test changes on second poll, got %d", len(newTestChanges)) } }) t.Run("reports new execution for same test", func(t *testing.T) { tracker := NewTestTracker() tracker.Update([]buildkite.BuildTest{ { ID: "test-1", Name: "flaky test", Executions: []buildkite.BuildTestExecution{{ ID: "exec-1", Status: "failed", FailureReason: "first failure", }}, }, }) newTestChanges := tracker.Update([]buildkite.BuildTest{ { ID: "test-1", Name: "flaky test", Executions: []buildkite.BuildTestExecution{{ ID: "exec-2", Status: "failed", FailureReason: "second failure", }}, }, }) if len(newTestChanges) != 1 { t.Fatalf("expected 1 new test change, got %d", len(newTestChanges)) } }) t.Run("skips tests without executions", func(t *testing.T) { tracker := NewTestTracker() tests := []buildkite.BuildTest{ {ID: "test-1", Name: "passing test"}, { ID: "test-2", Name: "failing test", Executions: []buildkite.BuildTestExecution{{ ID: "exec-1", Status: "failed", FailureReason: "boom", }}, }, } newTestChanges := tracker.Update(tests) if len(newTestChanges) != 1 { t.Fatalf("expected 1 new test change, got %d", len(newTestChanges)) } if newTestChanges[0].ID != "test-2" { t.Errorf("expected test-2, got %s", newTestChanges[0].ID) } }) t.Run("skips tests with missing execution ids", func(t *testing.T) { tracker := NewTestTracker() tests := []buildkite.BuildTest{ {ID: "test-1", Name: "passing test"}, { ID: "test-2", Name: "failing test", Executions: []buildkite.BuildTestExecution{{ Status: "failed", FailureReason: "boom", }}, }, } newTestChanges := tracker.Update(tests) if len(newTestChanges) != 0 { t.Fatalf("expected 0 new test change, got %d", len(newTestChanges)) } }) t.Run("handles multiple new test changes at once", func(t *testing.T) { tracker := NewTestTracker() tests := []buildkite.BuildTest{ { ID: "test-1", Executions: []buildkite.BuildTestExecution{{ID: "exec-1", Status: "failed"}}, }, { ID: "test-2", Executions: []buildkite.BuildTestExecution{{ID: "exec-2", Status: "failed"}}, }, { ID: "test-3", Executions: []buildkite.BuildTestExecution{{ID: "exec-3", Status: "failed"}}, }, } newTestChanges := tracker.Update(tests) if len(newTestChanges) != 3 { t.Fatalf("expected 3 new test changes, got %d", len(newTestChanges)) } }) t.Run("reports one test change when a test has multiple new executions", func(t *testing.T) { tracker := NewTestTracker() tests := []buildkite.BuildTest{ { ID: "test-1", Name: "flaky test", Executions: []buildkite.BuildTestExecution{ {ID: "exec-1", Status: "failed", FailureReason: "first failure"}, {ID: "exec-2", Status: "failed", FailureReason: "second failure"}, }, }, } newTestChanges := tracker.Update(tests) if len(newTestChanges) != 1 { t.Fatalf("expected 1 new test change, got %d", len(newTestChanges)) } newTestChanges = tracker.Update(tests) if len(newTestChanges) != 0 { t.Fatalf("expected 0 new test changes on second poll, got %d", len(newTestChanges)) } }) } ================================================ FILE: internal/build/watch/tracker.go ================================================ package watch import ( "fmt" "sort" "strings" buildkite "github.com/buildkite/go-buildkite/v4" ) // trackedJob holds a job and its lifecycle state across polls. type trackedJob struct { Job buildkite.Job PrevState string // state from previous poll, "" if first seen Reported bool // true once surfaced to caller as failed RetryReported bool // true once surfaced to caller as retry-passed } // JobSummary aggregates job counts by high-level state. type JobSummary struct { Passed int `json:"passed"` Failed int `json:"failed"` SoftFailed int `json:"soft_failed"` Running int `json:"running"` Scheduled int `json:"scheduled"` Blocked int `json:"blocked"` Skipped int `json:"skipped"` Waiting int `json:"waiting"` } // String returns a human-readable summary of non-zero job counts. func (s JobSummary) String() string { type entry struct { count int label string } entries := []entry{ {s.Passed, "passed"}, {s.Failed, "failed"}, {s.SoftFailed, "soft failed"}, {s.Running, "running"}, {s.Scheduled, "scheduled"}, {s.Blocked, "blocked"}, {s.Skipped, "skipped"}, {s.Waiting, "waiting"}, } var parts []string for _, e := range entries { if e.count > 0 { parts = append(parts, fmt.Sprintf("%d %s", e.count, e.label)) } } return strings.Join(parts, ", ") } // BuildStatus is the output of JobTracker.Update(). type BuildStatus struct { NewlyFailed []buildkite.Job NewlyRetryPassed []buildkite.Job Running []buildkite.Job TotalRunning int Summary JobSummary Build buildkite.Build } // JobTracker tracks job state changes across polls. type JobTracker struct { jobs map[string]*trackedJob } // NewJobTracker creates a new JobTracker. func NewJobTracker() *JobTracker { return &JobTracker{ jobs: make(map[string]*trackedJob), } } // Update processes a build and returns the current status with any state changes. func (t *JobTracker) Update(b buildkite.Build) BuildStatus { var status BuildStatus status.Build = b var running []buildkite.Job for _, j := range b.Jobs { if j.Type != "script" || j.State == "broken" { continue } job := NewFormattedJob(j) tj, exists := t.jobs[j.ID] if !exists { tj = &trackedJob{} t.jobs[j.ID] = tj } else { tj.PrevState = tj.Job.State } tj.Job = j prevJob := NewFormattedJob(buildkite.Job{State: tj.PrevState}) if job.IsFailed() && !prevJob.IsTerminalFailureState() && !tj.Reported { status.NewlyFailed = append(status.NewlyFailed, j) tj.Reported = true } if isActiveState(j.State) { running = append(running, j) } } // Second pass: detect retry jobs that just reached passed. for _, j := range b.Jobs { if j.Type != "script" || j.State != "passed" || j.RetriesCount == 0 { continue } tj := t.jobs[j.ID] if tj == nil || tj.RetryReported { continue } for _, orig := range t.jobs { if orig.Job.RetriedInJobID == j.ID && orig.Reported { status.NewlyRetryPassed = append(status.NewlyRetryPassed, j) tj.RetryReported = true break } } } status.Summary = t.summarize(b) status.TotalRunning = len(running) status.Running = running return status } // PassedJobs returns all non-superseded jobs that passed, sorted by start time. func (t *JobTracker) PassedJobs() []buildkite.Job { var result []buildkite.Job for _, tj := range t.jobs { if tj.Job.State == "passed" && !tj.Job.Retried { result = append(result, tj.Job) } } sortJobsByStartTime(result) return result } // FailedJobs returns all hard-failed, non-superseded jobs (excludes soft failures), // sorted by start time. func (t *JobTracker) FailedJobs() []buildkite.Job { var result []buildkite.Job for _, tj := range t.jobs { job := NewFormattedJob(tj.Job) if job.IsFailed() && !job.IsSoftFailed() && !tj.Job.Retried { result = append(result, tj.Job) } } sortJobsByStartTime(result) return result } func sortJobsByStartTime(jobs []buildkite.Job) { sort.Slice(jobs, func(i, j int) bool { si, sj := jobs[i].StartedAt, jobs[j].StartedAt switch { case si == nil && sj == nil: return jobs[i].ID < jobs[j].ID case si == nil: return false case sj == nil: return true case si.Time.Equal(sj.Time): return jobs[i].ID < jobs[j].ID default: return si.Before(sj.Time) } }) } func (t *JobTracker) summarize(b buildkite.Build) JobSummary { var s JobSummary for _, j := range b.Jobs { if j.Type != "script" || j.Retried { continue } job := NewFormattedJob(j) if job.IsSoftFailed() { s.SoftFailed++ continue } switch j.State { case "running", "canceling", "timing_out": s.Running++ case "passed": s.Passed++ case "failed", "timed_out", "canceled", "expired": s.Failed++ case "skipped", "broken": s.Skipped++ case "blocked", "blocked_failed": s.Blocked++ case "scheduled", "assigned", "accepted", "reserved": s.Scheduled++ case "waiting", "waiting_failed", "pending", "limited", "limiting", "platform_limited", "platform_limiting": s.Waiting++ } } return s } func isActiveState(state string) bool { return state == "running" || state == "canceling" || state == "timing_out" } ================================================ FILE: internal/build/watch/tracker_test.go ================================================ package watch import ( "fmt" "testing" "time" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestJobTracker_Update(t *testing.T) { t.Run("first poll reports failures", func(t *testing.T) { tracker := NewJobTracker() status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "passed"}, {ID: "2", Type: "script", State: "failed"}, {ID: "3", Type: "script", State: "running"}, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed, got %d", len(status.NewlyFailed)) } if status.NewlyFailed[0].ID != "2" { t.Errorf("expected job 2, got %s", status.NewlyFailed[0].ID) } }) t.Run("same data second poll has no newly failed", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "failed"}, }, }) status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "failed"}, }, }) if len(status.NewlyFailed) != 0 { t.Errorf("expected 0 newly failed, got %d", len(status.NewlyFailed)) } }) t.Run("running to failed transition", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "running"}, }, }) status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "failed"}, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed, got %d", len(status.NewlyFailed)) } if status.NewlyFailed[0].State != "failed" { t.Errorf("expected state failed, got %s", status.NewlyFailed[0].State) } }) t.Run("soft failed reported", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "running"}, }, }) status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "failed", SoftFailed: true}, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed, got %d", len(status.NewlyFailed)) } if !status.NewlyFailed[0].SoftFailed { t.Error("expected SoftFailed to be true") } }) t.Run("timed out reported as failed", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "running"}, }, }) status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "timed_out"}, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed, got %d", len(status.NewlyFailed)) } if status.NewlyFailed[0].State != "timed_out" { t.Errorf("expected state timed_out, got %s", status.NewlyFailed[0].State) } }) t.Run("skips non-script and broken jobs", func(t *testing.T) { tracker := NewJobTracker() status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "waiter", State: "failed"}, {ID: "2", Type: "manual", State: "failed"}, {ID: "3", Type: "script", State: "broken"}, {ID: "4", Type: "script", State: "failed"}, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed, got %d", len(status.NewlyFailed)) } if status.NewlyFailed[0].ID != "4" { t.Errorf("expected job 4, got %s", status.NewlyFailed[0].ID) } }) t.Run("returns all running jobs", func(t *testing.T) { tracker := NewJobTracker() var jobs []buildkite.Job for i := 0; i < 15; i++ { jobs = append(jobs, buildkite.Job{ ID: fmt.Sprintf("job-%d", i), Type: "script", State: "running", }) } status := tracker.Update(buildkite.Build{Jobs: jobs}) if status.TotalRunning != 15 { t.Errorf("expected TotalRunning 15, got %d", status.TotalRunning) } if len(status.Running) != 15 { t.Errorf("expected Running to include all 15 jobs, got %d", len(status.Running)) } }) t.Run("new job appears mid-build", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "running"}, }, }) status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "running"}, {ID: "2", Type: "script", State: "failed"}, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed, got %d", len(status.NewlyFailed)) } if status.NewlyFailed[0].ID != "2" { t.Errorf("expected job 2, got %s", status.NewlyFailed[0].ID) } }) t.Run("summary counts are correct", func(t *testing.T) { tracker := NewJobTracker() status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "passed"}, {ID: "2", Type: "script", State: "failed"}, {ID: "3", Type: "script", State: "running"}, {ID: "4", Type: "script", State: "scheduled"}, {ID: "5", Type: "script", State: "running"}, }, }) if status.Summary.Passed != 1 { t.Errorf("expected 1 passed, got %d", status.Summary.Passed) } if status.Summary.Failed != 1 { t.Errorf("expected 1 failed, got %d", status.Summary.Failed) } if status.Summary.Running != 2 { t.Errorf("expected 2 running, got %d", status.Summary.Running) } if status.Summary.Scheduled != 1 { t.Errorf("expected 1 scheduled, got %d", status.Summary.Scheduled) } }) t.Run("failed job includes exit status and duration", func(t *testing.T) { tracker := NewJobTracker() now := time.Now() start := now.Add(-5 * time.Second) exitCode := 2 status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ { ID: "1", Type: "script", Name: "lint", State: "failed", ExitStatus: &exitCode, StartedAt: &buildkite.Timestamp{Time: start}, FinishedAt: &buildkite.Timestamp{Time: now}, }, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed, got %d", len(status.NewlyFailed)) } fj := status.NewlyFailed[0] if fj.Name != "lint" { t.Errorf("expected name lint, got %s", fj.Name) } if fj.ExitStatus == nil || *fj.ExitStatus != 2 { t.Errorf("expected exit status 2, got %v", fj.ExitStatus) } if duration := NewFormattedJob(fj).Duration(); duration != 5*time.Second { t.Errorf("expected duration 5s, got %s", duration) } }) } func TestJobTracker_Update_RetriedJobs(t *testing.T) { t.Run("superseded job still reported as newly failed", func(t *testing.T) { tracker := NewJobTracker() // Poll 1: job already failed and retried (e.g. automatic retry) status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "running", RetriesCount: 1}, }, }) if len(status.NewlyFailed) != 1 { t.Fatalf("expected 1 newly failed (even though superseded), got %d", len(status.NewlyFailed)) } if status.NewlyFailed[0].ID != "orig" { t.Errorf("expected orig, got %s", status.NewlyFailed[0].ID) } // Poll 2: same state, not re-reported status = tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "running", RetriesCount: 1}, }, }) if len(status.NewlyFailed) != 0 { t.Errorf("expected 0 newly failed on re-poll, got %d", len(status.NewlyFailed)) } }) t.Run("retry passed detected when retry job reaches passed", func(t *testing.T) { tracker := NewJobTracker() // Poll 1: job fails tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed"}, }, }) // Poll 2: original retried, retry running tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "running", RetriesCount: 1}, }, }) // Poll 3: retry passes status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "passed", RetriesCount: 1}, }, }) if len(status.NewlyRetryPassed) != 1 { t.Fatalf("expected 1 retry passed, got %d", len(status.NewlyRetryPassed)) } if status.NewlyRetryPassed[0].ID != "retry-1" { t.Errorf("expected retry-1, got %s", status.NewlyRetryPassed[0].ID) } }) t.Run("retry passed reported only once", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed"}, }, }) tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "passed", RetriesCount: 1}, }, }) // Second poll with same passed state status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "passed", RetriesCount: 1}, }, }) if len(status.NewlyRetryPassed) != 0 { t.Errorf("expected 0 retry passed on second poll, got %d", len(status.NewlyRetryPassed)) } }) t.Run("chained retries: second retry passes", func(t *testing.T) { tracker := NewJobTracker() // Poll 1: original fails tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed"}, }, }) // Poll 2: original retried, first retry also fails tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "failed", RetriesCount: 1}, }, }) // Poll 3: first retry retried, second retry passes status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-2", RetriesCount: 1}, {ID: "retry-2", Type: "script", State: "passed", RetriesCount: 2}, }, }) if len(status.NewlyRetryPassed) != 1 { t.Fatalf("expected 1 retry passed, got %d", len(status.NewlyRetryPassed)) } if status.NewlyRetryPassed[0].ID != "retry-2" { t.Errorf("expected retry-2, got %s", status.NewlyRetryPassed[0].ID) } }) t.Run("summary excludes superseded jobs", func(t *testing.T) { tracker := NewJobTracker() status := tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "passed", RetriesCount: 1}, }, }) if status.Summary.Failed != 0 { t.Errorf("expected 0 failed (superseded excluded), got %d", status.Summary.Failed) } if status.Summary.Passed != 1 { t.Errorf("expected 1 passed, got %d", status.Summary.Passed) } }) } func TestJobTracker_FailedJobs(t *testing.T) { t.Run("returns hard failed jobs and excludes soft failures", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "failed"}, {ID: "2", Type: "script", State: "running"}, }, }) tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "script", State: "failed"}, {ID: "2", Type: "script", State: "failed", SoftFailed: true}, }, }) failedJobs := tracker.FailedJobs() if len(failedJobs) != 1 { t.Fatalf("expected 1 failed job, got %d", len(failedJobs)) } if failedJobs[0].ID != "1" { t.Errorf("expected failed job 1, got %s", failedJobs[0].ID) } }) t.Run("excludes non-script, broken, and soft-failed jobs", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "1", Type: "waiter", State: "failed"}, {ID: "2", Type: "script", State: "broken"}, {ID: "3", Type: "script", State: "failed"}, {ID: "4", Type: "script", State: "failed", SoftFailed: true}, }, }) failedJobs := tracker.FailedJobs() if len(failedJobs) != 1 { t.Fatalf("expected 1 failed job, got %d", len(failedJobs)) } if failedJobs[0].ID != "3" { t.Errorf("expected failed job 3, got %s", failedJobs[0].ID) } }) t.Run("excludes superseded (retried) jobs", func(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "failed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "still-failed", Type: "script", State: "failed"}, {ID: "retry-1", Type: "script", State: "passed", RetriesCount: 1}, }, }) failedJobs := tracker.FailedJobs() if len(failedJobs) != 1 { t.Fatalf("expected 1 failed job (superseded excluded), got %d", len(failedJobs)) } if failedJobs[0].ID != "still-failed" { t.Errorf("expected still-failed, got %s", failedJobs[0].ID) } }) } func TestJobTracker_PassedJobs_ExcludesSuperseded(t *testing.T) { tracker := NewJobTracker() tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "orig", Type: "script", State: "passed", Retried: true, RetriedInJobID: "retry-1"}, {ID: "retry-1", Type: "script", State: "passed", RetriesCount: 1}, }, }) jobs := tracker.PassedJobs() if len(jobs) != 1 { t.Fatalf("expected 1 passed job (superseded excluded), got %d", len(jobs)) } if jobs[0].ID != "retry-1" { t.Errorf("expected retry-1, got %s", jobs[0].ID) } } func TestJobTracker_PassedJobs_SortedByStartTime(t *testing.T) { tracker := NewJobTracker() t1 := buildkite.Timestamp{Time: time.Date(2025, 1, 1, 0, 0, 10, 0, time.UTC)} t2 := buildkite.Timestamp{Time: time.Date(2025, 1, 1, 0, 0, 5, 0, time.UTC)} tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "late", Type: "script", State: "passed", StartedAt: &t1}, {ID: "early", Type: "script", State: "passed", StartedAt: &t2}, {ID: "no-start", Type: "script", State: "passed"}, }, }) jobs := tracker.PassedJobs() if len(jobs) != 3 { t.Fatalf("expected 3 jobs, got %d", len(jobs)) } wantOrder := []string{"early", "late", "no-start"} for i, id := range wantOrder { if jobs[i].ID != id { t.Errorf("position %d: got %s, want %s", i, jobs[i].ID, id) } } } func TestJobTracker_FailedJobs_SortedByStartTime(t *testing.T) { tracker := NewJobTracker() t1 := buildkite.Timestamp{Time: time.Date(2025, 1, 1, 0, 0, 20, 0, time.UTC)} t2 := buildkite.Timestamp{Time: time.Date(2025, 1, 1, 0, 0, 10, 0, time.UTC)} tracker.Update(buildkite.Build{ Jobs: []buildkite.Job{ {ID: "b", Type: "script", State: "failed", StartedAt: &t1}, {ID: "a", Type: "script", State: "failed", StartedAt: &t2}, }, }) jobs := tracker.FailedJobs() if len(jobs) != 2 { t.Fatalf("expected 2 jobs, got %d", len(jobs)) } if jobs[0].ID != "a" || jobs[1].ID != "b" { t.Errorf("expected [a, b], got [%s, %s]", jobs[0].ID, jobs[1].ID) } } func TestJobTracker_Summarize(t *testing.T) { tracker := NewJobTracker() tests := []struct { name string jobs []buildkite.Job want JobSummary }{ { name: "empty build", jobs: nil, want: JobSummary{}, }, { name: "skips non-script jobs", jobs: []buildkite.Job{ {Type: "waiter", State: "passed"}, {Type: "manual", State: "blocked"}, }, want: JobSummary{}, }, { name: "counts passed", jobs: []buildkite.Job{ {Type: "script", State: "passed"}, {Type: "script", State: "passed"}, }, want: JobSummary{Passed: 2}, }, { name: "counts soft failed separately", jobs: []buildkite.Job{ {Type: "script", State: "failed", SoftFailed: true}, {Type: "script", State: "passed"}, }, want: JobSummary{Passed: 1, SoftFailed: 1}, }, { name: "counts failed and timed_out", jobs: []buildkite.Job{ {Type: "script", State: "failed"}, {Type: "script", State: "timed_out"}, }, want: JobSummary{Failed: 2}, }, { name: "counts running states", jobs: []buildkite.Job{ {Type: "script", State: "running"}, {Type: "script", State: "canceling"}, {Type: "script", State: "timing_out"}, }, want: JobSummary{Running: 3}, }, { name: "counts canceled as failed", jobs: []buildkite.Job{ {Type: "script", State: "canceled"}, }, want: JobSummary{Failed: 1}, }, { name: "counts expired as failed", jobs: []buildkite.Job{ {Type: "script", State: "expired"}, }, want: JobSummary{Failed: 1}, }, { name: "counts skipped and broken", jobs: []buildkite.Job{ {Type: "script", State: "skipped"}, {Type: "script", State: "broken"}, }, want: JobSummary{Skipped: 2}, }, { name: "counts blocked states", jobs: []buildkite.Job{ {Type: "script", State: "blocked"}, {Type: "script", State: "blocked_failed"}, }, want: JobSummary{Blocked: 2}, }, { name: "counts scheduled states", jobs: []buildkite.Job{ {Type: "script", State: "scheduled"}, {Type: "script", State: "assigned"}, {Type: "script", State: "accepted"}, {Type: "script", State: "reserved"}, }, want: JobSummary{Scheduled: 4}, }, { name: "counts waiting states", jobs: []buildkite.Job{ {Type: "script", State: "waiting"}, {Type: "script", State: "waiting_failed"}, {Type: "script", State: "pending"}, {Type: "script", State: "limited"}, {Type: "script", State: "limiting"}, {Type: "script", State: "platform_limited"}, {Type: "script", State: "platform_limiting"}, }, want: JobSummary{Waiting: 7}, }, { name: "ignores unknown states", jobs: []buildkite.Job{ {Type: "script", State: "passed"}, {Type: "script", State: "something_new"}, }, want: JobSummary{Passed: 1}, }, { name: "mixed build", jobs: []buildkite.Job{ {Type: "script", State: "passed"}, {Type: "script", State: "failed"}, {Type: "script", State: "running"}, {Type: "script", State: "scheduled"}, {Type: "waiter", State: "passed"}, }, want: JobSummary{Passed: 1, Failed: 1, Running: 1, Scheduled: 1}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tracker.summarize(buildkite.Build{Jobs: tt.jobs}) if got != tt.want { t.Errorf("summarize() = %+v, want %+v", got, tt.want) } }) } } func TestJobSummary_String(t *testing.T) { tests := []struct { name string summary JobSummary want string }{ { name: "empty summary", summary: JobSummary{}, want: "", }, { name: "single field", summary: JobSummary{Passed: 3}, want: "3 passed", }, { name: "multiple fields in order", summary: JobSummary{Passed: 2, Failed: 1, Running: 3}, want: "2 passed, 1 failed, 3 running", }, { name: "soft failed shown separately", summary: JobSummary{Passed: 3, Failed: 1, SoftFailed: 2}, want: "3 passed, 1 failed, 2 soft failed", }, { name: "all fields", summary: JobSummary{Passed: 1, Failed: 2, SoftFailed: 3, Running: 4, Scheduled: 5, Blocked: 6, Skipped: 7, Waiting: 8}, want: "1 passed, 2 failed, 3 soft failed, 4 running, 5 scheduled, 6 blocked, 7 skipped, 8 waiting", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.summary.String() if got != tt.want { t.Errorf("String() = %q, want %q", got, tt.want) } }) } } func TestJob_DisplayName(t *testing.T) { tests := []struct { name string job buildkite.Job want string }{ {"uses Name", buildkite.Job{Name: "lint"}, "lint"}, {"uses Label when no Name", buildkite.Job{Label: "test"}, "test"}, {"falls back to type", buildkite.Job{Type: "script"}, "script step"}, {"Name takes precedence", buildkite.Job{Name: "lint", Label: "test"}, "lint"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := NewFormattedJob(tt.job).DisplayName() if got != tt.want { t.Errorf("DisplayName() = %q, want %q", got, tt.want) } }) } } func TestJob_Duration(t *testing.T) { t.Run("no start time", func(t *testing.T) { d := NewFormattedJob(buildkite.Job{}).Duration() if d != 0 { t.Errorf("expected 0, got %s", d) } }) t.Run("with start and finish", func(t *testing.T) { start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) finish := time.Date(2025, 1, 1, 0, 0, 12, 0, time.UTC) d := NewFormattedJob(buildkite.Job{ StartedAt: &buildkite.Timestamp{Time: start}, FinishedAt: &buildkite.Timestamp{Time: finish}, }).Duration() if d != 12*time.Second { t.Errorf("expected 12s, got %s", d) } }) t.Run("truncates to seconds", func(t *testing.T) { start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) finish := time.Date(2025, 1, 1, 0, 0, 3, 500_000_000, time.UTC) d := NewFormattedJob(buildkite.Job{ StartedAt: &buildkite.Timestamp{Time: start}, FinishedAt: &buildkite.Timestamp{Time: finish}, }).Duration() if d != 3*time.Second { t.Errorf("expected 3s, got %s", d) } }) } ================================================ FILE: internal/build/watch/watch.go ================================================ package watch import ( "context" "errors" "fmt" "net/http" "time" buildstate "github.com/buildkite/cli/v3/internal/build/state" buildkite "github.com/buildkite/go-buildkite/v4" ) const ( // DefaultMaxConsecutiveErrors is the number of consecutive polling failures // before the watch loop aborts. DefaultMaxConsecutiveErrors = 10 // DefaultRequestTimeout is the per-request timeout for each polling call. DefaultRequestTimeout = 30 * time.Second ) // StatusFunc is called on each successful poll with the latest build state. // Returning an error aborts the watch loop and propagates that error to the caller. type StatusFunc func(b buildkite.Build) error // TestStatusFunc is called with newly-seen test changes on each poll. // Returning an error aborts the watch loop. type TestStatusFunc func(newTestChanges []buildkite.BuildTest) error // WatchOpt configures optional WatchBuild behavior. type WatchOpt func(*watchConfig) type watchConfig struct { onTestStatus TestStatusFunc includeRetriedJobs bool } // WithRetriedJobs includes retried (superseded) jobs in each poll so the // tracker can correlate original failures with their retry outcomes. func WithRetriedJobs() WatchOpt { return func(c *watchConfig) { c.includeRetriedJobs = true } } // WithTestTracking enables polling BuildTests.List for failed tests on each // iteration, calling onTestStatus with any newly-seen test changes. func WithTestTracking(fn TestStatusFunc) WatchOpt { return func(c *watchConfig) { c.onTestStatus = fn } } // WatchBuild polls a build until it reaches a terminal state (FinishedAt != nil). // It calls onStatus after each successful poll so callers can render progress. func WatchBuild( ctx context.Context, client *buildkite.Client, org, pipeline string, buildNumber int, interval time.Duration, onStatus StatusFunc, opts ...WatchOpt, ) (buildkite.Build, error) { cfg := &watchConfig{} for _, opt := range opts { opt(cfg) } var testTracker *TestTracker testPollingEnabled := false if cfg.onTestStatus != nil { testTracker = NewTestTracker() testPollingEnabled = true } var ( consecutiveErrors int lastBuild buildkite.Build ) for { if err := ctx.Err(); err != nil { return lastBuild, err } reqCtx, cancel := context.WithTimeout(ctx, DefaultRequestTimeout) var getOpts *buildkite.BuildGetOptions if cfg.includeRetriedJobs { getOpts = &buildkite.BuildGetOptions{ BuildsListOptions: buildkite.BuildsListOptions{ IncludeRetriedJobs: true, }, } } b, _, err := client.Builds.Get(reqCtx, org, pipeline, fmt.Sprint(buildNumber), getOpts) cancel() if err != nil { consecutiveErrors++ if consecutiveErrors >= DefaultMaxConsecutiveErrors { return lastBuild, fmt.Errorf("fetching build status (%d consecutive errors): %w", consecutiveErrors, err) } } else { consecutiveErrors = 0 lastBuild = b if onStatus != nil { if err := onStatus(b); err != nil { return b, err } } if testPollingEnabled && b.ID != "" { enabled, err := pollTestFailures(ctx, client, org, b.ID, testTracker, cfg.onTestStatus) if err != nil { return b, err } testPollingEnabled = enabled } if b.FinishedAt != nil || buildstate.IsTerminal(buildstate.State(b.State)) { return b, nil } } select { case <-ctx.Done(): return lastBuild, ctx.Err() case <-time.After(interval): } } } func pollTestFailures(ctx context.Context, client *buildkite.Client, org, buildID string, tracker *TestTracker, onTestStatus TestStatusFunc) (bool, error) { opts := &buildkite.BuildTestsListOptions{ ListOptions: buildkite.ListOptions{Page: 1, PerPage: 100}, Result: "failed", State: "enabled", Include: "executions", } var newTestChanges []buildkite.BuildTest for { reqCtx, cancel := context.WithTimeout(ctx, DefaultRequestTimeout) tests, resp, err := client.BuildTests.List(reqCtx, org, buildID, opts) cancel() if err != nil { if isPermanentTestPollingError(err) { return false, nil } // Test data may not be available yet; don't treat as fatal. break } newTestChanges = append(newTestChanges, tracker.Update(tests)...) if resp == nil || resp.NextPage == 0 { break } opts.Page = resp.NextPage } if len(newTestChanges) > 0 { return true, onTestStatus(newTestChanges) } return true, nil } func isPermanentTestPollingError(err error) bool { var apiErr *buildkite.ErrorResponse if !errors.As(err, &apiErr) || apiErr.Response == nil { return false } switch apiErr.Response.StatusCode { case http.StatusUnauthorized, http.StatusForbidden: return true default: return false } } ================================================ FILE: internal/build/watch/watch_test.go ================================================ package watch import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestWatchBuild(t *testing.T) { t.Run("polls until finished", func(t *testing.T) { pollCount := 0 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1") { pollCount++ b := buildkite.Build{Number: 1, State: "running"} if pollCount >= 3 { b.State = "passed" b.FinishedAt = &buildkite.Timestamp{Time: now} } json.NewEncoder(w).Encode(b) return } http.NotFound(w, r) })) defer s.Close() client := newTestClient(t, s.URL) var statusCalls int b, err := WatchBuild(context.Background(), client, "org", "pipe", 1, 10*time.Millisecond, func(b buildkite.Build) error { statusCalls++ return nil }) if err != nil { t.Fatalf("unexpected error: %v", err) } if b.State != "passed" { t.Errorf("expected state passed, got %s", b.State) } if pollCount < 3 { t.Errorf("expected at least 3 polls, got %d", pollCount) } if statusCalls < 3 { t.Errorf("expected at least 3 status calls, got %d", statusCalls) } }) t.Run("aborts after consecutive errors", func(t *testing.T) { pollCount := 0 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pollCount++ w.WriteHeader(http.StatusInternalServerError) })) defer s.Close() client := newTestClient(t, s.URL) _, err := WatchBuild(context.Background(), client, "org", "pipe", 1, 10*time.Millisecond, func(b buildkite.Build) error { t.Error("onStatus should not be called on errors") return nil }) if err == nil { t.Fatal("expected error, got nil") } if pollCount < DefaultMaxConsecutiveErrors { t.Errorf("expected at least %d polls, got %d", DefaultMaxConsecutiveErrors, pollCount) } }) t.Run("resets error count on success", func(t *testing.T) { pollCount := 0 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") pollCount++ // Fail for the first few, then succeed if pollCount <= 5 { w.WriteHeader(http.StatusInternalServerError) return } json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "passed", FinishedAt: &buildkite.Timestamp{Time: now}, }) })) defer s.Close() client := newTestClient(t, s.URL) b, err := WatchBuild(context.Background(), client, "org", "pipe", 1, 10*time.Millisecond, func(b buildkite.Build) error { return nil }) if err != nil { t.Fatalf("unexpected error: %v", err) } if b.State != "passed" { t.Errorf("expected state passed, got %s", b.State) } }) t.Run("returns context.DeadlineExceeded on timeout", func(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "running"}) })) defer s.Close() ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() client := newTestClient(t, s.URL) _, err := WatchBuild(ctx, client, "org", "pipe", 1, 10*time.Millisecond, func(b buildkite.Build) error { return nil }) if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("expected context.DeadlineExceeded, got: %v", err) } }) t.Run("returns context.Canceled on explicit cancel", func(t *testing.T) { pollCount := 0 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "running"}) })) defer s.Close() ctx, cancel := context.WithCancel(context.Background()) client := newTestClient(t, s.URL) _, err := WatchBuild(ctx, client, "org", "pipe", 1, 10*time.Millisecond, func(b buildkite.Build) error { pollCount++ if pollCount >= 2 { cancel() } return nil }) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got: %v", err) } }) t.Run("nil onStatus callback", func(t *testing.T) { now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "passed", FinishedAt: &buildkite.Timestamp{Time: now}, }) })) defer s.Close() client := newTestClient(t, s.URL) b, err := WatchBuild(context.Background(), client, "org", "pipe", 1, 10*time.Millisecond, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if b.State != "passed" { t.Errorf("expected state passed, got %s", b.State) } }) t.Run("returns skipped build without finished timestamp", func(t *testing.T) { pollCount := 0 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") pollCount++ json.NewEncoder(w).Encode(buildkite.Build{ Number: 1, State: "skipped", }) })) defer s.Close() client := newTestClient(t, s.URL) b, err := WatchBuild(context.Background(), client, "org", "pipe", 1, 10*time.Millisecond, func(b buildkite.Build) error { return nil }) if err != nil { t.Fatalf("unexpected error: %v", err) } if b.State != "skipped" { t.Errorf("expected state skipped, got %s", b.State) } if pollCount != 1 { t.Errorf("expected 1 poll, got %d", pollCount) } }) t.Run("returns callback error", func(t *testing.T) { callbackErr := errors.New("render failed") s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildkite.Build{Number: 1, State: "running"}) })) defer s.Close() client := newTestClient(t, s.URL) _, err := WatchBuild(context.Background(), client, "org", "pipe", 1, 10*time.Millisecond, func(b buildkite.Build) error { return callbackErr }) if !errors.Is(err, callbackErr) { t.Fatalf("expected callback error, got %v", err) } }) t.Run("disables test polling after authorization failure", func(t *testing.T) { buildPollCount := 0 testPollCount := 0 now := time.Now() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1"): buildPollCount++ b := buildkite.Build{ID: "build-123", Number: 1, State: "running"} if buildPollCount >= 3 { b.State = "passed" b.FinishedAt = &buildkite.Timestamp{Time: now} } json.NewEncoder(w).Encode(b) case r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/build-123/tests"): testPollCount++ w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{"message": "token is missing read_suites"}) default: http.NotFound(w, r) } })) defer s.Close() client := newTestClient(t, s.URL) b, err := WatchBuild( context.Background(), client, "org", "pipe", 1, 10*time.Millisecond, func(buildkite.Build) error { return nil }, WithTestTracking(func([]buildkite.BuildTest) error { return nil }), ) if err != nil { t.Fatalf("unexpected error: %v", err) } if b.State != "passed" { t.Fatalf("expected state passed, got %s", b.State) } if buildPollCount < 3 { t.Fatalf("expected at least 3 build polls, got %d", buildPollCount) } if testPollCount != 1 { t.Fatalf("expected test polling to stop after auth failure, got %d requests", testPollCount) } }) } func TestPollTestFailures(t *testing.T) { t.Run("follows pagination", func(t *testing.T) { var requestedPages []string s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != "GET" || !strings.Contains(r.URL.Path, "/builds/build-123/tests") { http.NotFound(w, r) return } requestedPages = append(requestedPages, r.URL.Query().Get("page")) if got, want := r.URL.Query().Get("include"), "executions"; got != want { t.Fatalf("include = %q, want %q", got, want) } switch r.URL.Query().Get("page") { case "1": w.Header().Set("Link", "</v2/analytics/organizations/org/builds/build-123/tests?page=2&per_page=10>; rel=\"next\"") json.NewEncoder(w).Encode([]buildkite.BuildTest{ {ID: "test-1", Name: "first-page failure", Executions: []buildkite.BuildTestExecution{{ID: "exec-1", Status: "failed"}}}, }) case "2": json.NewEncoder(w).Encode([]buildkite.BuildTest{ {ID: "test-2", Name: "second-page failure", Executions: []buildkite.BuildTestExecution{{ID: "exec-2", Status: "failed"}}}, }) default: t.Fatalf("unexpected page %q", r.URL.Query().Get("page")) } })) defer s.Close() client := newTestClient(t, s.URL) var reported []buildkite.BuildTest enabled, err := pollTestFailures(context.Background(), client, "org", "build-123", NewTestTracker(), func(newTestChanges []buildkite.BuildTest) error { reported = append(reported, newTestChanges...) return nil }) if err != nil { t.Fatalf("unexpected error: %v", err) } if !enabled { t.Fatal("expected test polling to remain enabled") } if got, want := requestedPages, []string{"1", "2"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { t.Fatalf("requested pages = %v, want %v", got, want) } if got, want := len(reported), 2; got != want { t.Fatalf("reported %d test changes, want %d", got, want) } if got, want := reported[1].Name, "second-page failure"; got != want { t.Fatalf("reported second page failure %q, want %q", got, want) } }) t.Run("disables polling on authorization errors", func(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{"message": "token is missing read_suites"}) })) defer s.Close() client := newTestClient(t, s.URL) enabled, err := pollTestFailures(context.Background(), client, "org", "build-123", NewTestTracker(), func([]buildkite.BuildTest) error { t.Fatal("onTestStatus should not be called on authorization errors") return nil }) if err != nil { t.Fatalf("unexpected error: %v", err) } if enabled { t.Fatal("expected test polling to be disabled") } }) } func newTestClient(t *testing.T, baseURL string) *buildkite.Client { t.Helper() client, err := buildkite.NewOpts( buildkite.WithBaseURL(baseURL), buildkite.WithTokenAuth("test-token"), ) if err != nil { t.Fatalf("creating test client: %v", err) } return client } ================================================ FILE: internal/cli/context.go ================================================ package cli type GlobalFlags interface { SkipConfirmation() bool DisableInput() bool IsQuiet() bool DisablePager() bool EnableDebug() bool } type Globals struct { Yes bool NoInput bool Quiet bool NoPager bool Debug bool } func (g Globals) SkipConfirmation() bool { return g.Yes } func (g Globals) DisableInput() bool { return g.NoInput } func (g Globals) IsQuiet() bool { return g.Quiet } func (g Globals) DisablePager() bool { return g.NoPager } func (g Globals) EnableDebug() bool { return g.Debug } ================================================ FILE: internal/cluster/list_queues.graphql ================================================ query GetClusterQueues($orgSlug: ID!, $clusterId: ID!) { organization(slug: $orgSlug) { cluster(id: $clusterId) { name description queues(first: 10) { edges { node { id uuid key description } } } } } } query GetClusterQueueAgent($orgSlug: ID!, $queueId: [ID!]) { organization(slug: $orgSlug) { agents(first: 10, clusterQueue: $queueId) { edges { node { name hostname version id clusterQueue{ id uuid } } } } } } ================================================ FILE: internal/cluster/query.go ================================================ package cluster import ( "context" "sync" "github.com/buildkite/cli/v3/internal/graphql" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" ) func GetQueues(ctx context.Context, f *factory.Factory, orgSlug string, clusterID string, lo *buildkite.ClusterQueuesListOptions) ([]buildkite.ClusterQueue, error) { queues, _, err := f.RestAPIClient.ClusterQueues.List(ctx, orgSlug, clusterID, lo) if err != nil { return nil, err } queuesResponse := make([]buildkite.ClusterQueue, len(queues)) var wg sync.WaitGroup errChan := make(chan error, len(queues)) for i, q := range queues { wg.Add(1) go func(i int, q buildkite.ClusterQueue) { defer wg.Done() queuesResponse[i] = buildkite.ClusterQueue{ CreatedAt: q.CreatedAt, CreatedBy: q.CreatedBy, Description: q.Description, DispatchPaused: q.DispatchPaused, DispatchPausedAt: q.DispatchPausedAt, DispatchPausedBy: q.DispatchPausedBy, DispatchPausedNote: q.DispatchPausedNote, ID: q.ID, Key: q.Key, URL: q.URL, WebURL: q.WebURL, } }(i, q) } go func() { wg.Wait() close(errChan) }() for err := range errChan { if err != nil { return nil, err } } return queuesResponse, nil } func GetQueueAgentCount(ctx context.Context, f *factory.Factory, orgSlug string, queues ...buildkite.ClusterQueue) (int, error) { queueIDs := []string{} for _, q := range queues { queueIDs = append(queueIDs, q.ID) } agent, err := graphql.GetClusterQueueAgent(ctx, f.GraphQLClient, orgSlug, queueIDs) if err != nil { return 0, err } return len(agent.Organization.Agents.Edges), nil } ================================================ FILE: internal/cluster/view.go ================================================ package cluster import ( "fmt" "strings" "time" "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) // ClusterViewTable renders a table view of one or more clusters func ClusterViewTable(c ...buildkite.Cluster) string { if len(c) == 0 { return "No clusters found." } if len(c) == 1 { return renderSingleClusterDetail(c[0]) } rows := make([][]string, 0, len(c)) for _, cluster := range c { rows = append(rows, []string{ output.ValueOrDash(cluster.Name), output.ValueOrDash(cluster.ID), output.ValueOrDash(cluster.DefaultQueueID), }) } return output.Table( []string{"Name", "ID", "Default Queue ID"}, rows, map[string]string{"name": "bold", "id": "dim", "default queue id": "dim"}, ) } func renderSingleClusterDetail(c buildkite.Cluster) string { var sb strings.Builder fmt.Fprintf(&sb, "Viewing %s\n\n", output.ValueOrDash(c.Name)) rows := [][]string{ {"Description", output.ValueOrDash(c.Description)}, {"Color", output.ValueOrDash(c.Color)}, {"Emoji", output.ValueOrDash(c.Emoji)}, {"ID", output.ValueOrDash(c.ID)}, {"GraphQL ID", output.ValueOrDash(c.GraphQLID)}, {"Default Queue ID", output.ValueOrDash(c.DefaultQueueID)}, {"Web URL", output.ValueOrDash(c.WebURL)}, {"API URL", output.ValueOrDash(c.URL)}, {"Queues URL", output.ValueOrDash(c.QueuesURL)}, {"Queue URL", output.ValueOrDash(c.DefaultQueueURL)}, } if c.CreatedBy.ID != "" { rows = append( rows, []string{"Created By Name", output.ValueOrDash(c.CreatedBy.Name)}, []string{"Created By Email", output.ValueOrDash(c.CreatedBy.Email)}, []string{"Created By ID", output.ValueOrDash(c.CreatedBy.ID)}, ) } if c.CreatedAt != nil { rows = append(rows, []string{"Created At", c.CreatedAt.Format(time.RFC3339)}) } table := output.Table( []string{"Field", "Value"}, rows, map[string]string{"field": "dim", "value": "italic"}, ) sb.WriteString(table) return sb.String() } ================================================ FILE: internal/config/config.go ================================================ // Package config contains the configuration for the bk CLI // // Configuration can come from files or environment variables. File based configuration works similar to unix config // file hierarchy where there is a "user" config file found under $HOME, and also a local config in the current // repository root (referred to as "local" config) package config import ( "errors" "fmt" "io" "maps" "os" "path/filepath" "runtime" "slices" "strconv" "strings" "sync" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/pkg/keyring" buildkite "github.com/buildkite/go-buildkite/v4" git "github.com/go-git/go-git/v5" "github.com/goccy/go-yaml" "github.com/spf13/afero" ) var ( legacyTokenWarningOnce sync.Once envTokenWarningOnce sync.Once ) const ( DefaultGraphQLEndpoint = "https://graphql.buildkite.com/v1" // ExperimentPreflight is the experiment flag name for the preflight command. ExperimentPreflight = "preflight" // DefaultExperiments is the comma-separated experiment list enabled out-of-the-box. DefaultExperiments = ExperimentPreflight appData = "AppData" configFilePath = "bk.yaml" localConfigFilePath = "." + configFilePath xdgConfigHome = "XDG_CONFIG_HOME" ) type orgConfig struct { APIToken string `yaml:"api_token,omitempty"` } type fileConfig struct { SelectedOrg string `yaml:"selected_org"` Organizations map[string]orgConfig `yaml:"organizations,omitempty"` Pipelines []string `yaml:"pipelines,omitempty"` NoPager bool `yaml:"no_pager,omitempty"` OutputFormat string `yaml:"output_format,omitempty"` Quiet bool `yaml:"quiet,omitempty"` NoInput bool `yaml:"no_input,omitempty"` Pager string `yaml:"pager,omitempty"` Telemetry *bool `yaml:"telemetry,omitempty"` Experiments string `yaml:"experiments,omitempty"` } // Config contains the configuration for the currently selected organization // to operate on with the CLI application type Config struct { fs afero.Fs userPath string localPath string user fileConfig local fileConfig } func New(fs afero.Fs, repo *git.Repository) *Config { if fs == nil { fs = afero.NewOsFs() } userPath := configFile() localPath := localConfigFilePath if repo != nil { if wt, _ := repo.Worktree(); wt != nil { localPath = filepath.Join(wt.Filesystem.Root(), localConfigFilePath) } } userCfg, userErr := loadFileConfig(fs, userPath) if userErr != nil { fmt.Fprintf(os.Stderr, "warning: failed to read config %s: %v\n", userPath, userErr) } localCfg, localErr := loadFileConfig(fs, localPath) if localErr != nil { fmt.Fprintf(os.Stderr, "warning: failed to read config %s: %v\n", localPath, localErr) } return &Config{ fs: fs, userPath: userPath, localPath: localPath, user: userCfg, local: localCfg, } } // OrganizationSlug gets the slug for the currently selected organization. This can be configured locally or per user. // This will search for configuration in that order. func (conf *Config) OrganizationSlug() string { return firstNonEmpty( os.Getenv("BUILDKITE_ORGANIZATION_SLUG"), conf.local.SelectedOrg, conf.user.SelectedOrg, ) } // SelectOrganization sets the selected organization in the configuration file func (conf *Config) SelectOrganization(org string, inGitRepo bool) error { if !inGitRepo { conf.user.SelectedOrg = org return conf.writeUser() } conf.local.SelectedOrg = org return conf.writeLocal() } // APIToken gets the API token configured for the currently selected organization. // Precedence: environment variable > keyring > config file (legacy, read-only with warning) func (conf *Config) APIToken() string { return conf.APITokenForOrg(conf.OrganizationSlug()) } // APITokenForOrg gets the API token for a specific organization. // Precedence: environment variable > keyring > config file (legacy, read-only with warning) func (conf *Config) APITokenForOrg(org string) string { if token := os.Getenv("BUILDKITE_API_TOKEN"); token != "" { envTokenWarningOnce.Do(func() { fmt.Fprintln(os.Stderr, "Warning: using BUILDKITE_API_TOKEN environment variable for authentication.") }) return token } kr := keyring.New() if kr.IsAvailable() { if token, err := kr.Get(org); err == nil && token != "" { return token } } // Legacy fallback: read tokens from config files (read-only) if token := firstNonEmpty( conf.user.getToken(org), conf.local.getToken(org), ); token != "" { legacyTokenWarningOnce.Do(func() { fmt.Fprintln(os.Stderr, "Warning: reading API token from config file is deprecated. Run `bk auth login` to store your token securely in the system keychain.") }) return token } return "" } // RefreshTokenForOrg gets the refresh token for a specific organization from the keyring. func (conf *Config) RefreshTokenForOrg(org string) string { if org == "" { return "" } kr := keyring.New() if kr.IsAvailable() { if token, err := kr.GetRefreshToken(org); err == nil && token != "" { return token } } return "" } // RefreshToken gets the refresh token for the currently selected organization. func (conf *Config) RefreshToken() string { return conf.RefreshTokenForOrg(conf.OrganizationSlug()) } // HasStoredTokenForOrg reports whether a token is stored for org in keyring // or config files, excluding environment variable overrides. func (conf *Config) HasStoredTokenForOrg(org string) bool { if org == "" { return false } kr := keyring.New() if kr.IsAvailable() { if token, err := kr.Get(org); err == nil && token != "" { return true } } // Legacy fallback: check config files (read-only) return firstNonEmpty( conf.user.getToken(org), conf.local.getToken(org), ) != "" } // EnsureOrganization records an organization in user config without requiring // a token value. This keeps org switching/listing functional for keychain-only // token storage. func (conf *Config) EnsureOrganization(org string) error { if org == "" { return nil } if conf.user.Organizations == nil { conf.user.Organizations = make(map[string]orgConfig) } if _, exists := conf.user.Organizations[org]; exists { return nil } conf.user.Organizations[org] = orgConfig{} return conf.writeUser() } func (conf *Config) ConfiguredOrganizations() []string { orgs := slices.Collect(maps.Keys(conf.user.Organizations)) if o := os.Getenv("BUILDKITE_ORGANIZATION_SLUG"); o != "" { orgs = append(orgs, o) } return orgs } func (conf *Config) GetGraphQLEndpoint() string { value := os.Getenv("BUILDKITE_GRAPHQL_ENDPOINT") if value != "" { return value } return DefaultGraphQLEndpoint } func (conf *Config) RESTAPIEndpoint() string { value := os.Getenv("BUILDKITE_REST_API_ENDPOINT") if value != "" { return value } return buildkite.DefaultBaseURL } func (conf *Config) PagerDisabled() bool { if v, ok := lookupBoolEnv("BUILDKITE_NO_PAGER"); ok { return v } if v, ok := lookupBoolEnv("NO_PAGER"); ok { return v } if conf.local.NoPager { return true } return conf.user.NoPager } func (conf *Config) SetNoPager(v bool, saveLocal bool) error { if !saveLocal { conf.user.NoPager = v return conf.writeUser() } conf.local.NoPager = v return conf.writeLocal() } // OutputFormat returns the configured output format (json, yaml, text). // Precedence: env > local > user > default (json) func (conf *Config) OutputFormat() string { return firstNonEmpty( os.Getenv("BUILDKITE_OUTPUT_FORMAT"), conf.local.OutputFormat, conf.user.OutputFormat, "json", ) } func (conf *Config) SetOutputFormat(v string, saveLocal bool) error { if !saveLocal { conf.user.OutputFormat = v return conf.writeUser() } conf.local.OutputFormat = v return conf.writeLocal() } // Quiet returns whether quiet mode is enabled. // Precedence: env > local > user func (conf *Config) Quiet() bool { if v, ok := lookupBoolEnv("BUILDKITE_QUIET"); ok { return v } if conf.local.Quiet { return true } return conf.user.Quiet } func (conf *Config) SetQuiet(v bool, saveLocal bool) error { if !saveLocal { conf.user.Quiet = v return conf.writeUser() } conf.local.Quiet = v return conf.writeLocal() } // NoInput returns whether interactive input is disabled. // Precedence: env > user (not stored in local config) func (conf *Config) NoInput() bool { if v, ok := lookupBoolEnv("BUILDKITE_NO_INPUT"); ok { return v } return conf.user.NoInput } // SetNoInput sets whether interactive input is disabled (user config only) func (conf *Config) SetNoInput(v bool) error { conf.user.NoInput = v return conf.writeUser() } // Pager returns the configured pager command. // Precedence: PAGER env > user config > default (less -R) func (conf *Config) Pager() string { return firstNonEmpty( os.Getenv("PAGER"), conf.user.Pager, "less -R", ) } // SetPager sets the pager command (user config only) func (conf *Config) SetPager(v string) error { conf.user.Pager = v return conf.writeUser() } // TelemetryEnabled returns whether telemetry is enabled. // Defaults to true if not explicitly set. // Precedence: env > user config func (conf *Config) TelemetryEnabled() bool { if v, ok := lookupBoolEnv("BK_TELEMETRY"); ok { return v } if conf.user.Telemetry != nil { return *conf.user.Telemetry } return true } // SetTelemetry sets whether telemetry is enabled (user config only) func (conf *Config) SetTelemetry(v bool) error { conf.user.Telemetry = &v return conf.writeUser() } // Experiments returns the comma-separated list of enabled experiments. // Precedence: env (even if empty) > user config > default func (conf *Config) Experiments() string { if v, ok := os.LookupEnv("BUILDKITE_EXPERIMENTS"); ok { return v } if conf.user.Experiments != "" { return conf.user.Experiments } return DefaultExperiments } // HasExperiment reports whether the given experiment name is enabled. func (conf *Config) HasExperiment(name string) bool { for _, exp := range strings.Split(conf.Experiments(), ",") { if exp := strings.TrimSpace(exp); exp != "" && exp == name { return true } } return false } // SetExperiments sets the experiments string (user config only) func (conf *Config) SetExperiments(v string) error { conf.user.Experiments = v return conf.writeUser() } func lookupBoolEnv(key string) (bool, bool) { v := os.Getenv(key) if v == "" { return false, false } b, err := strconv.ParseBool(v) if err != nil { return false, false } return b, true } // ClearAllOrganizations removes all organization entries and the selected // organization from the user configuration file. func (conf *Config) ClearAllOrganizations() error { conf.user.Organizations = make(map[string]orgConfig) conf.user.SelectedOrg = "" return conf.writeUser() } func (conf *Config) HasConfiguredOrganization(slug string) bool { return slices.Contains(conf.ConfiguredOrganizations(), slug) } // PreferredPipelines will retrieve the list of pipelines from local configuration func (conf *Config) PreferredPipelines() []pipeline.Pipeline { names := conf.local.Pipelines if len(names) == 0 { return []pipeline.Pipeline{} } pipelines := make([]pipeline.Pipeline, len(names)) for i, v := range names { pipelines[i] = pipeline.Pipeline{ Name: v, Org: conf.OrganizationSlug(), } } return pipelines } // SetPreferredPipelines will write the provided list of pipelines to local configuration func (conf *Config) SetPreferredPipelines(pipelines []pipeline.Pipeline) error { // only save pipelines if they are present if len(pipelines) == 0 { return nil } names := make([]string, len(pipelines)) for i, p := range pipelines { names[i] = p.Name } conf.local.Pipelines = names return conf.writeLocal() } func firstNonEmpty(s ...string) string { for _, k := range s { if k != "" { return k } } return "" } // Config path precedence: XDG_CONFIG_HOME, AppData (windows only), HOME. func configFile() string { var path string if a := os.Getenv(xdgConfigHome); a != "" { path = filepath.Join(a, configFilePath) } else if b := os.Getenv(appData); runtime.GOOS == "windows" && b != "" { path = filepath.Join(b, "Buildkite CLI", configFilePath) } else { c, err := createIfNotExistsConfigDir() if err != nil { return "" } path = filepath.Join(c, configFilePath) } return path } func createIfNotExistsConfigDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } configDir := filepath.Join(homeDir, ".config") if _, err := os.Stat(configDir); errors.Is(err, os.ErrNotExist) { err := os.Mkdir(configDir, 0o755) if err != nil { return "", err } } else if err != nil { // Other error occurred in checking the dir return "", err } return configDir, nil } func loadFileConfig(fs afero.Fs, path string) (fileConfig, error) { cfg := fileConfig{Organizations: make(map[string]orgConfig)} if path == "" { return cfg, nil } file, err := fs.Open(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return cfg, nil } return cfg, err } defer file.Close() content, err := io.ReadAll(file) if err != nil { return cfg, err } if len(content) == 0 { return cfg, nil } if err := yaml.Unmarshal(content, &cfg); err != nil { return cfg, err } if cfg.Organizations == nil { cfg.Organizations = make(map[string]orgConfig) } return cfg, nil } func writeFileConfig(fs afero.Fs, path string, cfg fileConfig) error { if path == "" { return nil } dir := filepath.Dir(path) if err := fs.MkdirAll(dir, 0o755); err != nil { return err } if cfg.Organizations == nil { cfg.Organizations = make(map[string]orgConfig) } data, err := yaml.Marshal(cfg) if err != nil { return err } return afero.WriteFile(fs, path, data, 0o600) } func (cfg fileConfig) getToken(org string) string { if org == "" { return "" } if cfg.Organizations == nil { return "" } if v, ok := cfg.Organizations[org]; ok { return v.APIToken } return "" } func (conf *Config) writeUser() error { return writeFileConfig(conf.fs, conf.userPath, conf.user) } func (conf *Config) writeLocal() error { return writeFileConfig(conf.fs, conf.localPath, conf.local) } ================================================ FILE: internal/config/config_test.go ================================================ package config import ( "os" "path/filepath" "testing" "github.com/buildkite/cli/v3/pkg/keyring" "github.com/spf13/afero" ) func setEnv(t *testing.T, key, value string) { original, had := os.LookupEnv(key) if err := os.Setenv(key, value); err != nil { t.Fatalf("failed to set env %s: %v", key, err) } t.Cleanup(func() { var restoreErr error if had { restoreErr = os.Setenv(key, original) } else { restoreErr = os.Unsetenv(key) } if restoreErr != nil { t.Fatalf("failed to restore env %s: %v", key, restoreErr) } }) } func unsetEnv(t *testing.T, key string) { original, had := os.LookupEnv(key) os.Unsetenv(key) t.Cleanup(func() { if had { os.Setenv(key, original) } }) } func prepareTestDirectory(fs afero.Fs, fixturePath, configPath string) error { // read the content of the fixture config file from the real filesystem in, err := os.ReadFile(filepath.Join("../../fixtures/config", fixturePath)) if err != nil { return err } // create the config file in the afero filesystem err = fs.MkdirAll(filepath.Dir(configPath), os.ModePerm) if err != nil { return err } out, err := fs.Create(configPath) if err != nil { return err } defer out.Close() _, err = out.Write(in) if err != nil { return err } return nil } func TestConfig(t *testing.T) { t.Parallel() t.Run("read in local config", func(t *testing.T) { fs := afero.NewMemMapFs() setEnv(t, "BUILDKITE_ORGANIZATION_SLUG", "") setEnv(t, "BUILDKITE_API_TOKEN", "") err := prepareTestDirectory(fs, "local.basic.yaml", localConfigFilePath) if err != nil { t.Fatal(err) } // try to load configuration conf := New(fs, nil) if got := conf.OrganizationSlug(); got != "buildkite-test" { t.Errorf("OrganizationSlug() does not match: %s", got) } if got := conf.APIToken(); got != "test-token-1234" { t.Errorf("APIToken() does not match: %s", got) } if got := conf.PreferredPipelines(); len(got) != 2 { t.Errorf("PreferredPipelines() does not match: %d", len(got)) } }) t.Run("APITokenForOrg reads legacy tokens from config", func(t *testing.T) { t.Parallel() setEnv(t, "BUILDKITE_API_TOKEN", "") fs := afero.NewMemMapFs() // Write a config with legacy token entries content := []byte("organizations:\n org1:\n api_token: token-org1\n org2:\n api_token: token-org2\n") if err := afero.WriteFile(fs, configFile(), content, 0o600); err != nil { t.Fatal(err) } conf := New(fs, nil) if conf.APITokenForOrg("org1") != "token-org1" { t.Errorf("expected token-org1, got %s", conf.APITokenForOrg("org1")) } if conf.APITokenForOrg("org2") != "token-org2" { t.Errorf("expected token-org2, got %s", conf.APITokenForOrg("org2")) } if conf.APITokenForOrg("nonexistent") != "" { t.Errorf("expected empty token for nonexistent org, got %s", conf.APITokenForOrg("nonexistent")) } }) t.Run("loadFileConfig returns error on invalid yaml", func(t *testing.T) { fs := afero.NewMemMapFs() path := filepath.Join(t.TempDir(), "bk.yaml") if err := afero.WriteFile(fs, path, []byte("selected_org: [oops"), 0o600); err != nil { t.Fatalf("failed to write invalid yaml: %v", err) } _, err := loadFileConfig(fs, path) if err == nil { t.Fatalf("expected error for invalid yaml, got nil") } }) t.Run("loadFileConfig ignores missing file", func(t *testing.T) { fs := afero.NewMemMapFs() _, err := loadFileConfig(fs, "does-not-exist.yaml") if err != nil { t.Fatalf("expected no error for missing file, got %v", err) } }) t.Run("preserves organization name case", func(t *testing.T) { t.Parallel() testCases := []struct { name string orgName string }{ { name: "mixed case organization name", orgName: "gridX", }, { name: "uppercase organization name", orgName: "ACME", }, { name: "lowercase organization name", orgName: "buildkite", }, { name: "camelCase organization name", orgName: "myOrg", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() conf := New(fs, nil) // Register organization if err := conf.EnsureOrganization(tc.orgName); err != nil { t.Fatalf("EnsureOrganization failed: %v", err) } // Select organization (simulate user config scenario) if err := conf.SelectOrganization(tc.orgName, false); err != nil { t.Fatalf("SelectOrganization failed: %v", err) } // Create a new config instance to simulate reading from file conf2 := New(fs, nil) // Verify organization name case is preserved gotOrg := conf2.OrganizationSlug() if gotOrg != tc.orgName { t.Errorf("expected organization slug %q, got %q - case was not preserved", tc.orgName, gotOrg) } }) } }) t.Run("OutputFormat returns correct precedence", func(t *testing.T) { t.Parallel() t.Run("defaults to json", func(t *testing.T) { t.Parallel() setEnv(t, "BUILDKITE_OUTPUT_FORMAT", "") fs := afero.NewMemMapFs() conf := New(fs, nil) if got := conf.OutputFormat(); got != "json" { t.Errorf("OutputFormat() = %q, want %q", got, "json") } }) t.Run("env overrides config", func(t *testing.T) { setEnv(t, "BUILDKITE_OUTPUT_FORMAT", "yaml") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetOutputFormat("text", false) if got := conf.OutputFormat(); got != "yaml" { t.Errorf("OutputFormat() = %q, want %q (env should override)", got, "yaml") } }) t.Run("config value is used", func(t *testing.T) { t.Parallel() setEnv(t, "BUILDKITE_OUTPUT_FORMAT", "") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetOutputFormat("yaml", false) if got := conf.OutputFormat(); got != "yaml" { t.Errorf("OutputFormat() = %q, want %q", got, "yaml") } }) }) t.Run("Quiet returns correct precedence", func(t *testing.T) { t.Parallel() t.Run("defaults to false", func(t *testing.T) { t.Parallel() setEnv(t, "BUILDKITE_QUIET", "") fs := afero.NewMemMapFs() conf := New(fs, nil) if conf.Quiet() { t.Error("Quiet() = true, want false") } }) t.Run("env overrides config", func(t *testing.T) { setEnv(t, "BUILDKITE_QUIET", "true") fs := afero.NewMemMapFs() conf := New(fs, nil) if !conf.Quiet() { t.Error("Quiet() = false, want true (env should override)") } }) }) t.Run("NoInput returns correct precedence", func(t *testing.T) { t.Parallel() t.Run("defaults to false", func(t *testing.T) { t.Parallel() setEnv(t, "BUILDKITE_NO_INPUT", "") fs := afero.NewMemMapFs() conf := New(fs, nil) if conf.NoInput() { t.Error("NoInput() = true, want false") } }) t.Run("env overrides config", func(t *testing.T) { setEnv(t, "BUILDKITE_NO_INPUT", "true") fs := afero.NewMemMapFs() conf := New(fs, nil) if !conf.NoInput() { t.Error("NoInput() = false, want true (env should override)") } }) }) t.Run("Pager returns correct precedence", func(t *testing.T) { t.Parallel() t.Run("defaults to less -R", func(t *testing.T) { t.Parallel() setEnv(t, "PAGER", "") fs := afero.NewMemMapFs() conf := New(fs, nil) if got := conf.Pager(); got != "less -R" { t.Errorf("Pager() = %q, want %q", got, "less -R") } }) t.Run("env overrides config", func(t *testing.T) { setEnv(t, "PAGER", "more") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetPager("vim") if got := conf.Pager(); got != "more" { t.Errorf("Pager() = %q, want %q (env should override)", got, "more") } }) t.Run("config value is used", func(t *testing.T) { t.Parallel() setEnv(t, "PAGER", "") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetPager("vim") if got := conf.Pager(); got != "vim" { t.Errorf("Pager() = %q, want %q", got, "vim") } }) }) } func TestAPITokenForOrgNoKeyring(t *testing.T) { // Ensure BUILDKITE_NO_KEYRING disables keychain access entirely and that // APITokenForOrg falls through to the config file (legacy) path without // attempting to call the OS keychain. setEnv(t, "BUILDKITE_NO_KEYRING", "1") setEnv(t, "CI", "") setEnv(t, "BUILDKITE", "") setEnv(t, "BUILDKITE_API_TOKEN", "") keyring.ResetForTesting() t.Cleanup(keyring.ResetForTesting) fs := afero.NewMemMapFs() content := []byte("organizations:\n my-org:\n api_token: legacy-token\n") if err := afero.WriteFile(fs, configFile(), content, 0o600); err != nil { t.Fatalf("failed to write config: %v", err) } conf := New(fs, nil) // Should return the legacy file token without touching the keychain. if got := conf.APITokenForOrg("my-org"); got != "legacy-token" { t.Errorf("APITokenForOrg() = %q, want %q", got, "legacy-token") } // Keyring must report unavailable. kr := keyring.New() if kr.IsAvailable() { t.Error("expected keyring to be unavailable when BUILDKITE_NO_KEYRING=1") } } func TestExperiments(t *testing.T) { t.Run("defaults to preflight", func(t *testing.T) { unsetEnv(t, "BUILDKITE_EXPERIMENTS") fs := afero.NewMemMapFs() conf := New(fs, nil) if got := conf.Experiments(); got != DefaultExperiments { t.Errorf("Experiments() = %q, want %q", got, DefaultExperiments) } }) t.Run("env overrides config", func(t *testing.T) { setEnv(t, "BUILDKITE_EXPERIMENTS", "alpha") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetExperiments("beta") if got := conf.Experiments(); got != "alpha" { t.Errorf("Experiments() = %q, want %q (env should override)", got, "alpha") } }) t.Run("env empty string does not fall through", func(t *testing.T) { setEnv(t, "BUILDKITE_EXPERIMENTS", "") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetExperiments("beta") if got := conf.Experiments(); got != "" { t.Errorf("Experiments() = %q, want %q (empty env should not fall through)", got, "") } }) t.Run("config overrides the default", func(t *testing.T) { unsetEnv(t, "BUILDKITE_EXPERIMENTS") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetExperiments("beta") if got := conf.Experiments(); got != "beta" { t.Errorf("Experiments() = %q, want %q", got, "beta") } }) t.Run("SetExperiments persists", func(t *testing.T) { unsetEnv(t, "BUILDKITE_EXPERIMENTS") fs := afero.NewMemMapFs() conf := New(fs, nil) if err := conf.SetExperiments("preflight,beta"); err != nil { t.Fatalf("SetExperiments() error: %v", err) } conf2 := New(fs, nil) if got := conf2.Experiments(); got != "preflight,beta" { t.Errorf("Experiments() after reload = %q, want %q", got, "preflight,beta") } }) } func TestHasExperimentEnvOverride(t *testing.T) { t.Run("empty env override disables default experiments", func(t *testing.T) { setEnv(t, "BUILDKITE_EXPERIMENTS", "") fs := afero.NewMemMapFs() conf := New(fs, nil) if conf.HasExperiment(ExperimentPreflight) { t.Errorf("HasExperiment(%q) = true, want false", ExperimentPreflight) } }) } func TestHasExperiment(t *testing.T) { tests := []struct { name string experiments string query string want bool }{ {"preflight defaults on", "", ExperimentPreflight, true}, {"single match", "preflight", "preflight", true}, {"multiple with match", "foo,preflight,bar", "preflight", true}, {"override without match", "foo,bar", "preflight", false}, {"whitespace handling", " preflight , bar ", "preflight", true}, {"other experiments still default off", "", "beta", false}, {"partial name no match", "preflightx", "preflight", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { unsetEnv(t, "BUILDKITE_EXPERIMENTS") fs := afero.NewMemMapFs() conf := New(fs, nil) conf.SetExperiments(tt.experiments) if got := conf.HasExperiment(tt.query); got != tt.want { t.Errorf("HasExperiment(%q) with experiments=%q: got %v, want %v", tt.query, tt.experiments, got, tt.want) } }) } } ================================================ FILE: internal/emoji/emoji.go ================================================ package emoji import ( "regexp" "strings" "sync" "github.com/buildkite/termoji" ) var ( once sync.Once renderer *termoji.Renderer // leadingShortcodes matches one or more :shortcode: tokens (with // optional whitespace between them) anchored at the start of the string. leadingShortcodes = regexp.MustCompile(`^(:[[:alnum:]_.+-]+:\s*)+`) ) func getRenderer() *termoji.Renderer { once.Do(func() { r, err := termoji.New(termoji.Options{}) if err != nil { return } renderer = r }) return renderer } // Render expands emoji shortcodes in text using termoji. Standard // Unicode emoji (e.g. :checkered_flag:) are converted to their Unicode // code points on all terminals. Buildkite custom emoji (e.g. :docker:) // are rendered as inline images on terminals that support the iTerm2 or // Kitty graphics protocol; on other terminals they are left unchanged. // // Because inline-image escape sequences embed foreground-colour resets, // callers should not wrap the result in ANSI foreground styling (e.g. // lipgloss). For coloured output, use [Split] to separate the emoji // prefix so it can be rendered outside the colour span. func Render(text string) string { if r := getRenderer(); r != nil { return r.Render(text) } return text } // Split separates leading emoji shortcodes from the rest of the text. // Whitespace between the emoji and text is trimmed from both sides so // callers can control spacing consistently. // // Split(":docker: Build image") → (":docker:", "Build image") // Split(":docker: :go: Build") → (":docker: :go:", "Build") // Split("Build image") → ("", "Build image") // Split(":pipeline:") → (":pipeline:", "") // // This is useful for coloured output: render the prefix with [Render] // outside the ANSI colour span, and style only the rest. func Split(text string) (prefix, rest string) { loc := leadingShortcodes.FindStringIndex(text) if loc == nil { return "", text } return strings.TrimRight(text[:loc[1]], " \t"), text[loc[1]:] } ================================================ FILE: internal/emoji/emoji_test.go ================================================ package emoji import ( "testing" ) func TestRender_standardEmoji(t *testing.T) { got := Render(":checkered_flag: Feature flags") want := "🏁 Feature flags" if got != want { t.Errorf("Render(%q) = %q, want %q", ":checkered_flag: Feature flags", got, want) } } func TestRender_plainText(t *testing.T) { got := Render("just plain text") if got != "just plain text" { t.Errorf("Render(%q) = %q, want unchanged", "just plain text", got) } } func TestSplit(t *testing.T) { tests := []struct { name string input string wantPrefix string wantRest string }{ { name: "single shortcode with text", input: ":docker: Build image", wantPrefix: ":docker:", wantRest: "Build image", }, { name: "multiple shortcodes", input: ":docker: :golang: Build", wantPrefix: ":docker: :golang:", wantRest: "Build", }, { name: "no shortcodes", input: "Build image", wantPrefix: "", wantRest: "Build image", }, { name: "shortcode only", input: ":pipeline:", wantPrefix: ":pipeline:", wantRest: "", }, { name: "shortcode with hyphen", input: ":golangci-lint: lint", wantPrefix: ":golangci-lint:", wantRest: "lint", }, { name: "shortcode in middle not matched", input: "Build :docker: image", wantPrefix: "", wantRest: "Build :docker: image", }, { name: "empty string", input: "", wantPrefix: "", wantRest: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prefix, rest := Split(tt.input) if prefix != tt.wantPrefix || rest != tt.wantRest { t.Errorf("Split(%q) = (%q, %q), want (%q, %q)", tt.input, prefix, rest, tt.wantPrefix, tt.wantRest) } }) } } ================================================ FILE: internal/errors/README.md ================================================ # Error Handling Package This package provides standardized error handling for the Buildkite CLI. It handles different types of errors consistently, provides helpful error messages and suggestions to users, and ensures a unified approach to error reporting across the application. ## Features - **Categorized Errors**: Different error types (validation, API, not found, etc.) with specific handling - **Contextual Error Messages**: Errors include context about what operation failed - **Helpful Suggestions**: Error messages include suggestions on how to fix the issue - **Command Integration**: Easy integration with Kong commands - **API Error Handling**: Specialized handling for API errors with status code interpretation - **Exit Codes**: Appropriate exit codes for different error types ## Usage ### Creating Errors ```go import ( bkErrors "github.com/buildkite/cli/v3/internal/errors" ) // Create various types of errors validationErr := bkErrors.NewValidationError( err, // Original error (can be nil) "Invalid input", // Details about the error "Try using a different value", // Suggestions (optional) "Check the documentation for valid options" // More suggestions ) apiErr := bkErrors.NewAPIError(err, "API request failed") notFoundErr := bkErrors.NewResourceNotFoundError(err, "Resource not found") ``` ### Adding Context to Errors ```go // Add suggestions to an existing error err = bkErrors.WithSuggestions(err, "Try this instead", "Or try this other option" ) // Add details to an existing error err = bkErrors.WithDetails(err, "Additional context") ``` ### Checking Error Types ```go if bkErrors.IsNotFound(err) { // Handle not found error } if bkErrors.IsValidationError(err) { // Handle validation error } if bkErrors.IsAPIError(err) { // Handle API error } ``` ### Wrapping API Errors ```go // Wrap HTTP errors with appropriate context err = bkErrors.WrapAPIError(err, "fetching pipeline") ``` ### Command Integration ```go // Wrap a command's RunE function with standard error handling cmd := &cobra.Command{ RunE: bkErrors.WrapRunE(func(cmd *cobra.Command, args []string) error { // Command implementation return err }), } ``` ### Using the Error Handler ```go // Create an error handler handler := bkErrors.NewHandler(). WithVerbose(verbose). WithWriter(os.Stderr) // Handle an error handler.Handle(err) // Handle an error with operation context handler.HandleWithDetails(err, "creating resource") // Print a warning handler.PrintWarning("Something might be wrong: %s", details) ``` ### Using the Command Error Handler ```go // In main.go: func main() { rootCmd, _ := root.NewCmdRoot(f) // Execute with error handling bkErrors.ExecuteWithErrorHandling(rootCmd, verbose) } ``` ================================================ FILE: internal/errors/api.go ================================================ package errors import ( "encoding/json" "fmt" "net/http" "strings" httpClient "github.com/buildkite/cli/v3/internal/http" ) // APIErrorResponse represents a Buildkite API error response type APIErrorResponse struct { Message string `json:"message"` Errors []string `json:"errors,omitempty"` Details map[string]string `json:"details,omitempty"` } // WrapAPIError wraps an API error with appropriate context and suggestions func WrapAPIError(err error, operation string) error { if err == nil { return nil } // If it's already a CLI error, add context but preserve the category if cliErr, ok := err.(*Error); ok { if operation != "" { cliErr.Details = fmt.Sprintf("%s: %s", operation, cliErr.Details) } return cliErr } // Handle HTTP client errors if httpErr, ok := err.(*httpClient.ErrorResponse); ok { return handleHTTPError(httpErr, operation) } // For all other errors, wrap as a generic API error return NewAPIError(err, fmt.Sprintf("API request failed during: %s", operation)) } // handleHTTPError processes an HTTP error and creates an appropriate CLI error func handleHTTPError(httpErr *httpClient.ErrorResponse, operation string) error { statusCode := httpErr.StatusCode details := fmt.Sprintf("%s failed with status %d", operation, statusCode) // Try to parse the response body as JSON var apiErr APIErrorResponse if len(httpErr.Body) > 0 { if err := json.Unmarshal(httpErr.Body, &apiErr); err == nil { // Successfully parsed API error if apiErr.Message != "" { details = fmt.Sprintf("%s: %s", details, apiErr.Message) } } else { // If not JSON, include the raw body if len(httpErr.Body) > 0 { bodyStr := string(httpErr.Body) if len(bodyStr) > 200 { bodyStr = bodyStr[:200] + "..." } details = fmt.Sprintf("%s: %s", details, bodyStr) } } } // Create appropriate error based on status code var err error switch { case statusCode == http.StatusNotFound: err = NewResourceNotFoundError(httpErr, details, suggestForNotFound(httpErr.URL)...) case statusCode == http.StatusUnauthorized: err = NewAuthenticationError(httpErr, details, "Check your API token in the configuration", "Run 'bk configure' to set up your token correctly") case statusCode == http.StatusForbidden: err = NewPermissionDeniedError(httpErr, details, "Verify that your API token has the required scopes", "You may need to create a new token with additional permissions") case statusCode == http.StatusBadRequest: err = handleBadRequestError(httpErr, details, apiErr) case statusCode >= 500: err = NewAPIError(httpErr, details, "This appears to be a server-side error", "Try again later or check the Buildkite status page") default: err = NewAPIError(httpErr, details) } return err } // handleBadRequestError processes a 400 Bad Request error func handleBadRequestError(httpErr *httpClient.ErrorResponse, details string, apiErr APIErrorResponse) error { suggestions := []string{} // Add specific errors as suggestions if len(apiErr.Errors) > 0 { suggestions = append(suggestions, apiErr.Errors...) } else { suggestions = append(suggestions, "Check the request parameters for invalid values") } // Add field-specific errors if len(apiErr.Details) > 0 { for field, msg := range apiErr.Details { suggestions = append(suggestions, fmt.Sprintf("%s: %s", field, msg)) } } return NewValidationError(httpErr, details, suggestions...) } // suggestForNotFound generates suggestions for a 404 Not Found error func suggestForNotFound(url string) []string { suggestions := []string{ "Check that the resource exists and you have access to it", } // Add more specific suggestions based on the URL pattern if strings.Contains(url, "/pipelines/") { suggestions = append(suggestions, "Verify the pipeline slug is correct") } else if strings.Contains(url, "/builds/") { suggestions = append(suggestions, "Verify the build number is correct") } else if strings.Contains(url, "/artifacts/") { suggestions = append(suggestions, "Verify the artifact ID is correct") } else if strings.Contains(url, "/agents/") { suggestions = append(suggestions, "Verify the agent ID is correct") } return suggestions } ================================================ FILE: internal/errors/api_test.go ================================================ package errors import ( "encoding/json" "testing" httpClient "github.com/buildkite/cli/v3/internal/http" ) func TestWrapAPIError(t *testing.T) { t.Parallel() t.Run("handles nil error", func(t *testing.T) { t.Parallel() result := WrapAPIError(nil, "test operation") if result != nil { t.Errorf("Expected nil, got %v", result) } }) t.Run("preserves CLI error category", func(t *testing.T) { t.Parallel() original := NewValidationError(nil, "Invalid input") result := WrapAPIError(original, "test operation") if !IsValidationError(result) { t.Error("Expected validation error category to be preserved") } }) t.Run("adds operation context to CLI error", func(t *testing.T) { t.Parallel() original := NewValidationError(nil, "Invalid input") result := WrapAPIError(original, "test operation") cliErr, ok := result.(*Error) if !ok { t.Fatal("Expected result to be a *Error") } if cliErr.Details != "test operation: Invalid input" { t.Errorf("Expected details to include operation, got: %q", cliErr.Details) } }) t.Run("wraps generic error as API error", func(t *testing.T) { t.Parallel() original := &simpleError{message: "something went wrong"} result := WrapAPIError(original, "test operation") if !IsAPIError(result) { t.Error("Expected generic error to be wrapped as API error") } }) } func TestHandleHTTPError(t *testing.T) { t.Parallel() t.Run("handles 404 not found", func(t *testing.T) { t.Parallel() httpErr := &httpClient.ErrorResponse{ StatusCode: 404, Status: "Not Found", URL: "https://api.buildkite.com/v2/pipelines/missing", Body: []byte(`{"message":"Pipeline not found"}`), } result := handleHTTPError(httpErr, "get pipeline") if !IsNotFound(result) { t.Error("Expected result to be a not found error") } // Check for pipeline-specific suggestion cliErr, ok := result.(*Error) if !ok { t.Fatal("Expected result to be a *Error") } foundSuggestion := false for _, suggestion := range cliErr.Suggestions { if suggestion == "Verify the pipeline slug is correct" { foundSuggestion = true break } } if !foundSuggestion { t.Error("Expected pipeline-specific suggestion for not found error") } }) t.Run("handles 401 unauthorized", func(t *testing.T) { t.Parallel() httpErr := &httpClient.ErrorResponse{ StatusCode: 401, Status: "Unauthorized", URL: "https://api.buildkite.com/v2/user", Body: []byte(`{"message":"Unauthorized"}`), } result := handleHTTPError(httpErr, "get user") if !IsAuthenticationError(result) { t.Error("Expected result to be an authentication error") } // Check for token suggestion cliErr, ok := result.(*Error) if !ok { t.Fatal("Expected result to be a *Error") } foundSuggestion := false for _, suggestion := range cliErr.Suggestions { if suggestion == "Check your API token in the configuration" { foundSuggestion = true break } } if !foundSuggestion { t.Error("Expected token-specific suggestion for unauthorized error") } }) t.Run("handles 403 forbidden", func(t *testing.T) { t.Parallel() httpErr := &httpClient.ErrorResponse{ StatusCode: 403, Status: "Forbidden", URL: "https://api.buildkite.com/v2/builds", Body: []byte(`{"message":"Forbidden"}`), } result := handleHTTPError(httpErr, "cancel build") if !IsPermissionDeniedError(result) { t.Error("Expected result to be a permission denied error") } }) t.Run("handles 400 bad request with field errors", func(t *testing.T) { t.Parallel() apiErr := APIErrorResponse{ Message: "Invalid request", Details: map[string]string{ "name": "cannot be blank", "url": "invalid format", }, } body, _ := json.Marshal(apiErr) httpErr := &httpClient.ErrorResponse{ StatusCode: 400, Status: "Bad Request", URL: "https://api.buildkite.com/v2/pipelines", Body: body, } result := handleHTTPError(httpErr, "create pipeline") if !IsValidationError(result) { t.Error("Expected result to be a validation error") } // Check that field errors are included in suggestions cliErr, ok := result.(*Error) if !ok { t.Fatal("Expected result to be a *Error") } foundNameError := false foundURLError := false for _, suggestion := range cliErr.Suggestions { if suggestion == "name: cannot be blank" { foundNameError = true } if suggestion == "url: invalid format" { foundURLError = true } } if !foundNameError || !foundURLError { t.Error("Expected field-specific errors to be included in suggestions") } }) t.Run("handles 500 server error", func(t *testing.T) { t.Parallel() httpErr := &httpClient.ErrorResponse{ StatusCode: 500, Status: "Internal Server Error", URL: "https://api.buildkite.com/v2/builds", Body: []byte(`{"message":"Internal server error"}`), } result := handleHTTPError(httpErr, "list builds") if !IsAPIError(result) { t.Error("Expected result to be an API error") } // Check for server error suggestion cliErr, ok := result.(*Error) if !ok { t.Fatal("Expected result to be a *Error") } foundSuggestion := false for _, suggestion := range cliErr.Suggestions { if suggestion == "This appears to be a server-side error" { foundSuggestion = true break } } if !foundSuggestion { t.Error("Expected server error suggestion") } }) } // simpleError is a simple implementation of the error interface for testing type simpleError struct { message string } func (e *simpleError) Error() string { return e.message } ================================================ FILE: internal/errors/errors.go ================================================ package errors import ( "errors" "fmt" "strings" ) // Standard error types that can be used to categorize errors var ( // ErrConfiguration indicates an error in the user's configuration ErrConfiguration = errors.New("configuration error") // ErrValidation indicates invalid input from the user ErrValidation = errors.New("validation error") // ErrAPI indicates an error from the Buildkite API ErrAPI = errors.New("API error") // ErrResourceNotFound indicates a requested resource was not found ErrResourceNotFound = errors.New("resource not found") // ErrPermissionDenied indicates the user lacks permission ErrPermissionDenied = errors.New("permission denied") // ErrAuthentication indicates an issue with authentication ErrAuthentication = errors.New("authentication error") // ErrInternal indicates an internal error in the CLI ErrInternal = errors.New("internal error") // ErrSnapshot indicates an error creating a preflight snapshot ErrSnapshot = errors.New("snapshot error") // ErrUserAborted indicates the user has canceled an operation ErrUserAborted = errors.New("user aborted") // ErrPreflightCompletedFailure indicates a preflight build reached a terminal failed outcome. ErrPreflightCompletedFailure = errors.New("preflight completed with failure") // ErrPreflightIncompleteFailure indicates a preflight build has observed failures but is not terminal yet. ErrPreflightIncompleteFailure = errors.New("preflight incomplete (failing)") // ErrPreflightIncomplete indicates a preflight build is still in progress. ErrPreflightIncomplete = errors.New("preflight incomplete") // ErrPreflightUnknown indicates a preflight build returned an unknown result. ErrPreflightUnknown = errors.New("preflight result unknown") ) // Error represents a CLI error with context type Error struct { // Original is the underlying error Original error // Category is the broad category of the error Category error // Details contains additional detail about the error Details string // Suggestions provides hints on how to fix the error Suggestions []string } // Error implements the error interface func (e *Error) Error() string { var msg strings.Builder if e.Category != nil { msg.WriteString(e.Category.Error()) msg.WriteString(": ") } // First include the original error, if present if e.Original != nil { msg.WriteString(e.Original.Error()) } // Then include details if present, regardless of whether Original is present if e.Details != "" { // Only add a separator if we've already written something if e.Original != nil { msg.WriteString(" (") msg.WriteString(e.Details) msg.WriteString(")") } else { msg.WriteString(e.Details) } } return msg.String() } // FormattedError returns a formatted multi-line error message suitable for display func (e *Error) FormattedError() string { var msg strings.Builder // Build the main error message if e.Category != nil { // Write category with uppercase first letter category := e.Category.Error() if len(category) > 0 { msg.WriteString(strings.ToUpper(category[:1]) + category[1:]) msg.WriteString(": ") } } if e.Original != nil { msg.WriteString(e.Original.Error()) } else if e.Details != "" { msg.WriteString(e.Details) } // Add detailed suggestions if available if len(e.Suggestions) > 0 { msg.WriteString("\n\n") for i, suggestion := range e.Suggestions { if i > 0 { msg.WriteString("\n") } msg.WriteString("• ") msg.WriteString(suggestion) } } return msg.String() } // Unwrap implements the errors.Unwrap interface to allow using errors.Is and errors.As func (e *Error) Unwrap() error { if e.Original != nil { return e.Original } return e.Category } // Is implements the errors.Is interface to allow checking error types func (e *Error) Is(target error) bool { return errors.Is(e.Category, target) || (e.Original != nil && errors.Is(e.Original, target)) } // NewError creates a new Error with the given attributes func NewError(original error, category error, details string, suggestions ...string) *Error { return &Error{ Original: original, Category: category, Details: details, Suggestions: suggestions, } } // WithSuggestions adds suggestions to an existing error func WithSuggestions(err error, suggestions ...string) error { if cliErr, ok := err.(*Error); ok { cliErr.Suggestions = append(cliErr.Suggestions, suggestions...) return cliErr } // If it's not already a CLI error, create a new one return NewError(err, nil, "", suggestions...) } // WithDetails adds details to an existing error func WithDetails(err error, details string) error { if cliErr, ok := err.(*Error); ok { if cliErr.Details == "" { cliErr.Details = details } else { cliErr.Details = fmt.Sprintf("%s: %s", cliErr.Details, details) } return cliErr } // If it's not already a CLI error, create a new one return NewError(err, nil, details) } // NewConfigurationError creates a new configuration error func NewConfigurationError(err error, details string, suggestions ...string) error { return NewError(err, ErrConfiguration, details, suggestions...) } // NewValidationError creates a new validation error func NewValidationError(err error, details string, suggestions ...string) error { return NewError(err, ErrValidation, details, suggestions...) } // NewAPIError creates a new API error func NewAPIError(err error, details string, suggestions ...string) error { return NewError(err, ErrAPI, details, suggestions...) } // NewResourceNotFoundError creates a new resource not found error func NewResourceNotFoundError(err error, details string, suggestions ...string) error { return NewError(err, ErrResourceNotFound, details, suggestions...) } // NewPermissionDeniedError creates a new permission denied error func NewPermissionDeniedError(err error, details string, suggestions ...string) error { return NewError(err, ErrPermissionDenied, details, suggestions...) } // NewAuthenticationError creates a new authentication error func NewAuthenticationError(err error, details string, suggestions ...string) error { return NewError(err, ErrAuthentication, details, suggestions...) } // NewInternalError creates a new internal error func NewInternalError(err error, details string, suggestions ...string) error { return NewError(err, ErrInternal, details, suggestions...) } // NewSnapshotError creates a new snapshot error func NewSnapshotError(err error, details string, suggestions ...string) error { return NewError(err, ErrSnapshot, details, suggestions...) } // NewUserAbortedError creates a new user aborted error func NewUserAbortedError(err error, details string, suggestions ...string) error { return NewError(err, ErrUserAborted, details, suggestions...) } // NewPreflightCompletedFailureError creates a new completed preflight failure error. func NewPreflightCompletedFailureError(err error, details string, suggestions ...string) error { return NewError(err, ErrPreflightCompletedFailure, details, suggestions...) } // NewPreflightIncompleteFailureError creates a new active preflight failure error. func NewPreflightIncompleteFailureError(err error, details string, suggestions ...string) error { return NewError(err, ErrPreflightIncompleteFailure, details, suggestions...) } // NewPreflightIncompleteError creates a new incomplete preflight error. func NewPreflightIncompleteError(err error, details string, suggestions ...string) error { return NewError(err, ErrPreflightIncomplete, details, suggestions...) } // NewPreflightUnknownError creates a new unknown preflight result error. func NewPreflightUnknownError(err error, details string, suggestions ...string) error { return NewError(err, ErrPreflightUnknown, details, suggestions...) } // IsNotFound returns true if the error indicates a resource was not found func IsNotFound(err error) bool { return errors.Is(err, ErrResourceNotFound) } // IsValidationError returns true if the error indicates a validation failure func IsValidationError(err error) bool { return errors.Is(err, ErrValidation) } // IsAPIError returns true if the error indicates an API failure func IsAPIError(err error) bool { return errors.Is(err, ErrAPI) } // IsAuthenticationError returns true if the error indicates an authentication failure func IsAuthenticationError(err error) bool { return errors.Is(err, ErrAuthentication) } // IsPermissionDeniedError returns true if the error indicates a permission issue func IsPermissionDeniedError(err error) bool { return errors.Is(err, ErrPermissionDenied) } // IsConfigurationError returns true if the error indicates a configuration issue func IsConfigurationError(err error) bool { return errors.Is(err, ErrConfiguration) } // IsPreflightCompletedFailure returns true if the error indicates a terminal preflight failure. func IsPreflightCompletedFailure(err error) bool { return errors.Is(err, ErrPreflightCompletedFailure) } // IsPreflightIncompleteFailure returns true if the error indicates an incomplete preflight failure. func IsPreflightIncompleteFailure(err error) bool { return errors.Is(err, ErrPreflightIncompleteFailure) } // IsPreflightIncomplete returns true if the error indicates an incomplete preflight build. func IsPreflightIncomplete(err error) bool { return errors.Is(err, ErrPreflightIncomplete) } // IsPreflightUnknown returns true if the error indicates an unknown preflight result. func IsPreflightUnknown(err error) bool { return errors.Is(err, ErrPreflightUnknown) } // IsUserAborted returns true if the error indicates the user aborted the operation func IsUserAborted(err error) bool { return errors.Is(err, ErrUserAborted) } ================================================ FILE: internal/errors/errors_test.go ================================================ package errors import ( "errors" "fmt" "strings" "testing" ) func TestErrorInterface(t *testing.T) { t.Parallel() t.Run("implements error interface", func(t *testing.T) { t.Parallel() originalErr := fmt.Errorf("original error") err := NewError(originalErr, ErrAPI, "additional details") // Check that Error() returns a non-empty string if err.Error() == "" { t.Error("Error() should return a non-empty string") } // Check that the error string contains both the category and original error errStr := err.Error() if !strings.Contains(errStr, "API error") { t.Errorf("Error string %q should contain category 'API error'", errStr) } if !strings.Contains(errStr, "original error") { t.Errorf("Error string %q should contain original error message", errStr) } }) t.Run("formatted error includes suggestions", func(t *testing.T) { t.Parallel() originalErr := fmt.Errorf("resource not found") suggestions := []string{"Check the resource name", "Verify you have access"} err := NewError(originalErr, ErrResourceNotFound, "failed to get resource", suggestions...) formatted := err.FormattedError() for _, suggestion := range suggestions { if !strings.Contains(formatted, suggestion) { t.Errorf("Formatted error should contain suggestion %q, got: %q", suggestion, formatted) } } }) } func TestErrorCategorization(t *testing.T) { t.Parallel() t.Run("errors.Is works with standard error types", func(t *testing.T) { t.Parallel() apiErr := NewAPIError(nil, "API request failed") if !errors.Is(apiErr, ErrAPI) { t.Error("errors.Is should identify API error category") } validationErr := NewValidationError(nil, "Invalid input") if !errors.Is(validationErr, ErrValidation) { t.Error("errors.Is should identify validation error category") } }) t.Run("error type checking functions work", func(t *testing.T) { t.Parallel() apiErr := NewAPIError(nil, "API request failed") if !IsAPIError(apiErr) { t.Error("IsAPIError should return true for API errors") } validationErr := NewValidationError(nil, "Invalid input") if !IsValidationError(validationErr) { t.Error("IsValidationError should return true for validation errors") } notFoundErr := NewResourceNotFoundError(nil, "Resource not found") if !IsNotFound(notFoundErr) { t.Error("IsNotFound should return true for not found errors") } }) } func TestErrorWrapping(t *testing.T) { t.Parallel() t.Run("WithSuggestions adds suggestions", func(t *testing.T) { t.Parallel() originalErr := NewValidationError(nil, "Invalid input") errWithSuggestions := WithSuggestions(originalErr, "Try this instead", "Or this") // Verify that it's still a validation error if !IsValidationError(errWithSuggestions) { t.Error("Error category should be preserved when adding suggestions") } // Verify suggestions were added cliErr, ok := errWithSuggestions.(*Error) if !ok { t.Fatal("WithSuggestions should return a *Error") } if len(cliErr.Suggestions) != 2 { t.Errorf("Expected 2 suggestions, got %d", len(cliErr.Suggestions)) } }) t.Run("WithDetails adds details", func(t *testing.T) { t.Parallel() originalErr := NewAPIError(nil, "API request failed") errWithDetails := WithDetails(originalErr, "Additional context") // Verify that it's still an API error if !IsAPIError(errWithDetails) { t.Error("Error category should be preserved when adding details") } // Verify details were added cliErr, ok := errWithDetails.(*Error) if !ok { t.Fatal("WithDetails should return a *Error") } if !strings.Contains(cliErr.Details, "Additional context") { t.Errorf("Details should contain added context, got %q", cliErr.Details) } }) t.Run("Unwrap returns original error", func(t *testing.T) { t.Parallel() originalErr := fmt.Errorf("original error") wrappedErr := NewAPIError(originalErr, "API request failed") unwrappedErr := errors.Unwrap(wrappedErr) if unwrappedErr != originalErr { t.Errorf("Unwrap should return original error, got %v", unwrappedErr) } }) t.Run("Unwrap with nil original returns category", func(t *testing.T) { t.Parallel() wrappedErr := NewAPIError(nil, "API request failed") unwrappedErr := errors.Unwrap(wrappedErr) if unwrappedErr != ErrAPI { t.Errorf("Unwrap should return category when original is nil, got %v", unwrappedErr) } }) t.Run("errors.Is works with original error", func(t *testing.T) { t.Parallel() originalErr := fmt.Errorf("not found: resource does not exist") wrappedErr := NewResourceNotFoundError(originalErr, "Could not find resource") // Should match both the category and the original error if !errors.Is(wrappedErr, ErrResourceNotFound) { t.Error("errors.Is should match error category") } if !errors.Is(wrappedErr, originalErr) { t.Error("errors.Is should match original error") } }) } func TestErrorCreationHelpers(t *testing.T) { t.Parallel() testCases := []struct { name string createFunc func(error, string, ...string) error errorType error checkFunc func(error) bool }{ { name: "NewConfigurationError", createFunc: NewConfigurationError, errorType: ErrConfiguration, checkFunc: IsConfigurationError, }, { name: "NewValidationError", createFunc: NewValidationError, errorType: ErrValidation, checkFunc: IsValidationError, }, { name: "NewAPIError", createFunc: NewAPIError, errorType: ErrAPI, checkFunc: IsAPIError, }, { name: "NewResourceNotFoundError", createFunc: NewResourceNotFoundError, errorType: ErrResourceNotFound, checkFunc: IsNotFound, }, { name: "NewPermissionDeniedError", createFunc: NewPermissionDeniedError, errorType: ErrPermissionDenied, checkFunc: IsPermissionDeniedError, }, { name: "NewAuthenticationError", createFunc: NewAuthenticationError, errorType: ErrAuthentication, checkFunc: IsAuthenticationError, }, { name: "NewUserAbortedError", createFunc: NewUserAbortedError, errorType: ErrUserAborted, checkFunc: IsUserAborted, }, } for _, tc := range testCases { tc := tc // Capture test case value t.Run(tc.name, func(t *testing.T) { t.Parallel() originalErr := fmt.Errorf("some error") details := "detailed error message" suggestion := "helpful suggestion" err := tc.createFunc(originalErr, details, suggestion) // Check that the error has the right category if !errors.Is(err, tc.errorType) { t.Errorf("Error should be of type %v", tc.errorType) } // Check that the error type check function works if !tc.checkFunc(err) { t.Errorf("Type check function should return true") } // Check that details and suggestions are included cliErr, ok := err.(*Error) if !ok { t.Fatal("Error should be a *Error") } if cliErr.Details != details { t.Errorf("Expected details %q, got %q", details, cliErr.Details) } if len(cliErr.Suggestions) != 1 || cliErr.Suggestions[0] != suggestion { t.Errorf("Expected suggestion %q, got %v", suggestion, cliErr.Suggestions) } }) } } ================================================ FILE: internal/errors/handler.go ================================================ package errors import ( "errors" "fmt" "io" "os" ) // Exit codes for different error types const ( ExitCodeSuccess = 0 ExitCodeGenericError = 1 ExitCodeValidationError = 2 ExitCodeAPIError = 3 ExitCodeNotFoundError = 4 ExitCodePermissionError = 5 ExitCodeConfigError = 6 ExitCodeAuthError = 7 ExitCodeInternalError = 8 ExitCodePreflightCompletedFailure = 9 ExitCodePreflightIncompleteFailure = 10 ExitCodePreflightIncomplete = 11 ExitCodePreflightUnknown = 12 ExitCodeUserAbortedError = 130 // Same as Ctrl+C in bash ) // Handler processes errors from commands and formats them appropriately type Handler struct { // Writer is where error messages will be written Writer io.Writer // ExitFunc is the function called to exit the program with a specific code ExitFunc func(int) // Verbose enables more detailed error messages Verbose bool } // NewHandler creates a new Handler with default settings func NewHandler() *Handler { return &Handler{ Writer: os.Stderr, ExitFunc: os.Exit, Verbose: false, } } // WithWriter sets the writer for error output func (h *Handler) WithWriter(w io.Writer) *Handler { h.Writer = w return h } // WithExitFunc sets the exit function func (h *Handler) WithExitFunc(f func(int)) *Handler { h.ExitFunc = f return h } // WithVerbose sets the verbose flag func (h *Handler) WithVerbose(v bool) *Handler { h.Verbose = v return h } // Handle processes an error and outputs it appropriately func (h *Handler) Handle(err error) { if err == nil { return } // Get the exit code based on error type exitCode := h.getExitCode(err) // Format the error message message := h.formatError(err) // Write the error message fmt.Fprintln(h.Writer, message) // Call the exit function with the appropriate code if h.ExitFunc != nil { h.ExitFunc(exitCode) } } // getExitCode determines the appropriate exit code based on the error type func (h *Handler) getExitCode(err error) int { switch { case IsValidationError(err): return ExitCodeValidationError case IsAPIError(err): return ExitCodeAPIError case IsNotFound(err): return ExitCodeNotFoundError case IsPermissionDeniedError(err): return ExitCodePermissionError case IsConfigurationError(err): return ExitCodeConfigError case IsAuthenticationError(err): return ExitCodeAuthError case IsPreflightCompletedFailure(err): return ExitCodePreflightCompletedFailure case IsPreflightIncompleteFailure(err): return ExitCodePreflightIncompleteFailure case IsPreflightIncomplete(err): return ExitCodePreflightIncomplete case IsPreflightUnknown(err): return ExitCodePreflightUnknown case IsUserAborted(err): return ExitCodeUserAbortedError case errors.Is(err, ErrInternal): return ExitCodeInternalError default: return ExitCodeGenericError } } // formatError creates a formatted error message based on the error type func (h *Handler) formatError(err error) string { prefix := "Error:" if cliErr, ok := err.(*Error); ok { // For CLI errors, use the formatted error message var message string if cliErr.Category != nil { // Get a more specific prefix based on the error category prefix = h.getCategoryPrefix(cliErr.Category) } // If verbose mode is enabled, include more details if h.Verbose { message = cliErr.FormattedError() } else { // In non-verbose mode, include the main error message and the first suggestion message = cliErr.Error() if len(cliErr.Suggestions) > 0 { message = fmt.Sprintf("%s\nTip: %s", message, cliErr.Suggestions[0]) } } return fmt.Sprintf("%s %s", prefix, message) } // For regular errors, just return the error message return fmt.Sprintf("%s %s", prefix, err.Error()) } // getCategoryPrefix returns an appropriate prefix for the error category func (h *Handler) getCategoryPrefix(category error) string { switch category { case ErrValidation: return "Validation Error:" case ErrAPI: return "API Error:" case ErrResourceNotFound: return "Not Found:" case ErrPermissionDenied: return "Permission Denied:" case ErrConfiguration: return "Configuration Error:" case ErrAuthentication: return "Authentication Error:" case ErrPreflightCompletedFailure: return "Preflight Failure:" case ErrPreflightIncompleteFailure: return "Preflight Failing:" case ErrPreflightIncomplete: return "Preflight Incomplete:" case ErrPreflightUnknown: return "Preflight Unknown Result:" case ErrUserAborted: return "Aborted:" case ErrInternal: return "Internal Error:" default: return "Error:" } } // HandleWithDetails processes an error with additional contextual details func (h *Handler) HandleWithDetails(err error, operation string) { if err == nil { return } // Add operation context to the error var contextualErr error if operation != "" { // Check if it's already a CLI error if cliErr, ok := err.(*Error); ok { // Create a deep copy of the original error to avoid modifying it newSuggestions := make([]string, len(cliErr.Suggestions)) copy(newSuggestions, cliErr.Suggestions) newCliErr := &Error{ Original: cliErr.Original, Category: cliErr.Category, Suggestions: newSuggestions, Details: cliErr.Details, } // Add operation to details if newCliErr.Details == "" { newCliErr.Details = fmt.Sprintf("failed during: %s", operation) } else { newCliErr.Details = fmt.Sprintf("%s (during: %s)", newCliErr.Details, operation) } contextualErr = newCliErr } else { // Wrap in a new error with operation details contextualErr = NewError(err, nil, fmt.Sprintf("failed during: %s", operation)) } } else { contextualErr = err } // Handle the contextual error h.Handle(contextualErr) } // PrintWarning prints a warning message func (h *Handler) PrintWarning(format string, args ...interface{}) { message := fmt.Sprintf(format, args...) fmt.Fprintf(h.Writer, "Warning: %s\n", message) } // MessageForError returns a formatted message for an error without exiting func MessageForError(err error) string { if err == nil { return "" } handler := NewHandler() return handler.formatError(err) } // GetExitCodeForError returns the exit code for a given error func GetExitCodeForError(err error) int { if err == nil { return ExitCodeSuccess } handler := NewHandler() return handler.getExitCode(err) } ================================================ FILE: internal/errors/handler_test.go ================================================ package errors import ( "bytes" "fmt" "strings" "testing" ) // stripANSI removes ANSI color codes from a string for easier testing func stripANSI(s string) string { r := strings.NewReplacer( "\x1b[0m", "", "\x1b[1m", "", "\x1b[2m", "", "\x1b[31m", "", "\x1b[32m", "", "\x1b[33m", "", "\x1b[34m", "", "\x1b[35m", "", "\x1b[36m", "", "\x1b[37m", "", "\x1b[91m", "", "\x1b[92m", "", "\x1b[93m", "", "\x1b[94m", "", "\x1b[95m", "", "\x1b[96m", "", "\x1b[97m", "", ) return r.Replace(s) } func TestHandler(t *testing.T) { t.Parallel() t.Run("handles nil error", func(t *testing.T) { t.Parallel() var buf bytes.Buffer var exitCode int handler := NewHandler(). WithWriter(&buf). WithExitFunc(func(code int) { exitCode = code }) handler.Handle(nil) if buf.Len() > 0 { t.Errorf("Expected no output for nil error, got: %q", buf.String()) } if exitCode != 0 { t.Errorf("Expected exit code 0 for nil error, got: %d", exitCode) } }) t.Run("formats different error types", func(t *testing.T) { t.Parallel() testCases := []struct { name string err error expectedPrefix string expectedCode int }{ { name: "validation error", err: NewValidationError(nil, "Invalid input"), expectedPrefix: "Validation Error:", expectedCode: ExitCodeValidationError, }, { name: "API error", err: NewAPIError(nil, "API request failed"), expectedPrefix: "API Error:", expectedCode: ExitCodeAPIError, }, { name: "not found error", err: NewResourceNotFoundError(nil, "Resource not found"), expectedPrefix: "Not Found:", expectedCode: ExitCodeNotFoundError, }, { name: "simple error", err: fmt.Errorf("simple error"), expectedPrefix: "Error:", expectedCode: ExitCodeGenericError, }, } for _, tc := range testCases { tc := tc // Capture range variable t.Run(tc.name, func(t *testing.T) { t.Parallel() var buf bytes.Buffer var exitCode int handler := NewHandler(). WithWriter(&buf). WithExitFunc(func(code int) { exitCode = code }) handler.Handle(tc.err) output := stripANSI(buf.String()) if !strings.Contains(output, tc.expectedPrefix) { t.Errorf("Expected output to contain %q, got: %q", tc.expectedPrefix, output) } if exitCode != tc.expectedCode { t.Errorf("Expected exit code %d, got: %d", tc.expectedCode, exitCode) } }) } }) t.Run("includes suggestions in verbose mode", func(t *testing.T) { t.Parallel() var buf bytes.Buffer handler := NewHandler(). WithWriter(&buf). WithExitFunc(func(int) {}). WithVerbose(true) suggestion1 := "Try using a different name" suggestion2 := "Check your spelling" err := NewValidationError(nil, "Invalid name", suggestion1, suggestion2) handler.Handle(err) output := stripANSI(buf.String()) if !strings.Contains(output, suggestion1) { t.Errorf("Expected output to contain suggestion %q, got: %q", suggestion1, output) } if !strings.Contains(output, suggestion2) { t.Errorf("Expected output to contain suggestion %q, got: %q", suggestion2, output) } }) t.Run("includes one suggestion in non-verbose mode", func(t *testing.T) { t.Parallel() var buf bytes.Buffer handler := NewHandler(). WithWriter(&buf). WithExitFunc(func(int) {}) suggestion1 := "Try using a different name" suggestion2 := "Check your spelling" err := NewValidationError(nil, "Invalid name", suggestion1, suggestion2) handler.Handle(err) output := stripANSI(buf.String()) if !strings.Contains(output, suggestion1) { t.Errorf("Expected output to contain first suggestion %q, got: %q", suggestion1, output) } if strings.Contains(output, suggestion2) { t.Errorf("Expected output to NOT contain second suggestion in non-verbose mode, got: %q", output) } }) t.Run("handles errors with details", func(t *testing.T) { t.Parallel() var buf bytes.Buffer handler := NewHandler(). WithWriter(&buf). WithExitFunc(func(int) {}) // Create a simple error that won't be shared with other tests err := fmt.Errorf("test error: something went wrong") operation := "fetching data" handler.HandleWithDetails(err, operation) output := stripANSI(buf.String()) if !strings.Contains(output, "something went wrong") { t.Errorf("Expected output to contain error message, got: %q", output) } if !strings.Contains(output, operation) { t.Errorf("Expected output to contain operation details %q, got: %q", operation, output) } }) t.Run("prints warnings", func(t *testing.T) { t.Parallel() var buf bytes.Buffer handler := NewHandler(). WithWriter(&buf) warningMsg := "Something might be wrong" handler.PrintWarning("%s", warningMsg) output := stripANSI(buf.String()) if !strings.Contains(output, "Warning:") { t.Errorf("Expected output to contain 'Warning:', got: %q", output) } if !strings.Contains(output, warningMsg) { t.Errorf("Expected output to contain warning message %q, got: %q", warningMsg, output) } }) t.Run("MessageForError returns formatted message", func(t *testing.T) { t.Parallel() err := NewValidationError(nil, "Invalid input") message := MessageForError(err) // Strip ANSI codes for testing plainMessage := stripANSI(message) if !strings.Contains(plainMessage, "Validation Error:") { t.Errorf("Expected message to contain error category, got: %q", plainMessage) } if !strings.Contains(plainMessage, "Invalid input") { t.Errorf("Expected message to contain error details, got: %q", plainMessage) } }) t.Run("GetExitCodeForError returns correct code", func(t *testing.T) { t.Parallel() testCases := []struct { name string err error expectedCode int }{ { name: "nil error", err: nil, expectedCode: ExitCodeSuccess, }, { name: "validation error", err: NewValidationError(nil, ""), expectedCode: ExitCodeValidationError, }, { name: "API error", err: NewAPIError(nil, ""), expectedCode: ExitCodeAPIError, }, { name: "not found error", err: NewResourceNotFoundError(nil, ""), expectedCode: ExitCodeNotFoundError, }, { name: "preflight completed failure", err: NewPreflightCompletedFailureError(fmt.Errorf("failed"), ""), expectedCode: ExitCodePreflightCompletedFailure, }, { name: "preflight incomplete failure", err: NewPreflightIncompleteFailureError(fmt.Errorf("failed"), ""), expectedCode: ExitCodePreflightIncompleteFailure, }, { name: "preflight incomplete", err: NewPreflightIncompleteError(fmt.Errorf("incomplete"), ""), expectedCode: ExitCodePreflightIncomplete, }, { name: "preflight unknown result", err: NewPreflightUnknownError(fmt.Errorf("unknown"), ""), expectedCode: ExitCodePreflightUnknown, }, } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() code := GetExitCodeForError(tc.err) if code != tc.expectedCode { t.Errorf("Expected exit code %d, got: %d", tc.expectedCode, code) } }) } }) } ================================================ FILE: internal/graphql/generated.go ================================================ // Code generated by github.com/Khan/genqlient, DO NOT EDIT. package graphql import ( "context" "encoding/json" "fmt" "time" "github.com/Khan/genqlient/graphql" ) // CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload includes the requested fields of the GraphQL type JobTypeCommandCancelPayload. // The GraphQL type's documentation follows. // // Autogenerated return type of JobTypeCommandCancel. type CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload struct { // A unique identifier for the client performing the mutation. ClientMutationId *string `json:"clientMutationId"` JobTypeCommand CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand `json:"jobTypeCommand"` } // GetClientMutationId returns CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload.ClientMutationId, and is useful for accessing the field via an interface. func (v *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload) GetClientMutationId() *string { return v.ClientMutationId } // GetJobTypeCommand returns CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload.JobTypeCommand, and is useful for accessing the field via an interface. func (v *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload) GetJobTypeCommand() CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand { return v.JobTypeCommand } // CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand includes the requested fields of the GraphQL type JobTypeCommand. // The GraphQL type's documentation follows. // // A type of job that runs a command on an agent type CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand struct { Id string `json:"id"` // The UUID for this job Uuid string `json:"uuid"` // The state of the job State JobStates `json:"state"` // The URL for the job Url string `json:"url"` } // GetId returns CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand.Id, and is useful for accessing the field via an interface. func (v *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand) GetId() string { return v.Id } // GetUuid returns CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand.Uuid, and is useful for accessing the field via an interface. func (v *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand) GetUuid() string { return v.Uuid } // GetState returns CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand.State, and is useful for accessing the field via an interface. func (v *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand) GetState() JobStates { return v.State } // GetUrl returns CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand.Url, and is useful for accessing the field via an interface. func (v *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayloadJobTypeCommand) GetUrl() string { return v.Url } // CancelJobResponse is returned by CancelJob on success. type CancelJobResponse struct { // Cancel a job. JobTypeCommandCancel *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload `json:"jobTypeCommandCancel"` } // GetJobTypeCommandCancel returns CancelJobResponse.JobTypeCommandCancel, and is useful for accessing the field via an interface. func (v *CancelJobResponse) GetJobTypeCommandCancel() *CancelJobJobTypeCommandCancelJobTypeCommandCancelPayload { return v.JobTypeCommandCancel } // FindClustersOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type FindClustersOrganization struct { // Returns clusters for an Organization Clusters *FindClustersOrganizationClustersClusterConnection `json:"clusters"` } // GetClusters returns FindClustersOrganization.Clusters, and is useful for accessing the field via an interface. func (v *FindClustersOrganization) GetClusters() *FindClustersOrganizationClustersClusterConnection { return v.Clusters } // FindClustersOrganizationClustersClusterConnection includes the requested fields of the GraphQL type ClusterConnection. type FindClustersOrganizationClustersClusterConnection struct { Edges []*FindClustersOrganizationClustersClusterConnectionEdgesClusterEdge `json:"edges"` PageInfo *FindClustersOrganizationClustersClusterConnectionPageInfo `json:"pageInfo"` } // GetEdges returns FindClustersOrganizationClustersClusterConnection.Edges, and is useful for accessing the field via an interface. func (v *FindClustersOrganizationClustersClusterConnection) GetEdges() []*FindClustersOrganizationClustersClusterConnectionEdgesClusterEdge { return v.Edges } // GetPageInfo returns FindClustersOrganizationClustersClusterConnection.PageInfo, and is useful for accessing the field via an interface. func (v *FindClustersOrganizationClustersClusterConnection) GetPageInfo() *FindClustersOrganizationClustersClusterConnectionPageInfo { return v.PageInfo } // FindClustersOrganizationClustersClusterConnectionEdgesClusterEdge includes the requested fields of the GraphQL type ClusterEdge. type FindClustersOrganizationClustersClusterConnectionEdgesClusterEdge struct { Node *FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster `json:"node"` } // GetNode returns FindClustersOrganizationClustersClusterConnectionEdgesClusterEdge.Node, and is useful for accessing the field via an interface. func (v *FindClustersOrganizationClustersClusterConnectionEdgesClusterEdge) GetNode() *FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster { return v.Node } // FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster includes the requested fields of the GraphQL type Cluster. type FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster struct { Id string `json:"id"` // Name of the cluster Name string `json:"name"` } // GetId returns FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster.Id, and is useful for accessing the field via an interface. func (v *FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster) GetId() string { return v.Id } // GetName returns FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster.Name, and is useful for accessing the field via an interface. func (v *FindClustersOrganizationClustersClusterConnectionEdgesClusterEdgeNodeCluster) GetName() string { return v.Name } // FindClustersOrganizationClustersClusterConnectionPageInfo includes the requested fields of the GraphQL type PageInfo. // The GraphQL type's documentation follows. // // Information about pagination in a connection. type FindClustersOrganizationClustersClusterConnectionPageInfo struct { // When paginating forwards, are there more items? HasNextPage bool `json:"hasNextPage"` // When paginating forwards, the cursor to continue. EndCursor *string `json:"endCursor"` } // GetHasNextPage returns FindClustersOrganizationClustersClusterConnectionPageInfo.HasNextPage, and is useful for accessing the field via an interface. func (v *FindClustersOrganizationClustersClusterConnectionPageInfo) GetHasNextPage() bool { return v.HasNextPage } // GetEndCursor returns FindClustersOrganizationClustersClusterConnectionPageInfo.EndCursor, and is useful for accessing the field via an interface. func (v *FindClustersOrganizationClustersClusterConnectionPageInfo) GetEndCursor() *string { return v.EndCursor } // FindClustersResponse is returned by FindClusters on success. type FindClustersResponse struct { // Find an organization Organization *FindClustersOrganization `json:"organization"` } // GetOrganization returns FindClustersResponse.Organization, and is useful for accessing the field via an interface. func (v *FindClustersResponse) GetOrganization() *FindClustersOrganization { return v.Organization } // FindQueuesForClusterNode includes the requested fields of the GraphQL interface Node. // // FindQueuesForClusterNode is implemented by the following types: // FindQueuesForClusterNodeAPIAccessToken // FindQueuesForClusterNodeAPIAccessTokenCode // FindQueuesForClusterNodeAPIApplication // FindQueuesForClusterNodeAgent // FindQueuesForClusterNodeAgentToken // FindQueuesForClusterNodeAnnotation // FindQueuesForClusterNodeArtifact // FindQueuesForClusterNodeAuditEvent // FindQueuesForClusterNodeAuthorizationBitbucket // FindQueuesForClusterNodeAuthorizationGitHub // FindQueuesForClusterNodeAuthorizationGitHubApp // FindQueuesForClusterNodeAuthorizationGitHubEnterprise // FindQueuesForClusterNodeAuthorizationGoogle // FindQueuesForClusterNodeAuthorizationSAML // FindQueuesForClusterNodeBuild // FindQueuesForClusterNodeChangelog // FindQueuesForClusterNodeCluster // FindQueuesForClusterNodeClusterQueue // FindQueuesForClusterNodeClusterQueueToken // FindQueuesForClusterNodeClusterToken // FindQueuesForClusterNodeEmail // FindQueuesForClusterNodeJobEventAssigned // FindQueuesForClusterNodeJobEventBuildStepUploadCreated // FindQueuesForClusterNodeJobEventCanceled // FindQueuesForClusterNodeJobEventFinished // FindQueuesForClusterNodeJobEventGeneric // FindQueuesForClusterNodeJobEventRetried // FindQueuesForClusterNodeJobEventTimedOut // FindQueuesForClusterNodeJobTypeBlock // FindQueuesForClusterNodeJobTypeCommand // FindQueuesForClusterNodeJobTypeTrigger // FindQueuesForClusterNodeJobTypeWait // FindQueuesForClusterNodeNotificationServiceSlack // FindQueuesForClusterNodeOrganization // FindQueuesForClusterNodeOrganizationBanner // FindQueuesForClusterNodeOrganizationInvitation // FindQueuesForClusterNodeOrganizationMember // FindQueuesForClusterNodePipeline // FindQueuesForClusterNodePipelineMetric // FindQueuesForClusterNodePipelineSchedule // FindQueuesForClusterNodePipelineTemplate // FindQueuesForClusterNodeSSOProviderGitHubApp // FindQueuesForClusterNodeSSOProviderGoogleGSuite // FindQueuesForClusterNodeSSOProviderSAML // FindQueuesForClusterNodeSecret // FindQueuesForClusterNodeSuite // FindQueuesForClusterNodeTeam // FindQueuesForClusterNodeTeamMember // FindQueuesForClusterNodeTeamPipeline // FindQueuesForClusterNodeTeamSuite // FindQueuesForClusterNodeUser // FindQueuesForClusterNodeViewer // The GraphQL type's documentation follows. // // An object with an ID. type FindQueuesForClusterNode interface { implementsGraphQLInterfaceFindQueuesForClusterNode() // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). GetTypename() *string } func (v *FindQueuesForClusterNodeAPIAccessToken) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAPIAccessTokenCode) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAPIApplication) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAgent) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeAgentToken) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeAnnotation) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeArtifact) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeAuditEvent) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeAuthorizationBitbucket) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAuthorizationGitHub) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAuthorizationGitHubApp) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAuthorizationGitHubEnterprise) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAuthorizationGoogle) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeAuthorizationSAML) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeBuild) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeChangelog) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeCluster) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeClusterQueue) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeClusterQueueToken) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeClusterToken) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeEmail) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeJobEventAssigned) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobEventBuildStepUploadCreated) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobEventCanceled) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobEventFinished) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobEventGeneric) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobEventRetried) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobEventTimedOut) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobTypeBlock) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeJobTypeCommand) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobTypeTrigger) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeJobTypeWait) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeNotificationServiceSlack) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeOrganization) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeOrganizationBanner) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeOrganizationInvitation) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeOrganizationMember) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodePipeline) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodePipelineMetric) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodePipelineSchedule) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodePipelineTemplate) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeSSOProviderGitHubApp) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeSSOProviderGoogleGSuite) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeSSOProviderSAML) implementsGraphQLInterfaceFindQueuesForClusterNode() { } func (v *FindQueuesForClusterNodeSecret) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeSuite) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeTeam) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeTeamMember) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeTeamPipeline) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeTeamSuite) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeUser) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func (v *FindQueuesForClusterNodeViewer) implementsGraphQLInterfaceFindQueuesForClusterNode() {} func __unmarshalFindQueuesForClusterNode(b []byte, v *FindQueuesForClusterNode) error { if string(b) == "null" { return nil } var tn struct { TypeName string `json:"__typename"` } err := json.Unmarshal(b, &tn) if err != nil { return err } switch tn.TypeName { case "APIAccessToken": *v = new(FindQueuesForClusterNodeAPIAccessToken) return json.Unmarshal(b, *v) case "APIAccessTokenCode": *v = new(FindQueuesForClusterNodeAPIAccessTokenCode) return json.Unmarshal(b, *v) case "APIApplication": *v = new(FindQueuesForClusterNodeAPIApplication) return json.Unmarshal(b, *v) case "Agent": *v = new(FindQueuesForClusterNodeAgent) return json.Unmarshal(b, *v) case "AgentToken": *v = new(FindQueuesForClusterNodeAgentToken) return json.Unmarshal(b, *v) case "Annotation": *v = new(FindQueuesForClusterNodeAnnotation) return json.Unmarshal(b, *v) case "Artifact": *v = new(FindQueuesForClusterNodeArtifact) return json.Unmarshal(b, *v) case "AuditEvent": *v = new(FindQueuesForClusterNodeAuditEvent) return json.Unmarshal(b, *v) case "AuthorizationBitbucket": *v = new(FindQueuesForClusterNodeAuthorizationBitbucket) return json.Unmarshal(b, *v) case "AuthorizationGitHub": *v = new(FindQueuesForClusterNodeAuthorizationGitHub) return json.Unmarshal(b, *v) case "AuthorizationGitHubApp": *v = new(FindQueuesForClusterNodeAuthorizationGitHubApp) return json.Unmarshal(b, *v) case "AuthorizationGitHubEnterprise": *v = new(FindQueuesForClusterNodeAuthorizationGitHubEnterprise) return json.Unmarshal(b, *v) case "AuthorizationGoogle": *v = new(FindQueuesForClusterNodeAuthorizationGoogle) return json.Unmarshal(b, *v) case "AuthorizationSAML": *v = new(FindQueuesForClusterNodeAuthorizationSAML) return json.Unmarshal(b, *v) case "Build": *v = new(FindQueuesForClusterNodeBuild) return json.Unmarshal(b, *v) case "Changelog": *v = new(FindQueuesForClusterNodeChangelog) return json.Unmarshal(b, *v) case "Cluster": *v = new(FindQueuesForClusterNodeCluster) return json.Unmarshal(b, *v) case "ClusterQueue": *v = new(FindQueuesForClusterNodeClusterQueue) return json.Unmarshal(b, *v) case "ClusterQueueToken": *v = new(FindQueuesForClusterNodeClusterQueueToken) return json.Unmarshal(b, *v) case "ClusterToken": *v = new(FindQueuesForClusterNodeClusterToken) return json.Unmarshal(b, *v) case "Email": *v = new(FindQueuesForClusterNodeEmail) return json.Unmarshal(b, *v) case "JobEventAssigned": *v = new(FindQueuesForClusterNodeJobEventAssigned) return json.Unmarshal(b, *v) case "JobEventBuildStepUploadCreated": *v = new(FindQueuesForClusterNodeJobEventBuildStepUploadCreated) return json.Unmarshal(b, *v) case "JobEventCanceled": *v = new(FindQueuesForClusterNodeJobEventCanceled) return json.Unmarshal(b, *v) case "JobEventFinished": *v = new(FindQueuesForClusterNodeJobEventFinished) return json.Unmarshal(b, *v) case "JobEventGeneric": *v = new(FindQueuesForClusterNodeJobEventGeneric) return json.Unmarshal(b, *v) case "JobEventRetried": *v = new(FindQueuesForClusterNodeJobEventRetried) return json.Unmarshal(b, *v) case "JobEventTimedOut": *v = new(FindQueuesForClusterNodeJobEventTimedOut) return json.Unmarshal(b, *v) case "JobTypeBlock": *v = new(FindQueuesForClusterNodeJobTypeBlock) return json.Unmarshal(b, *v) case "JobTypeCommand": *v = new(FindQueuesForClusterNodeJobTypeCommand) return json.Unmarshal(b, *v) case "JobTypeTrigger": *v = new(FindQueuesForClusterNodeJobTypeTrigger) return json.Unmarshal(b, *v) case "JobTypeWait": *v = new(FindQueuesForClusterNodeJobTypeWait) return json.Unmarshal(b, *v) case "NotificationServiceSlack": *v = new(FindQueuesForClusterNodeNotificationServiceSlack) return json.Unmarshal(b, *v) case "Organization": *v = new(FindQueuesForClusterNodeOrganization) return json.Unmarshal(b, *v) case "OrganizationBanner": *v = new(FindQueuesForClusterNodeOrganizationBanner) return json.Unmarshal(b, *v) case "OrganizationInvitation": *v = new(FindQueuesForClusterNodeOrganizationInvitation) return json.Unmarshal(b, *v) case "OrganizationMember": *v = new(FindQueuesForClusterNodeOrganizationMember) return json.Unmarshal(b, *v) case "Pipeline": *v = new(FindQueuesForClusterNodePipeline) return json.Unmarshal(b, *v) case "PipelineMetric": *v = new(FindQueuesForClusterNodePipelineMetric) return json.Unmarshal(b, *v) case "PipelineSchedule": *v = new(FindQueuesForClusterNodePipelineSchedule) return json.Unmarshal(b, *v) case "PipelineTemplate": *v = new(FindQueuesForClusterNodePipelineTemplate) return json.Unmarshal(b, *v) case "SSOProviderGitHubApp": *v = new(FindQueuesForClusterNodeSSOProviderGitHubApp) return json.Unmarshal(b, *v) case "SSOProviderGoogleGSuite": *v = new(FindQueuesForClusterNodeSSOProviderGoogleGSuite) return json.Unmarshal(b, *v) case "SSOProviderSAML": *v = new(FindQueuesForClusterNodeSSOProviderSAML) return json.Unmarshal(b, *v) case "Secret": *v = new(FindQueuesForClusterNodeSecret) return json.Unmarshal(b, *v) case "Suite": *v = new(FindQueuesForClusterNodeSuite) return json.Unmarshal(b, *v) case "Team": *v = new(FindQueuesForClusterNodeTeam) return json.Unmarshal(b, *v) case "TeamMember": *v = new(FindQueuesForClusterNodeTeamMember) return json.Unmarshal(b, *v) case "TeamPipeline": *v = new(FindQueuesForClusterNodeTeamPipeline) return json.Unmarshal(b, *v) case "TeamSuite": *v = new(FindQueuesForClusterNodeTeamSuite) return json.Unmarshal(b, *v) case "User": *v = new(FindQueuesForClusterNodeUser) return json.Unmarshal(b, *v) case "Viewer": *v = new(FindQueuesForClusterNodeViewer) return json.Unmarshal(b, *v) case "": return fmt.Errorf( "response was missing Node.__typename") default: return fmt.Errorf( `unexpected concrete type for FindQueuesForClusterNode: "%v"`, tn.TypeName) } } func __marshalFindQueuesForClusterNode(v *FindQueuesForClusterNode) ([]byte, error) { var typename string switch v := (*v).(type) { case *FindQueuesForClusterNodeAPIAccessToken: typename = "APIAccessToken" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAPIAccessToken }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAPIAccessTokenCode: typename = "APIAccessTokenCode" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAPIAccessTokenCode }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAPIApplication: typename = "APIApplication" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAPIApplication }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAgent: typename = "Agent" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAgent }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAgentToken: typename = "AgentToken" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAgentToken }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAnnotation: typename = "Annotation" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAnnotation }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeArtifact: typename = "Artifact" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeArtifact }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAuditEvent: typename = "AuditEvent" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAuditEvent }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAuthorizationBitbucket: typename = "AuthorizationBitbucket" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAuthorizationBitbucket }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAuthorizationGitHub: typename = "AuthorizationGitHub" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAuthorizationGitHub }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAuthorizationGitHubApp: typename = "AuthorizationGitHubApp" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAuthorizationGitHubApp }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAuthorizationGitHubEnterprise: typename = "AuthorizationGitHubEnterprise" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAuthorizationGitHubEnterprise }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAuthorizationGoogle: typename = "AuthorizationGoogle" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAuthorizationGoogle }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeAuthorizationSAML: typename = "AuthorizationSAML" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeAuthorizationSAML }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeBuild: typename = "Build" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeBuild }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeChangelog: typename = "Changelog" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeChangelog }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeCluster: typename = "Cluster" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeCluster }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeClusterQueue: typename = "ClusterQueue" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeClusterQueue }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeClusterQueueToken: typename = "ClusterQueueToken" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeClusterQueueToken }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeClusterToken: typename = "ClusterToken" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeClusterToken }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeEmail: typename = "Email" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeEmail }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobEventAssigned: typename = "JobEventAssigned" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobEventAssigned }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobEventBuildStepUploadCreated: typename = "JobEventBuildStepUploadCreated" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobEventBuildStepUploadCreated }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobEventCanceled: typename = "JobEventCanceled" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobEventCanceled }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobEventFinished: typename = "JobEventFinished" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobEventFinished }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobEventGeneric: typename = "JobEventGeneric" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobEventGeneric }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobEventRetried: typename = "JobEventRetried" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobEventRetried }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobEventTimedOut: typename = "JobEventTimedOut" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobEventTimedOut }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobTypeBlock: typename = "JobTypeBlock" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobTypeBlock }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobTypeCommand: typename = "JobTypeCommand" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobTypeCommand }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobTypeTrigger: typename = "JobTypeTrigger" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobTypeTrigger }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeJobTypeWait: typename = "JobTypeWait" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeJobTypeWait }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeNotificationServiceSlack: typename = "NotificationServiceSlack" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeNotificationServiceSlack }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeOrganization: typename = "Organization" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeOrganization }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeOrganizationBanner: typename = "OrganizationBanner" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeOrganizationBanner }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeOrganizationInvitation: typename = "OrganizationInvitation" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeOrganizationInvitation }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeOrganizationMember: typename = "OrganizationMember" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeOrganizationMember }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodePipeline: typename = "Pipeline" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodePipeline }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodePipelineMetric: typename = "PipelineMetric" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodePipelineMetric }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodePipelineSchedule: typename = "PipelineSchedule" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodePipelineSchedule }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodePipelineTemplate: typename = "PipelineTemplate" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodePipelineTemplate }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeSSOProviderGitHubApp: typename = "SSOProviderGitHubApp" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeSSOProviderGitHubApp }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeSSOProviderGoogleGSuite: typename = "SSOProviderGoogleGSuite" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeSSOProviderGoogleGSuite }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeSSOProviderSAML: typename = "SSOProviderSAML" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeSSOProviderSAML }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeSecret: typename = "Secret" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeSecret }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeSuite: typename = "Suite" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeSuite }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeTeam: typename = "Team" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeTeam }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeTeamMember: typename = "TeamMember" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeTeamMember }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeTeamPipeline: typename = "TeamPipeline" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeTeamPipeline }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeTeamSuite: typename = "TeamSuite" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeTeamSuite }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeUser: typename = "User" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeUser }{typename, v} return json.Marshal(result) case *FindQueuesForClusterNodeViewer: typename = "Viewer" result := struct { TypeName string `json:"__typename"` *FindQueuesForClusterNodeViewer }{typename, v} return json.Marshal(result) case nil: return []byte("null"), nil default: return nil, fmt.Errorf( `unexpected concrete type for FindQueuesForClusterNode: "%T"`, v) } } // FindQueuesForClusterNodeAPIAccessToken includes the requested fields of the GraphQL type APIAccessToken. // The GraphQL type's documentation follows. // // API access tokens for authentication with the Buildkite API type FindQueuesForClusterNodeAPIAccessToken struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAPIAccessToken.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAPIAccessToken) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAPIAccessTokenCode includes the requested fields of the GraphQL type APIAccessTokenCode. // The GraphQL type's documentation follows. // // A code that is used by an API Application to request an API Access Token type FindQueuesForClusterNodeAPIAccessTokenCode struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAPIAccessTokenCode.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAPIAccessTokenCode) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAPIApplication includes the requested fields of the GraphQL type APIApplication. // The GraphQL type's documentation follows. // // An API Application type FindQueuesForClusterNodeAPIApplication struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAPIApplication.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAPIApplication) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAgent includes the requested fields of the GraphQL type Agent. // The GraphQL type's documentation follows. // // An agent type FindQueuesForClusterNodeAgent struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAgent.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAgent) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAgentToken includes the requested fields of the GraphQL type AgentToken. // The GraphQL type's documentation follows. // // A token used to connect an agent to Buildkite type FindQueuesForClusterNodeAgentToken struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAgentToken.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAgentToken) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAnnotation includes the requested fields of the GraphQL type Annotation. // The GraphQL type's documentation follows. // // An annotation allows you to add arbitrary content to the top of a build page in the Buildkite UI type FindQueuesForClusterNodeAnnotation struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAnnotation.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAnnotation) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeArtifact includes the requested fields of the GraphQL type Artifact. // The GraphQL type's documentation follows. // // A file uploaded from the agent whilst running a job type FindQueuesForClusterNodeArtifact struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeArtifact.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeArtifact) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAuditEvent includes the requested fields of the GraphQL type AuditEvent. // The GraphQL type's documentation follows. // // Audit record of an event which occurred in the system type FindQueuesForClusterNodeAuditEvent struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAuditEvent.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAuditEvent) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAuthorizationBitbucket includes the requested fields of the GraphQL type AuthorizationBitbucket. // The GraphQL type's documentation follows. // // A Bitbucket account authorized with a Buildkite account type FindQueuesForClusterNodeAuthorizationBitbucket struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAuthorizationBitbucket.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAuthorizationBitbucket) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAuthorizationGitHub includes the requested fields of the GraphQL type AuthorizationGitHub. // The GraphQL type's documentation follows. // // A GitHub account authorized with a Buildkite account type FindQueuesForClusterNodeAuthorizationGitHub struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAuthorizationGitHub.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAuthorizationGitHub) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAuthorizationGitHubApp includes the requested fields of the GraphQL type AuthorizationGitHubApp. // The GraphQL type's documentation follows. // // A GitHub app authorized with a Buildkite account type FindQueuesForClusterNodeAuthorizationGitHubApp struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAuthorizationGitHubApp.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAuthorizationGitHubApp) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAuthorizationGitHubEnterprise includes the requested fields of the GraphQL type AuthorizationGitHubEnterprise. // The GraphQL type's documentation follows. // // A GitHub Enterprise account authorized with a Buildkite account type FindQueuesForClusterNodeAuthorizationGitHubEnterprise struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAuthorizationGitHubEnterprise.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAuthorizationGitHubEnterprise) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAuthorizationGoogle includes the requested fields of the GraphQL type AuthorizationGoogle. // The GraphQL type's documentation follows. // // A Google account authorized with a Buildkite account type FindQueuesForClusterNodeAuthorizationGoogle struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAuthorizationGoogle.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAuthorizationGoogle) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeAuthorizationSAML includes the requested fields of the GraphQL type AuthorizationSAML. // The GraphQL type's documentation follows. // // A SAML account authorized with a Buildkite account type FindQueuesForClusterNodeAuthorizationSAML struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeAuthorizationSAML.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeAuthorizationSAML) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeBuild includes the requested fields of the GraphQL type Build. // The GraphQL type's documentation follows. // // A build from a pipeline type FindQueuesForClusterNodeBuild struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeBuild.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeBuild) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeChangelog includes the requested fields of the GraphQL type Changelog. // The GraphQL type's documentation follows. // // A changelog type FindQueuesForClusterNodeChangelog struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeChangelog.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeChangelog) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeCluster includes the requested fields of the GraphQL type Cluster. type FindQueuesForClusterNodeCluster struct { Typename *string `json:"__typename"` Id string `json:"id"` // Name of the cluster Name string `json:"name"` Queues *FindQueuesForClusterNodeClusterQueuesClusterQueueConnection `json:"queues"` } // GetTypename returns FindQueuesForClusterNodeCluster.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeCluster) GetTypename() *string { return v.Typename } // GetId returns FindQueuesForClusterNodeCluster.Id, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeCluster) GetId() string { return v.Id } // GetName returns FindQueuesForClusterNodeCluster.Name, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeCluster) GetName() string { return v.Name } // GetQueues returns FindQueuesForClusterNodeCluster.Queues, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeCluster) GetQueues() *FindQueuesForClusterNodeClusterQueuesClusterQueueConnection { return v.Queues } // FindQueuesForClusterNodeClusterQueue includes the requested fields of the GraphQL type ClusterQueue. type FindQueuesForClusterNodeClusterQueue struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeClusterQueue.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueue) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeClusterQueueToken includes the requested fields of the GraphQL type ClusterQueueToken. // The GraphQL type's documentation follows. // // A token used to register an agent with a Buildkite cluster queue type FindQueuesForClusterNodeClusterQueueToken struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeClusterQueueToken.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueueToken) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeClusterQueuesClusterQueueConnection includes the requested fields of the GraphQL type ClusterQueueConnection. type FindQueuesForClusterNodeClusterQueuesClusterQueueConnection struct { Edges []*FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge `json:"edges"` PageInfo *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo `json:"pageInfo"` } // GetEdges returns FindQueuesForClusterNodeClusterQueuesClusterQueueConnection.Edges, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueuesClusterQueueConnection) GetEdges() []*FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge { return v.Edges } // GetPageInfo returns FindQueuesForClusterNodeClusterQueuesClusterQueueConnection.PageInfo, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueuesClusterQueueConnection) GetPageInfo() *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo { return v.PageInfo } // FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge includes the requested fields of the GraphQL type ClusterQueueEdge. type FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge struct { Node *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue `json:"node"` } // GetNode returns FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge.Node, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge) GetNode() *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue { return v.Node } // FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue includes the requested fields of the GraphQL type ClusterQueue. type FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue struct { Id string `json:"id"` Key string `json:"key"` } // GetId returns FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue.Id, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue) GetId() string { return v.Id } // GetKey returns FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue.Key, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue) GetKey() string { return v.Key } // FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo includes the requested fields of the GraphQL type PageInfo. // The GraphQL type's documentation follows. // // Information about pagination in a connection. type FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo struct { // When paginating forwards, are there more items? HasNextPage bool `json:"hasNextPage"` // When paginating forwards, the cursor to continue. EndCursor *string `json:"endCursor"` } // GetHasNextPage returns FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo.HasNextPage, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo) GetHasNextPage() bool { return v.HasNextPage } // GetEndCursor returns FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo.EndCursor, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterQueuesClusterQueueConnectionPageInfo) GetEndCursor() *string { return v.EndCursor } // FindQueuesForClusterNodeClusterToken includes the requested fields of the GraphQL type ClusterToken. // The GraphQL type's documentation follows. // // A token used to connect an agent in cluster to Buildkite type FindQueuesForClusterNodeClusterToken struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeClusterToken.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeClusterToken) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeEmail includes the requested fields of the GraphQL type Email. // The GraphQL type's documentation follows. // // An email address type FindQueuesForClusterNodeEmail struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeEmail.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeEmail) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobEventAssigned includes the requested fields of the GraphQL type JobEventAssigned. // The GraphQL type's documentation follows. // // An event created when the dispatcher assigns the job to an agent type FindQueuesForClusterNodeJobEventAssigned struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobEventAssigned.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobEventAssigned) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobEventBuildStepUploadCreated includes the requested fields of the GraphQL type JobEventBuildStepUploadCreated. // The GraphQL type's documentation follows. // // An event created when the job creates new build steps via pipeline upload type FindQueuesForClusterNodeJobEventBuildStepUploadCreated struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobEventBuildStepUploadCreated.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobEventBuildStepUploadCreated) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobEventCanceled includes the requested fields of the GraphQL type JobEventCanceled. // The GraphQL type's documentation follows. // // An event created when the job is canceled type FindQueuesForClusterNodeJobEventCanceled struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobEventCanceled.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobEventCanceled) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobEventFinished includes the requested fields of the GraphQL type JobEventFinished. // The GraphQL type's documentation follows. // // An event created when the job is finished type FindQueuesForClusterNodeJobEventFinished struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobEventFinished.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobEventFinished) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobEventGeneric includes the requested fields of the GraphQL type JobEventGeneric. // The GraphQL type's documentation follows. // // A generic event type that doesn't have any additional meta-information associated with the event type FindQueuesForClusterNodeJobEventGeneric struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobEventGeneric.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobEventGeneric) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobEventRetried includes the requested fields of the GraphQL type JobEventRetried. // The GraphQL type's documentation follows. // // An event created when the job is retried type FindQueuesForClusterNodeJobEventRetried struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobEventRetried.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobEventRetried) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobEventTimedOut includes the requested fields of the GraphQL type JobEventTimedOut. // The GraphQL type's documentation follows. // // An event created when the job is timed out type FindQueuesForClusterNodeJobEventTimedOut struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobEventTimedOut.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobEventTimedOut) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobTypeBlock includes the requested fields of the GraphQL type JobTypeBlock. // The GraphQL type's documentation follows. // // A type of job that requires a user to unblock it before proceeding in a build pipeline type FindQueuesForClusterNodeJobTypeBlock struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobTypeBlock.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobTypeBlock) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobTypeCommand includes the requested fields of the GraphQL type JobTypeCommand. // The GraphQL type's documentation follows. // // A type of job that runs a command on an agent type FindQueuesForClusterNodeJobTypeCommand struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobTypeCommand.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobTypeCommand) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobTypeTrigger includes the requested fields of the GraphQL type JobTypeTrigger. // The GraphQL type's documentation follows. // // A type of job that triggers another build on a pipeline type FindQueuesForClusterNodeJobTypeTrigger struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobTypeTrigger.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobTypeTrigger) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeJobTypeWait includes the requested fields of the GraphQL type JobTypeWait. // The GraphQL type's documentation follows. // // A type of job that waits for all previous jobs to pass before proceeding the build pipeline type FindQueuesForClusterNodeJobTypeWait struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeJobTypeWait.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeJobTypeWait) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeNotificationServiceSlack includes the requested fields of the GraphQL type NotificationServiceSlack. // The GraphQL type's documentation follows. // // Deliver notifications to Slack type FindQueuesForClusterNodeNotificationServiceSlack struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeNotificationServiceSlack.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeNotificationServiceSlack) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type FindQueuesForClusterNodeOrganization struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeOrganization.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeOrganization) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeOrganizationBanner includes the requested fields of the GraphQL type OrganizationBanner. // The GraphQL type's documentation follows. // // System banner of an organization type FindQueuesForClusterNodeOrganizationBanner struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeOrganizationBanner.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeOrganizationBanner) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeOrganizationInvitation includes the requested fields of the GraphQL type OrganizationInvitation. // The GraphQL type's documentation follows. // // A pending invitation to a user to join this organization type FindQueuesForClusterNodeOrganizationInvitation struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeOrganizationInvitation.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeOrganizationInvitation) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeOrganizationMember includes the requested fields of the GraphQL type OrganizationMember. // The GraphQL type's documentation follows. // // A member of an organization type FindQueuesForClusterNodeOrganizationMember struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeOrganizationMember.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeOrganizationMember) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodePipeline includes the requested fields of the GraphQL type Pipeline. // The GraphQL type's documentation follows. // // A pipeline type FindQueuesForClusterNodePipeline struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodePipeline.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodePipeline) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodePipelineMetric includes the requested fields of the GraphQL type PipelineMetric. // The GraphQL type's documentation follows. // // A metric for a pipeline type FindQueuesForClusterNodePipelineMetric struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodePipelineMetric.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodePipelineMetric) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodePipelineSchedule includes the requested fields of the GraphQL type PipelineSchedule. // The GraphQL type's documentation follows. // // A schedule of when a build should automatically triggered for a Pipeline type FindQueuesForClusterNodePipelineSchedule struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodePipelineSchedule.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodePipelineSchedule) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodePipelineTemplate includes the requested fields of the GraphQL type PipelineTemplate. // The GraphQL type's documentation follows. // // A template defining a fixed step configuration for a pipeline type FindQueuesForClusterNodePipelineTemplate struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodePipelineTemplate.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodePipelineTemplate) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeSSOProviderGitHubApp includes the requested fields of the GraphQL type SSOProviderGitHubApp. // The GraphQL type's documentation follows. // // Single sign-on provided by GitHub type FindQueuesForClusterNodeSSOProviderGitHubApp struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeSSOProviderGitHubApp.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeSSOProviderGitHubApp) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeSSOProviderGoogleGSuite includes the requested fields of the GraphQL type SSOProviderGoogleGSuite. // The GraphQL type's documentation follows. // // Single sign-on provided by Google type FindQueuesForClusterNodeSSOProviderGoogleGSuite struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeSSOProviderGoogleGSuite.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeSSOProviderGoogleGSuite) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeSSOProviderSAML includes the requested fields of the GraphQL type SSOProviderSAML. // The GraphQL type's documentation follows. // // Single sign-on provided via SAML type FindQueuesForClusterNodeSSOProviderSAML struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeSSOProviderSAML.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeSSOProviderSAML) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeSecret includes the requested fields of the GraphQL type Secret. // The GraphQL type's documentation follows. // // A secret hosted by Buildkite. This does not contain the secret value or encrypted material. type FindQueuesForClusterNodeSecret struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeSecret.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeSecret) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeSuite includes the requested fields of the GraphQL type Suite. // The GraphQL type's documentation follows. // // A suite type FindQueuesForClusterNodeSuite struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeSuite.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeSuite) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeTeam includes the requested fields of the GraphQL type Team. // The GraphQL type's documentation follows. // // An organization team type FindQueuesForClusterNodeTeam struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeTeam.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeTeam) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeTeamMember includes the requested fields of the GraphQL type TeamMember. // The GraphQL type's documentation follows. // // An member of a team type FindQueuesForClusterNodeTeamMember struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeTeamMember.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeTeamMember) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeTeamPipeline includes the requested fields of the GraphQL type TeamPipeline. // The GraphQL type's documentation follows. // // An pipeline that's been assigned to a team type FindQueuesForClusterNodeTeamPipeline struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeTeamPipeline.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeTeamPipeline) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeTeamSuite includes the requested fields of the GraphQL type TeamSuite. // The GraphQL type's documentation follows. // // A suite that's been assigned to a team type FindQueuesForClusterNodeTeamSuite struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeTeamSuite.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeTeamSuite) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeUser includes the requested fields of the GraphQL type User. // The GraphQL type's documentation follows. // // A user type FindQueuesForClusterNodeUser struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeUser.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeUser) GetTypename() *string { return v.Typename } // FindQueuesForClusterNodeViewer includes the requested fields of the GraphQL type Viewer. // The GraphQL type's documentation follows. // // Represents the current user session type FindQueuesForClusterNodeViewer struct { Typename *string `json:"__typename"` } // GetTypename returns FindQueuesForClusterNodeViewer.Typename, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterNodeViewer) GetTypename() *string { return v.Typename } // FindQueuesForClusterResponse is returned by FindQueuesForCluster on success. type FindQueuesForClusterResponse struct { // Fetches an object given its ID. Node *FindQueuesForClusterNode `json:"-"` } // GetNode returns FindQueuesForClusterResponse.Node, and is useful for accessing the field via an interface. func (v *FindQueuesForClusterResponse) GetNode() *FindQueuesForClusterNode { return v.Node } func (v *FindQueuesForClusterResponse) UnmarshalJSON(b []byte) error { if string(b) == "null" { return nil } var firstPass struct { *FindQueuesForClusterResponse Node json.RawMessage `json:"node"` graphql.NoUnmarshalJSON } firstPass.FindQueuesForClusterResponse = v err := json.Unmarshal(b, &firstPass) if err != nil { return err } { dst := &v.Node src := firstPass.Node if len(src) != 0 && string(src) != "null" { *dst = new(FindQueuesForClusterNode) err = __unmarshalFindQueuesForClusterNode( src, *dst) if err != nil { return fmt.Errorf( "unable to unmarshal FindQueuesForClusterResponse.Node: %w", err) } } } return nil } type __premarshalFindQueuesForClusterResponse struct { Node json.RawMessage `json:"node"` } func (v *FindQueuesForClusterResponse) MarshalJSON() ([]byte, error) { premarshaled, err := v.__premarshalJSON() if err != nil { return nil, err } return json.Marshal(premarshaled) } func (v *FindQueuesForClusterResponse) __premarshalJSON() (*__premarshalFindQueuesForClusterResponse, error) { var retval __premarshalFindQueuesForClusterResponse { dst := &retval.Node src := v.Node if src != nil { var err error *dst, err = __marshalFindQueuesForClusterNode( src) if err != nil { return nil, fmt.Errorf( "unable to marshal FindQueuesForClusterResponse.Node: %w", err) } } } return &retval, nil } // FindUserByEmailOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type FindUserByEmailOrganization struct { // Returns users within the organization Members *FindUserByEmailOrganizationMembersOrganizationMemberConnection `json:"members"` } // GetMembers returns FindUserByEmailOrganization.Members, and is useful for accessing the field via an interface. func (v *FindUserByEmailOrganization) GetMembers() *FindUserByEmailOrganizationMembersOrganizationMemberConnection { return v.Members } // FindUserByEmailOrganizationMembersOrganizationMemberConnection includes the requested fields of the GraphQL type OrganizationMemberConnection. type FindUserByEmailOrganizationMembersOrganizationMemberConnection struct { Edges []*FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdge `json:"edges"` } // GetEdges returns FindUserByEmailOrganizationMembersOrganizationMemberConnection.Edges, and is useful for accessing the field via an interface. func (v *FindUserByEmailOrganizationMembersOrganizationMemberConnection) GetEdges() []*FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdge { return v.Edges } // FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdge includes the requested fields of the GraphQL type OrganizationMemberEdge. type FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdge struct { Node *FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMember `json:"node"` } // GetNode returns FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdge.Node, and is useful for accessing the field via an interface. func (v *FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdge) GetNode() *FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMember { return v.Node } // FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMember includes the requested fields of the GraphQL type OrganizationMember. // The GraphQL type's documentation follows. // // A member of an organization type FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMember struct { User FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMemberUser `json:"user"` } // GetUser returns FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMember.User, and is useful for accessing the field via an interface. func (v *FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMember) GetUser() FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMemberUser { return v.User } // FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMemberUser includes the requested fields of the GraphQL type User. // The GraphQL type's documentation follows. // // A user type FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMemberUser struct { Id string `json:"id"` } // GetId returns FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMemberUser.Id, and is useful for accessing the field via an interface. func (v *FindUserByEmailOrganizationMembersOrganizationMemberConnectionEdgesOrganizationMemberEdgeNodeOrganizationMemberUser) GetId() string { return v.Id } // FindUserByEmailResponse is returned by FindUserByEmail on success. type FindUserByEmailResponse struct { // Find an organization Organization *FindUserByEmailOrganization `json:"organization"` } // GetOrganization returns FindUserByEmailResponse.Organization, and is useful for accessing the field via an interface. func (v *FindUserByEmailResponse) GetOrganization() *FindUserByEmailOrganization { return v.Organization } // GetClusterQueueAgentOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type GetClusterQueueAgentOrganization struct { Agents *GetClusterQueueAgentOrganizationAgentsAgentConnection `json:"agents"` } // GetAgents returns GetClusterQueueAgentOrganization.Agents, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganization) GetAgents() *GetClusterQueueAgentOrganizationAgentsAgentConnection { return v.Agents } // GetClusterQueueAgentOrganizationAgentsAgentConnection includes the requested fields of the GraphQL type AgentConnection. type GetClusterQueueAgentOrganizationAgentsAgentConnection struct { Edges []*GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdge `json:"edges"` } // GetEdges returns GetClusterQueueAgentOrganizationAgentsAgentConnection.Edges, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnection) GetEdges() []*GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdge { return v.Edges } // GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdge includes the requested fields of the GraphQL type AgentEdge. type GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdge struct { Node *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent `json:"node"` } // GetNode returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdge.Node, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdge) GetNode() *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent { return v.Node } // GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent includes the requested fields of the GraphQL type Agent. // The GraphQL type's documentation follows. // // An agent type GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent struct { // The name of the agent Name string `json:"name"` // The hostname of the machine running the agent Hostname *string `json:"hostname"` // The version of the agent Version *string `json:"version"` Id string `json:"id"` ClusterQueue *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue `json:"clusterQueue"` } // GetName returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent.Name, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent) GetName() string { return v.Name } // GetHostname returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent.Hostname, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent) GetHostname() *string { return v.Hostname } // GetVersion returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent.Version, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent) GetVersion() *string { return v.Version } // GetId returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent.Id, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent) GetId() string { return v.Id } // GetClusterQueue returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent.ClusterQueue, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgent) GetClusterQueue() *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue { return v.ClusterQueue } // GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue includes the requested fields of the GraphQL type ClusterQueue. type GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue struct { Id string `json:"id"` // The public UUID for this cluster queue Uuid string `json:"uuid"` } // GetId returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue.Id, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue) GetId() string { return v.Id } // GetUuid returns GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue.Uuid, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentOrganizationAgentsAgentConnectionEdgesAgentEdgeNodeAgentClusterQueue) GetUuid() string { return v.Uuid } // GetClusterQueueAgentResponse is returned by GetClusterQueueAgent on success. type GetClusterQueueAgentResponse struct { // Find an organization Organization *GetClusterQueueAgentOrganization `json:"organization"` } // GetOrganization returns GetClusterQueueAgentResponse.Organization, and is useful for accessing the field via an interface. func (v *GetClusterQueueAgentResponse) GetOrganization() *GetClusterQueueAgentOrganization { return v.Organization } // GetClusterQueuesOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type GetClusterQueuesOrganization struct { // Return cluster in the Organization by UUID Cluster *GetClusterQueuesOrganizationCluster `json:"cluster"` } // GetCluster returns GetClusterQueuesOrganization.Cluster, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganization) GetCluster() *GetClusterQueuesOrganizationCluster { return v.Cluster } // GetClusterQueuesOrganizationCluster includes the requested fields of the GraphQL type Cluster. type GetClusterQueuesOrganizationCluster struct { // Name of the cluster Name string `json:"name"` // Description of the cluster Description *string `json:"description"` Queues *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnection `json:"queues"` } // GetName returns GetClusterQueuesOrganizationCluster.Name, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationCluster) GetName() string { return v.Name } // GetDescription returns GetClusterQueuesOrganizationCluster.Description, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationCluster) GetDescription() *string { return v.Description } // GetQueues returns GetClusterQueuesOrganizationCluster.Queues, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationCluster) GetQueues() *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnection { return v.Queues } // GetClusterQueuesOrganizationClusterQueuesClusterQueueConnection includes the requested fields of the GraphQL type ClusterQueueConnection. type GetClusterQueuesOrganizationClusterQueuesClusterQueueConnection struct { Edges []*GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge `json:"edges"` } // GetEdges returns GetClusterQueuesOrganizationClusterQueuesClusterQueueConnection.Edges, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnection) GetEdges() []*GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge { return v.Edges } // GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge includes the requested fields of the GraphQL type ClusterQueueEdge. type GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge struct { Node *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue `json:"node"` } // GetNode returns GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge.Node, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdge) GetNode() *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue { return v.Node } // GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue includes the requested fields of the GraphQL type ClusterQueue. type GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue struct { Id string `json:"id"` // The public UUID for this cluster queue Uuid string `json:"uuid"` Key string `json:"key"` Description *string `json:"description"` } // GetId returns GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue.Id, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue) GetId() string { return v.Id } // GetUuid returns GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue.Uuid, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue) GetUuid() string { return v.Uuid } // GetKey returns GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue.Key, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue) GetKey() string { return v.Key } // GetDescription returns GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue.Description, and is useful for accessing the field via an interface. func (v *GetClusterQueuesOrganizationClusterQueuesClusterQueueConnectionEdgesClusterQueueEdgeNodeClusterQueue) GetDescription() *string { return v.Description } // GetClusterQueuesResponse is returned by GetClusterQueues on success. type GetClusterQueuesResponse struct { // Find an organization Organization *GetClusterQueuesOrganization `json:"organization"` } // GetOrganization returns GetClusterQueuesResponse.Organization, and is useful for accessing the field via an interface. func (v *GetClusterQueuesResponse) GetOrganization() *GetClusterQueuesOrganization { return v.Organization } // GetOrganizationIDOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type GetOrganizationIDOrganization struct { Id string `json:"id"` } // GetId returns GetOrganizationIDOrganization.Id, and is useful for accessing the field via an interface. func (v *GetOrganizationIDOrganization) GetId() string { return v.Id } // GetOrganizationIDResponse is returned by GetOrganizationID on success. type GetOrganizationIDResponse struct { // Find an organization Organization *GetOrganizationIDOrganization `json:"organization"` } // GetOrganization returns GetOrganizationIDResponse.Organization, and is useful for accessing the field via an interface. func (v *GetOrganizationIDResponse) GetOrganization() *GetOrganizationIDOrganization { return v.Organization } // InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload includes the requested fields of the GraphQL type OrganizationInvitationCreatePayload. // The GraphQL type's documentation follows. // // Autogenerated return type of OrganizationInvitationCreate. type InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload struct { // A unique identifier for the client performing the mutation. ClientMutationId *string `json:"clientMutationId"` } // GetClientMutationId returns InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload.ClientMutationId, and is useful for accessing the field via an interface. func (v *InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload) GetClientMutationId() *string { return v.ClientMutationId } // InviteUserResponse is returned by InviteUser on success. type InviteUserResponse struct { // Send email invitations to this organization. OrganizationInvitationCreate *InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload `json:"organizationInvitationCreate"` } // GetOrganizationInvitationCreate returns InviteUserResponse.OrganizationInvitationCreate, and is useful for accessing the field via an interface. func (v *InviteUserResponse) GetOrganizationInvitationCreate() *InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload { return v.OrganizationInvitationCreate } // All the possible states a job can be in type JobStates string const ( // The job has just been created and doesn't have a state yet JobStatesPending JobStates = "PENDING" // The job is waiting on a `wait` step to finish JobStatesWaiting JobStates = "WAITING" // The job was in a `WAITING` state when the build failed JobStatesWaitingFailed JobStates = "WAITING_FAILED" // The job is waiting on a `block` step to finish JobStatesBlocked JobStates = "BLOCKED" // The job was in a `BLOCKED` state when the build failed JobStatesBlockedFailed JobStates = "BLOCKED_FAILED" // This `block` job has been manually unblocked JobStatesUnblocked JobStates = "UNBLOCKED" // This `block` job was in an `UNBLOCKED` state when the build failed JobStatesUnblockedFailed JobStates = "UNBLOCKED_FAILED" // The job is waiting on a concurrency group check before becoming either `LIMITED` or `SCHEDULED` JobStatesLimiting JobStates = "LIMITING" // The job is waiting for jobs with the same concurrency group to finish JobStatesLimited JobStates = "LIMITED" // The job is scheduled and waiting for an agent JobStatesScheduled JobStates = "SCHEDULED" // The job has been assigned to an agent, and it's waiting for it to accept JobStatesAssigned JobStates = "ASSIGNED" // The job was accepted by the agent, and now it's waiting to start running JobStatesAccepted JobStates = "ACCEPTED" // The job is running JobStatesRunning JobStates = "RUNNING" // The job has finished JobStatesFinished JobStates = "FINISHED" // The job is currently canceling JobStatesCanceling JobStates = "CANCELING" // The job was canceled JobStatesCanceled JobStates = "CANCELED" // The job is timing out for taking too long JobStatesTimingOut JobStates = "TIMING_OUT" // The job timed out JobStatesTimedOut JobStates = "TIMED_OUT" // The job was skipped JobStatesSkipped JobStates = "SKIPPED" // The jobs configuration means that it can't be run JobStatesBroken JobStates = "BROKEN" // The job expired before it was started on an agent JobStatesExpired JobStates = "EXPIRED" ) var AllJobStates = []JobStates{ JobStatesPending, JobStatesWaiting, JobStatesWaitingFailed, JobStatesBlocked, JobStatesBlockedFailed, JobStatesUnblocked, JobStatesUnblockedFailed, JobStatesLimiting, JobStatesLimited, JobStatesScheduled, JobStatesAssigned, JobStatesAccepted, JobStatesRunning, JobStatesFinished, JobStatesCanceling, JobStatesCanceled, JobStatesTimingOut, JobStatesTimedOut, JobStatesSkipped, JobStatesBroken, JobStatesExpired, } // ListJobsByAgentQueryRulesOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type ListJobsByAgentQueryRulesOrganization struct { Jobs *ListJobsByAgentQueryRulesOrganizationJobsJobConnection `json:"jobs"` } // GetJobs returns ListJobsByAgentQueryRulesOrganization.Jobs, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganization) GetJobs() *ListJobsByAgentQueryRulesOrganizationJobsJobConnection { return v.Jobs } // ListJobsByAgentQueryRulesOrganizationJobsJobConnection includes the requested fields of the GraphQL type JobConnection. type ListJobsByAgentQueryRulesOrganizationJobsJobConnection struct { Edges []*ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge `json:"edges"` PageInfo *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo `json:"pageInfo"` } // GetEdges returns ListJobsByAgentQueryRulesOrganizationJobsJobConnection.Edges, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnection) GetEdges() []*ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge { return v.Edges } // GetPageInfo returns ListJobsByAgentQueryRulesOrganizationJobsJobConnection.PageInfo, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnection) GetPageInfo() *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo { return v.PageInfo } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge includes the requested fields of the GraphQL type JobEdge. type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge struct { Node *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob `json:"-"` } // GetNode returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge.Node, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge) GetNode() *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob { return v.Node } func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge) UnmarshalJSON(b []byte) error { if string(b) == "null" { return nil } var firstPass struct { *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge Node json.RawMessage `json:"node"` graphql.NoUnmarshalJSON } firstPass.ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge = v err := json.Unmarshal(b, &firstPass) if err != nil { return err } { dst := &v.Node src := firstPass.Node if len(src) != 0 && string(src) != "null" { *dst = new(ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) err = __unmarshalListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob( src, *dst) if err != nil { return fmt.Errorf( "unable to unmarshal ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge.Node: %w", err) } } } return nil } type __premarshalListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge struct { Node json.RawMessage `json:"node"` } func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge) MarshalJSON() ([]byte, error) { premarshaled, err := v.__premarshalJSON() if err != nil { return nil, err } return json.Marshal(premarshaled) } func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge) __premarshalJSON() (*__premarshalListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge, error) { var retval __premarshalListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge { dst := &retval.Node src := v.Node if src != nil { var err error *dst, err = __marshalListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob( src) if err != nil { return nil, fmt.Errorf( "unable to marshal ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdge.Node: %w", err) } } } return &retval, nil } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob includes the requested fields of the GraphQL interface Job. // // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob is implemented by the following types: // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait // The GraphQL type's documentation follows. // // Kinds of jobs that can exist on a build type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob interface { implementsGraphQLInterfaceListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). GetTypename() *string } func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) implementsGraphQLInterfaceListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) implementsGraphQLInterfaceListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) implementsGraphQLInterfaceListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) implementsGraphQLInterfaceListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func __unmarshalListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob(b []byte, v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) error { if string(b) == "null" { return nil } var tn struct { TypeName string `json:"__typename"` } err := json.Unmarshal(b, &tn) if err != nil { return err } switch tn.TypeName { case "JobTypeBlock": *v = new(ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) return json.Unmarshal(b, *v) case "JobTypeCommand": *v = new(ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) return json.Unmarshal(b, *v) case "JobTypeTrigger": *v = new(ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) return json.Unmarshal(b, *v) case "JobTypeWait": *v = new(ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) return json.Unmarshal(b, *v) case "": return fmt.Errorf( "response was missing Job.__typename") default: return fmt.Errorf( `unexpected concrete type for ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob: "%v"`, tn.TypeName) } } func __marshalListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob(v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) ([]byte, error) { var typename string switch v := (*v).(type) { case *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock: typename = "JobTypeBlock" result := struct { TypeName string `json:"__typename"` *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock }{typename, v} return json.Marshal(result) case *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand: typename = "JobTypeCommand" result := struct { TypeName string `json:"__typename"` *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand }{typename, v} return json.Marshal(result) case *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger: typename = "JobTypeTrigger" result := struct { TypeName string `json:"__typename"` *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger }{typename, v} return json.Marshal(result) case *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait: typename = "JobTypeWait" result := struct { TypeName string `json:"__typename"` *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait }{typename, v} return json.Marshal(result) case nil: return []byte("null"), nil default: return nil, fmt.Errorf( `unexpected concrete type for ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJob: "%T"`, v) } } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock includes the requested fields of the GraphQL type JobTypeBlock. // The GraphQL type's documentation follows. // // A type of job that requires a user to unblock it before proceeding in a build pipeline type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) GetTypename() *string { return v.Typename } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand includes the requested fields of the GraphQL type JobTypeCommand. // The GraphQL type's documentation follows. // // A type of job that runs a command on an agent type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand struct { Typename *string `json:"__typename"` Id string `json:"id"` // The UUID for this job Uuid string `json:"uuid"` // The command the job will run Command *string `json:"command"` // The state of the job State JobStates `json:"state"` // The exit status returned by the command on the agent ExitStatus *string `json:"exitStatus"` // The URL for the job Url string `json:"url"` // The time when the job started running StartedAt *time.Time `json:"startedAt"` // The time when the job finished FinishedAt *time.Time `json:"finishedAt"` // The time when the job was created CreatedAt *time.Time `json:"createdAt"` // The agent that is running the job Agent *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent `json:"agent"` } // GetTypename returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetTypename() *string { return v.Typename } // GetId returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Id, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetId() string { return v.Id } // GetUuid returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Uuid, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetUuid() string { return v.Uuid } // GetCommand returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Command, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCommand() *string { return v.Command } // GetState returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.State, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetState() JobStates { return v.State } // GetExitStatus returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.ExitStatus, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetExitStatus() *string { return v.ExitStatus } // GetUrl returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Url, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetUrl() string { return v.Url } // GetStartedAt returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.StartedAt, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetStartedAt() *time.Time { return v.StartedAt } // GetFinishedAt returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.FinishedAt, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetFinishedAt() *time.Time { return v.FinishedAt } // GetCreatedAt returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.CreatedAt, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCreatedAt() *time.Time { return v.CreatedAt } // GetAgent returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Agent, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetAgent() *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent { return v.Agent } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent includes the requested fields of the GraphQL type Agent. // The GraphQL type's documentation follows. // // An agent type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent struct { Id string `json:"id"` // The name of the agent Name string `json:"name"` // The hostname of the machine running the agent Hostname *string `json:"hostname"` // The meta data this agent was stared with MetaData []string `json:"metaData"` } // GetId returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Id, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetId() string { return v.Id } // GetName returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Name, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetName() string { return v.Name } // GetHostname returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Hostname, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetHostname() *string { return v.Hostname } // GetMetaData returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.MetaData, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetMetaData() []string { return v.MetaData } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger includes the requested fields of the GraphQL type JobTypeTrigger. // The GraphQL type's documentation follows. // // A type of job that triggers another build on a pipeline type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) GetTypename() *string { return v.Typename } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait includes the requested fields of the GraphQL type JobTypeWait. // The GraphQL type's documentation follows. // // A type of job that waits for all previous jobs to pass before proceeding the build pipeline type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) GetTypename() *string { return v.Typename } // ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo includes the requested fields of the GraphQL type PageInfo. // The GraphQL type's documentation follows. // // Information about pagination in a connection. type ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo struct { // When paginating forwards, the cursor to continue. EndCursor *string `json:"endCursor"` // When paginating forwards, are there more items? HasNextPage bool `json:"hasNextPage"` } // GetEndCursor returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo.EndCursor, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo) GetEndCursor() *string { return v.EndCursor } // GetHasNextPage returns ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo.HasNextPage, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesOrganizationJobsJobConnectionPageInfo) GetHasNextPage() bool { return v.HasNextPage } // ListJobsByAgentQueryRulesResponse is returned by ListJobsByAgentQueryRules on success. type ListJobsByAgentQueryRulesResponse struct { // Find an organization Organization *ListJobsByAgentQueryRulesOrganization `json:"organization"` } // GetOrganization returns ListJobsByAgentQueryRulesResponse.Organization, and is useful for accessing the field via an interface. func (v *ListJobsByAgentQueryRulesResponse) GetOrganization() *ListJobsByAgentQueryRulesOrganization { return v.Organization } // ListJobsByQueueOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type ListJobsByQueueOrganization struct { Jobs *ListJobsByQueueOrganizationJobsJobConnection `json:"jobs"` } // GetJobs returns ListJobsByQueueOrganization.Jobs, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganization) GetJobs() *ListJobsByQueueOrganizationJobsJobConnection { return v.Jobs } // ListJobsByQueueOrganizationJobsJobConnection includes the requested fields of the GraphQL type JobConnection. type ListJobsByQueueOrganizationJobsJobConnection struct { Edges []*ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge `json:"edges"` PageInfo *ListJobsByQueueOrganizationJobsJobConnectionPageInfo `json:"pageInfo"` } // GetEdges returns ListJobsByQueueOrganizationJobsJobConnection.Edges, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnection) GetEdges() []*ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge { return v.Edges } // GetPageInfo returns ListJobsByQueueOrganizationJobsJobConnection.PageInfo, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnection) GetPageInfo() *ListJobsByQueueOrganizationJobsJobConnectionPageInfo { return v.PageInfo } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge includes the requested fields of the GraphQL type JobEdge. type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge struct { Node *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob `json:"-"` } // GetNode returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge.Node, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge) GetNode() *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob { return v.Node } func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge) UnmarshalJSON(b []byte) error { if string(b) == "null" { return nil } var firstPass struct { *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge Node json.RawMessage `json:"node"` graphql.NoUnmarshalJSON } firstPass.ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge = v err := json.Unmarshal(b, &firstPass) if err != nil { return err } { dst := &v.Node src := firstPass.Node if len(src) != 0 && string(src) != "null" { *dst = new(ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) err = __unmarshalListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob( src, *dst) if err != nil { return fmt.Errorf( "unable to unmarshal ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge.Node: %w", err) } } } return nil } type __premarshalListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge struct { Node json.RawMessage `json:"node"` } func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge) MarshalJSON() ([]byte, error) { premarshaled, err := v.__premarshalJSON() if err != nil { return nil, err } return json.Marshal(premarshaled) } func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge) __premarshalJSON() (*__premarshalListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge, error) { var retval __premarshalListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge { dst := &retval.Node src := v.Node if src != nil { var err error *dst, err = __marshalListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob( src) if err != nil { return nil, fmt.Errorf( "unable to marshal ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdge.Node: %w", err) } } } return &retval, nil } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob includes the requested fields of the GraphQL interface Job. // // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob is implemented by the following types: // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait // The GraphQL type's documentation follows. // // Kinds of jobs that can exist on a build type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob interface { implementsGraphQLInterfaceListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). GetTypename() *string } func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) implementsGraphQLInterfaceListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) implementsGraphQLInterfaceListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) implementsGraphQLInterfaceListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) implementsGraphQLInterfaceListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func __unmarshalListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob(b []byte, v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) error { if string(b) == "null" { return nil } var tn struct { TypeName string `json:"__typename"` } err := json.Unmarshal(b, &tn) if err != nil { return err } switch tn.TypeName { case "JobTypeBlock": *v = new(ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) return json.Unmarshal(b, *v) case "JobTypeCommand": *v = new(ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) return json.Unmarshal(b, *v) case "JobTypeTrigger": *v = new(ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) return json.Unmarshal(b, *v) case "JobTypeWait": *v = new(ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) return json.Unmarshal(b, *v) case "": return fmt.Errorf( "response was missing Job.__typename") default: return fmt.Errorf( `unexpected concrete type for ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob: "%v"`, tn.TypeName) } } func __marshalListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob(v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) ([]byte, error) { var typename string switch v := (*v).(type) { case *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock: typename = "JobTypeBlock" result := struct { TypeName string `json:"__typename"` *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock }{typename, v} return json.Marshal(result) case *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand: typename = "JobTypeCommand" result := struct { TypeName string `json:"__typename"` *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand }{typename, v} return json.Marshal(result) case *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger: typename = "JobTypeTrigger" result := struct { TypeName string `json:"__typename"` *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger }{typename, v} return json.Marshal(result) case *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait: typename = "JobTypeWait" result := struct { TypeName string `json:"__typename"` *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait }{typename, v} return json.Marshal(result) case nil: return []byte("null"), nil default: return nil, fmt.Errorf( `unexpected concrete type for ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJob: "%T"`, v) } } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock includes the requested fields of the GraphQL type JobTypeBlock. // The GraphQL type's documentation follows. // // A type of job that requires a user to unblock it before proceeding in a build pipeline type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) GetTypename() *string { return v.Typename } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand includes the requested fields of the GraphQL type JobTypeCommand. // The GraphQL type's documentation follows. // // A type of job that runs a command on an agent type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand struct { Typename *string `json:"__typename"` Id string `json:"id"` // The UUID for this job Uuid string `json:"uuid"` // The command the job will run Command *string `json:"command"` // The state of the job State JobStates `json:"state"` // The exit status returned by the command on the agent ExitStatus *string `json:"exitStatus"` // The URL for the job Url string `json:"url"` // The time when the job started running StartedAt *time.Time `json:"startedAt"` // The time when the job finished FinishedAt *time.Time `json:"finishedAt"` // The time when the job was created CreatedAt *time.Time `json:"createdAt"` // The cluster of this job Cluster *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster `json:"cluster"` // The cluster queue of this job ClusterQueue *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue `json:"clusterQueue"` // The agent that is running the job Agent *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent `json:"agent"` } // GetTypename returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetTypename() *string { return v.Typename } // GetId returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Id, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetId() string { return v.Id } // GetUuid returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Uuid, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetUuid() string { return v.Uuid } // GetCommand returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Command, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCommand() *string { return v.Command } // GetState returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.State, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetState() JobStates { return v.State } // GetExitStatus returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.ExitStatus, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetExitStatus() *string { return v.ExitStatus } // GetUrl returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Url, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetUrl() string { return v.Url } // GetStartedAt returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.StartedAt, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetStartedAt() *time.Time { return v.StartedAt } // GetFinishedAt returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.FinishedAt, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetFinishedAt() *time.Time { return v.FinishedAt } // GetCreatedAt returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.CreatedAt, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCreatedAt() *time.Time { return v.CreatedAt } // GetCluster returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Cluster, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCluster() *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster { return v.Cluster } // GetClusterQueue returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.ClusterQueue, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetClusterQueue() *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue { return v.ClusterQueue } // GetAgent returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Agent, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetAgent() *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent { return v.Agent } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent includes the requested fields of the GraphQL type Agent. // The GraphQL type's documentation follows. // // An agent type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent struct { Id string `json:"id"` // The name of the agent Name string `json:"name"` // The hostname of the machine running the agent Hostname *string `json:"hostname"` // The meta data this agent was stared with MetaData []string `json:"metaData"` } // GetId returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Id, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetId() string { return v.Id } // GetName returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Name, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetName() string { return v.Name } // GetHostname returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Hostname, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetHostname() *string { return v.Hostname } // GetMetaData returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.MetaData, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetMetaData() []string { return v.MetaData } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster includes the requested fields of the GraphQL type Cluster. type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster struct { Id string `json:"id"` // Name of the cluster Name string `json:"name"` } // GetId returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster.Id, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster) GetId() string { return v.Id } // GetName returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster.Name, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster) GetName() string { return v.Name } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue includes the requested fields of the GraphQL type ClusterQueue. type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue struct { Id string `json:"id"` Key string `json:"key"` } // GetId returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue.Id, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue) GetId() string { return v.Id } // GetKey returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue.Key, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue) GetKey() string { return v.Key } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger includes the requested fields of the GraphQL type JobTypeTrigger. // The GraphQL type's documentation follows. // // A type of job that triggers another build on a pipeline type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) GetTypename() *string { return v.Typename } // ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait includes the requested fields of the GraphQL type JobTypeWait. // The GraphQL type's documentation follows. // // A type of job that waits for all previous jobs to pass before proceeding the build pipeline type ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) GetTypename() *string { return v.Typename } // ListJobsByQueueOrganizationJobsJobConnectionPageInfo includes the requested fields of the GraphQL type PageInfo. // The GraphQL type's documentation follows. // // Information about pagination in a connection. type ListJobsByQueueOrganizationJobsJobConnectionPageInfo struct { // When paginating forwards, the cursor to continue. EndCursor *string `json:"endCursor"` // When paginating forwards, are there more items? HasNextPage bool `json:"hasNextPage"` } // GetEndCursor returns ListJobsByQueueOrganizationJobsJobConnectionPageInfo.EndCursor, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionPageInfo) GetEndCursor() *string { return v.EndCursor } // GetHasNextPage returns ListJobsByQueueOrganizationJobsJobConnectionPageInfo.HasNextPage, and is useful for accessing the field via an interface. func (v *ListJobsByQueueOrganizationJobsJobConnectionPageInfo) GetHasNextPage() bool { return v.HasNextPage } // ListJobsByQueueResponse is returned by ListJobsByQueue on success. type ListJobsByQueueResponse struct { // Find an organization Organization *ListJobsByQueueOrganization `json:"organization"` } // GetOrganization returns ListJobsByQueueResponse.Organization, and is useful for accessing the field via an interface. func (v *ListJobsByQueueResponse) GetOrganization() *ListJobsByQueueOrganization { return v.Organization } // ListJobsByStateOrganization includes the requested fields of the GraphQL type Organization. // The GraphQL type's documentation follows. // // An organization type ListJobsByStateOrganization struct { Jobs *ListJobsByStateOrganizationJobsJobConnection `json:"jobs"` } // GetJobs returns ListJobsByStateOrganization.Jobs, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganization) GetJobs() *ListJobsByStateOrganizationJobsJobConnection { return v.Jobs } // ListJobsByStateOrganizationJobsJobConnection includes the requested fields of the GraphQL type JobConnection. type ListJobsByStateOrganizationJobsJobConnection struct { Edges []*ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge `json:"edges"` PageInfo *ListJobsByStateOrganizationJobsJobConnectionPageInfo `json:"pageInfo"` } // GetEdges returns ListJobsByStateOrganizationJobsJobConnection.Edges, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnection) GetEdges() []*ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge { return v.Edges } // GetPageInfo returns ListJobsByStateOrganizationJobsJobConnection.PageInfo, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnection) GetPageInfo() *ListJobsByStateOrganizationJobsJobConnectionPageInfo { return v.PageInfo } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge includes the requested fields of the GraphQL type JobEdge. type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge struct { Node *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob `json:"-"` } // GetNode returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge.Node, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge) GetNode() *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob { return v.Node } func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge) UnmarshalJSON(b []byte) error { if string(b) == "null" { return nil } var firstPass struct { *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge Node json.RawMessage `json:"node"` graphql.NoUnmarshalJSON } firstPass.ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge = v err := json.Unmarshal(b, &firstPass) if err != nil { return err } { dst := &v.Node src := firstPass.Node if len(src) != 0 && string(src) != "null" { *dst = new(ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) err = __unmarshalListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob( src, *dst) if err != nil { return fmt.Errorf( "unable to unmarshal ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge.Node: %w", err) } } } return nil } type __premarshalListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge struct { Node json.RawMessage `json:"node"` } func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge) MarshalJSON() ([]byte, error) { premarshaled, err := v.__premarshalJSON() if err != nil { return nil, err } return json.Marshal(premarshaled) } func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge) __premarshalJSON() (*__premarshalListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge, error) { var retval __premarshalListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge { dst := &retval.Node src := v.Node if src != nil { var err error *dst, err = __marshalListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob( src) if err != nil { return nil, fmt.Errorf( "unable to marshal ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdge.Node: %w", err) } } } return &retval, nil } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob includes the requested fields of the GraphQL interface Job. // // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob is implemented by the following types: // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait // The GraphQL type's documentation follows. // // Kinds of jobs that can exist on a build type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob interface { implementsGraphQLInterfaceListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). GetTypename() *string } func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) implementsGraphQLInterfaceListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) implementsGraphQLInterfaceListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) implementsGraphQLInterfaceListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) implementsGraphQLInterfaceListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob() { } func __unmarshalListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob(b []byte, v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) error { if string(b) == "null" { return nil } var tn struct { TypeName string `json:"__typename"` } err := json.Unmarshal(b, &tn) if err != nil { return err } switch tn.TypeName { case "JobTypeBlock": *v = new(ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) return json.Unmarshal(b, *v) case "JobTypeCommand": *v = new(ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) return json.Unmarshal(b, *v) case "JobTypeTrigger": *v = new(ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) return json.Unmarshal(b, *v) case "JobTypeWait": *v = new(ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) return json.Unmarshal(b, *v) case "": return fmt.Errorf( "response was missing Job.__typename") default: return fmt.Errorf( `unexpected concrete type for ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob: "%v"`, tn.TypeName) } } func __marshalListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob(v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob) ([]byte, error) { var typename string switch v := (*v).(type) { case *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock: typename = "JobTypeBlock" result := struct { TypeName string `json:"__typename"` *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock }{typename, v} return json.Marshal(result) case *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand: typename = "JobTypeCommand" result := struct { TypeName string `json:"__typename"` *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand }{typename, v} return json.Marshal(result) case *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger: typename = "JobTypeTrigger" result := struct { TypeName string `json:"__typename"` *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger }{typename, v} return json.Marshal(result) case *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait: typename = "JobTypeWait" result := struct { TypeName string `json:"__typename"` *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait }{typename, v} return json.Marshal(result) case nil: return []byte("null"), nil default: return nil, fmt.Errorf( `unexpected concrete type for ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJob: "%T"`, v) } } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock includes the requested fields of the GraphQL type JobTypeBlock. // The GraphQL type's documentation follows. // // A type of job that requires a user to unblock it before proceeding in a build pipeline type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeBlock) GetTypename() *string { return v.Typename } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand includes the requested fields of the GraphQL type JobTypeCommand. // The GraphQL type's documentation follows. // // A type of job that runs a command on an agent type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand struct { Typename *string `json:"__typename"` Id string `json:"id"` // The UUID for this job Uuid string `json:"uuid"` // The label of the job Label *string `json:"label"` // The command the job will run Command *string `json:"command"` // The state of the job State JobStates `json:"state"` // The exit status returned by the command on the agent ExitStatus *string `json:"exitStatus"` // The URL for the job Url string `json:"url"` // The time when the job started running StartedAt *time.Time `json:"startedAt"` // The time when the job finished FinishedAt *time.Time `json:"finishedAt"` // The time when the job was created CreatedAt *time.Time `json:"createdAt"` // The cluster of this job Cluster *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster `json:"cluster"` // The cluster queue of this job ClusterQueue *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue `json:"clusterQueue"` // The agent that is running the job Agent *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent `json:"agent"` } // GetTypename returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetTypename() *string { return v.Typename } // GetId returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Id, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetId() string { return v.Id } // GetUuid returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Uuid, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetUuid() string { return v.Uuid } // GetLabel returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Label, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetLabel() *string { return v.Label } // GetCommand returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Command, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCommand() *string { return v.Command } // GetState returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.State, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetState() JobStates { return v.State } // GetExitStatus returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.ExitStatus, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetExitStatus() *string { return v.ExitStatus } // GetUrl returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Url, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetUrl() string { return v.Url } // GetStartedAt returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.StartedAt, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetStartedAt() *time.Time { return v.StartedAt } // GetFinishedAt returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.FinishedAt, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetFinishedAt() *time.Time { return v.FinishedAt } // GetCreatedAt returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.CreatedAt, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCreatedAt() *time.Time { return v.CreatedAt } // GetCluster returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Cluster, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetCluster() *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster { return v.Cluster } // GetClusterQueue returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.ClusterQueue, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetClusterQueue() *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue { return v.ClusterQueue } // GetAgent returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand.Agent, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommand) GetAgent() *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent { return v.Agent } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent includes the requested fields of the GraphQL type Agent. // The GraphQL type's documentation follows. // // An agent type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent struct { Id string `json:"id"` // The name of the agent Name string `json:"name"` // The hostname of the machine running the agent Hostname *string `json:"hostname"` // The meta data this agent was stared with MetaData []string `json:"metaData"` } // GetId returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Id, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetId() string { return v.Id } // GetName returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Name, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetName() string { return v.Name } // GetHostname returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.Hostname, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetHostname() *string { return v.Hostname } // GetMetaData returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent.MetaData, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandAgent) GetMetaData() []string { return v.MetaData } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster includes the requested fields of the GraphQL type Cluster. type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster struct { Id string `json:"id"` // Name of the cluster Name string `json:"name"` } // GetId returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster.Id, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster) GetId() string { return v.Id } // GetName returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster.Name, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandCluster) GetName() string { return v.Name } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue includes the requested fields of the GraphQL type ClusterQueue. type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue struct { Id string `json:"id"` Key string `json:"key"` } // GetId returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue.Id, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue) GetId() string { return v.Id } // GetKey returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue.Key, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeCommandClusterQueue) GetKey() string { return v.Key } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger includes the requested fields of the GraphQL type JobTypeTrigger. // The GraphQL type's documentation follows. // // A type of job that triggers another build on a pipeline type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeTrigger) GetTypename() *string { return v.Typename } // ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait includes the requested fields of the GraphQL type JobTypeWait. // The GraphQL type's documentation follows. // // A type of job that waits for all previous jobs to pass before proceeding the build pipeline type ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait struct { Typename *string `json:"__typename"` } // GetTypename returns ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait.Typename, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionEdgesJobEdgeNodeJobTypeWait) GetTypename() *string { return v.Typename } // ListJobsByStateOrganizationJobsJobConnectionPageInfo includes the requested fields of the GraphQL type PageInfo. // The GraphQL type's documentation follows. // // Information about pagination in a connection. type ListJobsByStateOrganizationJobsJobConnectionPageInfo struct { // When paginating forwards, the cursor to continue. EndCursor *string `json:"endCursor"` // When paginating forwards, are there more items? HasNextPage bool `json:"hasNextPage"` } // GetEndCursor returns ListJobsByStateOrganizationJobsJobConnectionPageInfo.EndCursor, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionPageInfo) GetEndCursor() *string { return v.EndCursor } // GetHasNextPage returns ListJobsByStateOrganizationJobsJobConnectionPageInfo.HasNextPage, and is useful for accessing the field via an interface. func (v *ListJobsByStateOrganizationJobsJobConnectionPageInfo) GetHasNextPage() bool { return v.HasNextPage } // ListJobsByStateResponse is returned by ListJobsByState on success. type ListJobsByStateResponse struct { // Find an organization Organization *ListJobsByStateOrganization `json:"organization"` } // GetOrganization returns ListJobsByStateResponse.Organization, and is useful for accessing the field via an interface. func (v *ListJobsByStateResponse) GetOrganization() *ListJobsByStateOrganization { return v.Organization } // PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload includes the requested fields of the GraphQL type PipelineCreateWebhookPayload. // The GraphQL type's documentation follows. // // Autogenerated return type of PipelineCreateWebhook. type PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload struct { // A unique identifier for the client performing the mutation. ClientMutationId *string `json:"clientMutationId"` PipelineID string `json:"pipelineID"` } // GetClientMutationId returns PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload.ClientMutationId, and is useful for accessing the field via an interface. func (v *PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload) GetClientMutationId() *string { return v.ClientMutationId } // GetPipelineID returns PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload.PipelineID, and is useful for accessing the field via an interface. func (v *PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload) GetPipelineID() string { return v.PipelineID } // PipelineCreateWebhookResponse is returned by PipelineCreateWebhook on success. type PipelineCreateWebhookResponse struct { // Create SCM webhooks for a pipeline. PipelineCreateWebhook *PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload `json:"pipelineCreateWebhook"` } // GetPipelineCreateWebhook returns PipelineCreateWebhookResponse.PipelineCreateWebhook, and is useful for accessing the field via an interface. func (v *PipelineCreateWebhookResponse) GetPipelineCreateWebhook() *PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload { return v.PipelineCreateWebhook } // RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload includes the requested fields of the GraphQL type JobTypeCommandRetryPayload. // The GraphQL type's documentation follows. // // Autogenerated return type of JobTypeCommandRetry. type RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload struct { JobTypeCommand RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand `json:"jobTypeCommand"` } // GetJobTypeCommand returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload.JobTypeCommand, and is useful for accessing the field via an interface. func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload) GetJobTypeCommand() RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand { return v.JobTypeCommand } // RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand includes the requested fields of the GraphQL type JobTypeCommand. // The GraphQL type's documentation follows. // // A type of job that runs a command on an agent type RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand struct { Id string `json:"id"` // The state of the job State JobStates `json:"state"` // The URL for the job Url string `json:"url"` } // GetId returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand.Id, and is useful for accessing the field via an interface. func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand) GetId() string { return v.Id } // GetState returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand.State, and is useful for accessing the field via an interface. func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand) GetState() JobStates { return v.State } // GetUrl returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand.Url, and is useful for accessing the field via an interface. func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand) GetUrl() string { return v.Url } // RetryJobResponse is returned by RetryJob on success. type RetryJobResponse struct { // Retry a job. JobTypeCommandRetry *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload `json:"jobTypeCommandRetry"` } // GetJobTypeCommandRetry returns RetryJobResponse.JobTypeCommandRetry, and is useful for accessing the field via an interface. func (v *RetryJobResponse) GetJobTypeCommandRetry() *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload { return v.JobTypeCommandRetry } // UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload includes the requested fields of the GraphQL type JobTypeBlockUnblockPayload. // The GraphQL type's documentation follows. // // Autogenerated return type of JobTypeBlockUnblock. type UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload struct { JobTypeBlock UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock `json:"jobTypeBlock"` } // GetJobTypeBlock returns UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload.JobTypeBlock, and is useful for accessing the field via an interface. func (v *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload) GetJobTypeBlock() UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock { return v.JobTypeBlock } // UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock includes the requested fields of the GraphQL type JobTypeBlock. // The GraphQL type's documentation follows. // // A type of job that requires a user to unblock it before proceeding in a build pipeline type UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock struct { Id string `json:"id"` // The state of the job State JobStates `json:"state"` // Whether or not this job can be unblocked yet (may be waiting on another job to finish) IsUnblockable *bool `json:"isUnblockable"` // The build that this job is a part of Build *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlockBuild `json:"build"` } // GetId returns UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock.Id, and is useful for accessing the field via an interface. func (v *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock) GetId() string { return v.Id } // GetState returns UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock.State, and is useful for accessing the field via an interface. func (v *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock) GetState() JobStates { return v.State } // GetIsUnblockable returns UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock.IsUnblockable, and is useful for accessing the field via an interface. func (v *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock) GetIsUnblockable() *bool { return v.IsUnblockable } // GetBuild returns UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock.Build, and is useful for accessing the field via an interface. func (v *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlock) GetBuild() *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlockBuild { return v.Build } // UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlockBuild includes the requested fields of the GraphQL type Build. // The GraphQL type's documentation follows. // // A build from a pipeline type UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlockBuild struct { // The URL for the build Url string `json:"url"` } // GetUrl returns UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlockBuild.Url, and is useful for accessing the field via an interface. func (v *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayloadJobTypeBlockBuild) GetUrl() string { return v.Url } // UnblockJobResponse is returned by UnblockJob on success. type UnblockJobResponse struct { // Unblocks a build's "Block pipeline" job. JobTypeBlockUnblock *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload `json:"jobTypeBlockUnblock"` } // GetJobTypeBlockUnblock returns UnblockJobResponse.JobTypeBlockUnblock, and is useful for accessing the field via an interface. func (v *UnblockJobResponse) GetJobTypeBlockUnblock() *UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload { return v.JobTypeBlockUnblock } // __CancelJobInput is used internally by genqlient type __CancelJobInput struct { JobId string `json:"jobId"` } // GetJobId returns __CancelJobInput.JobId, and is useful for accessing the field via an interface. func (v *__CancelJobInput) GetJobId() string { return v.JobId } // __FindClustersInput is used internally by genqlient type __FindClustersInput struct { Org string `json:"org"` Cursor *string `json:"cursor"` } // GetOrg returns __FindClustersInput.Org, and is useful for accessing the field via an interface. func (v *__FindClustersInput) GetOrg() string { return v.Org } // GetCursor returns __FindClustersInput.Cursor, and is useful for accessing the field via an interface. func (v *__FindClustersInput) GetCursor() *string { return v.Cursor } // __FindQueuesForClusterInput is used internally by genqlient type __FindQueuesForClusterInput struct { ClusterId string `json:"clusterId"` Cursor *string `json:"cursor"` } // GetClusterId returns __FindQueuesForClusterInput.ClusterId, and is useful for accessing the field via an interface. func (v *__FindQueuesForClusterInput) GetClusterId() string { return v.ClusterId } // GetCursor returns __FindQueuesForClusterInput.Cursor, and is useful for accessing the field via an interface. func (v *__FindQueuesForClusterInput) GetCursor() *string { return v.Cursor } // __FindUserByEmailInput is used internally by genqlient type __FindUserByEmailInput struct { Organization string `json:"organization"` Email string `json:"email"` } // GetOrganization returns __FindUserByEmailInput.Organization, and is useful for accessing the field via an interface. func (v *__FindUserByEmailInput) GetOrganization() string { return v.Organization } // GetEmail returns __FindUserByEmailInput.Email, and is useful for accessing the field via an interface. func (v *__FindUserByEmailInput) GetEmail() string { return v.Email } // __GetClusterQueueAgentInput is used internally by genqlient type __GetClusterQueueAgentInput struct { OrgSlug string `json:"orgSlug"` QueueId []string `json:"queueId"` } // GetOrgSlug returns __GetClusterQueueAgentInput.OrgSlug, and is useful for accessing the field via an interface. func (v *__GetClusterQueueAgentInput) GetOrgSlug() string { return v.OrgSlug } // GetQueueId returns __GetClusterQueueAgentInput.QueueId, and is useful for accessing the field via an interface. func (v *__GetClusterQueueAgentInput) GetQueueId() []string { return v.QueueId } // __GetClusterQueuesInput is used internally by genqlient type __GetClusterQueuesInput struct { OrgSlug string `json:"orgSlug"` ClusterId string `json:"clusterId"` } // GetOrgSlug returns __GetClusterQueuesInput.OrgSlug, and is useful for accessing the field via an interface. func (v *__GetClusterQueuesInput) GetOrgSlug() string { return v.OrgSlug } // GetClusterId returns __GetClusterQueuesInput.ClusterId, and is useful for accessing the field via an interface. func (v *__GetClusterQueuesInput) GetClusterId() string { return v.ClusterId } // __GetOrganizationIDInput is used internally by genqlient type __GetOrganizationIDInput struct { Slug string `json:"slug"` } // GetSlug returns __GetOrganizationIDInput.Slug, and is useful for accessing the field via an interface. func (v *__GetOrganizationIDInput) GetSlug() string { return v.Slug } // __InviteUserInput is used internally by genqlient type __InviteUserInput struct { Organization string `json:"organization"` Emails []string `json:"emails"` } // GetOrganization returns __InviteUserInput.Organization, and is useful for accessing the field via an interface. func (v *__InviteUserInput) GetOrganization() string { return v.Organization } // GetEmails returns __InviteUserInput.Emails, and is useful for accessing the field via an interface. func (v *__InviteUserInput) GetEmails() []string { return v.Emails } // __ListJobsByAgentQueryRulesInput is used internally by genqlient type __ListJobsByAgentQueryRulesInput struct { Org string `json:"org"` AgentQueryRules []string `json:"agentQueryRules"` First *int `json:"first"` After *string `json:"after"` } // GetOrg returns __ListJobsByAgentQueryRulesInput.Org, and is useful for accessing the field via an interface. func (v *__ListJobsByAgentQueryRulesInput) GetOrg() string { return v.Org } // GetAgentQueryRules returns __ListJobsByAgentQueryRulesInput.AgentQueryRules, and is useful for accessing the field via an interface. func (v *__ListJobsByAgentQueryRulesInput) GetAgentQueryRules() []string { return v.AgentQueryRules } // GetFirst returns __ListJobsByAgentQueryRulesInput.First, and is useful for accessing the field via an interface. func (v *__ListJobsByAgentQueryRulesInput) GetFirst() *int { return v.First } // GetAfter returns __ListJobsByAgentQueryRulesInput.After, and is useful for accessing the field via an interface. func (v *__ListJobsByAgentQueryRulesInput) GetAfter() *string { return v.After } // __ListJobsByQueueInput is used internally by genqlient type __ListJobsByQueueInput struct { Org string `json:"org"` ClusterQueue []string `json:"clusterQueue"` First *int `json:"first"` After *string `json:"after"` } // GetOrg returns __ListJobsByQueueInput.Org, and is useful for accessing the field via an interface. func (v *__ListJobsByQueueInput) GetOrg() string { return v.Org } // GetClusterQueue returns __ListJobsByQueueInput.ClusterQueue, and is useful for accessing the field via an interface. func (v *__ListJobsByQueueInput) GetClusterQueue() []string { return v.ClusterQueue } // GetFirst returns __ListJobsByQueueInput.First, and is useful for accessing the field via an interface. func (v *__ListJobsByQueueInput) GetFirst() *int { return v.First } // GetAfter returns __ListJobsByQueueInput.After, and is useful for accessing the field via an interface. func (v *__ListJobsByQueueInput) GetAfter() *string { return v.After } // __ListJobsByStateInput is used internally by genqlient type __ListJobsByStateInput struct { Org string `json:"org"` State []JobStates `json:"state"` First *int `json:"first"` After *string `json:"after"` } // GetOrg returns __ListJobsByStateInput.Org, and is useful for accessing the field via an interface. func (v *__ListJobsByStateInput) GetOrg() string { return v.Org } // GetState returns __ListJobsByStateInput.State, and is useful for accessing the field via an interface. func (v *__ListJobsByStateInput) GetState() []JobStates { return v.State } // GetFirst returns __ListJobsByStateInput.First, and is useful for accessing the field via an interface. func (v *__ListJobsByStateInput) GetFirst() *int { return v.First } // GetAfter returns __ListJobsByStateInput.After, and is useful for accessing the field via an interface. func (v *__ListJobsByStateInput) GetAfter() *string { return v.After } // __PipelineCreateWebhookInput is used internally by genqlient type __PipelineCreateWebhookInput struct { Id string `json:"id"` } // GetId returns __PipelineCreateWebhookInput.Id, and is useful for accessing the field via an interface. func (v *__PipelineCreateWebhookInput) GetId() string { return v.Id } // __RetryJobInput is used internally by genqlient type __RetryJobInput struct { Id string `json:"id"` } // GetId returns __RetryJobInput.Id, and is useful for accessing the field via an interface. func (v *__RetryJobInput) GetId() string { return v.Id } // __UnblockJobInput is used internally by genqlient type __UnblockJobInput struct { Id string `json:"id"` Fields *string `json:"fields"` } // GetId returns __UnblockJobInput.Id, and is useful for accessing the field via an interface. func (v *__UnblockJobInput) GetId() string { return v.Id } // GetFields returns __UnblockJobInput.Fields, and is useful for accessing the field via an interface. func (v *__UnblockJobInput) GetFields() *string { return v.Fields } // The mutation executed by CancelJob. const CancelJob_Operation = ` mutation CancelJob ($jobId: ID!) { jobTypeCommandCancel(input: {id:$jobId}) { clientMutationId jobTypeCommand { id uuid state url } } } ` func CancelJob( ctx_ context.Context, client_ graphql.Client, jobId string, ) (data_ *CancelJobResponse, err_ error) { req_ := &graphql.Request{ OpName: "CancelJob", Query: CancelJob_Operation, Variables: &__CancelJobInput{ JobId: jobId, }, } data_ = &CancelJobResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by FindClusters. const FindClusters_Operation = ` query FindClusters ($org: ID!, $cursor: String) { organization(slug: $org) { clusters(first: 100, after: $cursor) { edges { node { id name } } pageInfo { hasNextPage endCursor } } } } ` func FindClusters( ctx_ context.Context, client_ graphql.Client, org string, cursor *string, ) (data_ *FindClustersResponse, err_ error) { req_ := &graphql.Request{ OpName: "FindClusters", Query: FindClusters_Operation, Variables: &__FindClustersInput{ Org: org, Cursor: cursor, }, } data_ = &FindClustersResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by FindQueuesForCluster. const FindQueuesForCluster_Operation = ` query FindQueuesForCluster ($clusterId: ID!, $cursor: String) { node(id: $clusterId) { __typename ... on Cluster { id name queues(first: 100, after: $cursor) { edges { node { id key } } pageInfo { hasNextPage endCursor } } } } } ` func FindQueuesForCluster( ctx_ context.Context, client_ graphql.Client, clusterId string, cursor *string, ) (data_ *FindQueuesForClusterResponse, err_ error) { req_ := &graphql.Request{ OpName: "FindQueuesForCluster", Query: FindQueuesForCluster_Operation, Variables: &__FindQueuesForClusterInput{ ClusterId: clusterId, Cursor: cursor, }, } data_ = &FindQueuesForClusterResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by FindUserByEmail. const FindUserByEmail_Operation = ` query FindUserByEmail ($organization: ID!, $email: String!) { organization(slug: $organization) { members(first: 1, email: $email) { edges { node { user { id } } } } } } ` func FindUserByEmail( ctx_ context.Context, client_ graphql.Client, organization string, email string, ) (data_ *FindUserByEmailResponse, err_ error) { req_ := &graphql.Request{ OpName: "FindUserByEmail", Query: FindUserByEmail_Operation, Variables: &__FindUserByEmailInput{ Organization: organization, Email: email, }, } data_ = &FindUserByEmailResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by GetClusterQueueAgent. const GetClusterQueueAgent_Operation = ` query GetClusterQueueAgent ($orgSlug: ID!, $queueId: [ID!]) { organization(slug: $orgSlug) { agents(first: 10, clusterQueue: $queueId) { edges { node { name hostname version id clusterQueue { id uuid } } } } } } ` func GetClusterQueueAgent( ctx_ context.Context, client_ graphql.Client, orgSlug string, queueId []string, ) (data_ *GetClusterQueueAgentResponse, err_ error) { req_ := &graphql.Request{ OpName: "GetClusterQueueAgent", Query: GetClusterQueueAgent_Operation, Variables: &__GetClusterQueueAgentInput{ OrgSlug: orgSlug, QueueId: queueId, }, } data_ = &GetClusterQueueAgentResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by GetClusterQueues. const GetClusterQueues_Operation = ` query GetClusterQueues ($orgSlug: ID!, $clusterId: ID!) { organization(slug: $orgSlug) { cluster(id: $clusterId) { name description queues(first: 10) { edges { node { id uuid key description } } } } } } ` func GetClusterQueues( ctx_ context.Context, client_ graphql.Client, orgSlug string, clusterId string, ) (data_ *GetClusterQueuesResponse, err_ error) { req_ := &graphql.Request{ OpName: "GetClusterQueues", Query: GetClusterQueues_Operation, Variables: &__GetClusterQueuesInput{ OrgSlug: orgSlug, ClusterId: clusterId, }, } data_ = &GetClusterQueuesResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by GetOrganizationID. const GetOrganizationID_Operation = ` query GetOrganizationID ($slug: ID!) { organization(slug: $slug) { id } } ` func GetOrganizationID( ctx_ context.Context, client_ graphql.Client, slug string, ) (data_ *GetOrganizationIDResponse, err_ error) { req_ := &graphql.Request{ OpName: "GetOrganizationID", Query: GetOrganizationID_Operation, Variables: &__GetOrganizationIDInput{ Slug: slug, }, } data_ = &GetOrganizationIDResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The mutation executed by InviteUser. const InviteUser_Operation = ` mutation InviteUser ($organization: ID!, $emails: [String!]!) { organizationInvitationCreate(input: {organizationID:$organization,emails:$emails}) { clientMutationId } } ` func InviteUser( ctx_ context.Context, client_ graphql.Client, organization string, emails []string, ) (data_ *InviteUserResponse, err_ error) { req_ := &graphql.Request{ OpName: "InviteUser", Query: InviteUser_Operation, Variables: &__InviteUserInput{ Organization: organization, Emails: emails, }, } data_ = &InviteUserResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by ListJobsByAgentQueryRules. const ListJobsByAgentQueryRules_Operation = ` query ListJobsByAgentQueryRules ($org: ID!, $agentQueryRules: [String!], $first: Int, $after: String) { organization(slug: $org) { jobs(first: $first, after: $after, agentQueryRules: $agentQueryRules) { edges { node { __typename ... on JobTypeCommand { id uuid command state exitStatus url startedAt finishedAt createdAt agent { id name hostname metaData } } } } pageInfo { endCursor hasNextPage } } } } ` func ListJobsByAgentQueryRules( ctx_ context.Context, client_ graphql.Client, org string, agentQueryRules []string, first *int, after *string, ) (data_ *ListJobsByAgentQueryRulesResponse, err_ error) { req_ := &graphql.Request{ OpName: "ListJobsByAgentQueryRules", Query: ListJobsByAgentQueryRules_Operation, Variables: &__ListJobsByAgentQueryRulesInput{ Org: org, AgentQueryRules: agentQueryRules, First: first, After: after, }, } data_ = &ListJobsByAgentQueryRulesResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by ListJobsByQueue. const ListJobsByQueue_Operation = ` query ListJobsByQueue ($org: ID!, $clusterQueue: [ID!], $first: Int, $after: String) { organization(slug: $org) { jobs(clusterQueue: $clusterQueue, first: $first, after: $after) { edges { node { __typename ... on JobTypeCommand { id uuid command state exitStatus url startedAt finishedAt createdAt cluster { id name } clusterQueue { id key } agent { id name hostname metaData } } } } pageInfo { endCursor hasNextPage } } } } ` func ListJobsByQueue( ctx_ context.Context, client_ graphql.Client, org string, clusterQueue []string, first *int, after *string, ) (data_ *ListJobsByQueueResponse, err_ error) { req_ := &graphql.Request{ OpName: "ListJobsByQueue", Query: ListJobsByQueue_Operation, Variables: &__ListJobsByQueueInput{ Org: org, ClusterQueue: clusterQueue, First: first, After: after, }, } data_ = &ListJobsByQueueResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The query executed by ListJobsByState. const ListJobsByState_Operation = ` query ListJobsByState ($org: ID!, $state: [JobStates!], $first: Int, $after: String) { organization(slug: $org) { jobs(state: $state, first: $first, after: $after) { edges { node { __typename ... on JobTypeCommand { id uuid label command state exitStatus url startedAt finishedAt createdAt cluster { id name } clusterQueue { id key } agent { id name hostname metaData } } } } pageInfo { endCursor hasNextPage } } } } ` func ListJobsByState( ctx_ context.Context, client_ graphql.Client, org string, state []JobStates, first *int, after *string, ) (data_ *ListJobsByStateResponse, err_ error) { req_ := &graphql.Request{ OpName: "ListJobsByState", Query: ListJobsByState_Operation, Variables: &__ListJobsByStateInput{ Org: org, State: state, First: first, After: after, }, } data_ = &ListJobsByStateResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The mutation executed by PipelineCreateWebhook. const PipelineCreateWebhook_Operation = ` mutation PipelineCreateWebhook ($id: ID!) { pipelineCreateWebhook(input: {id:$id}) { clientMutationId pipelineID } } ` func PipelineCreateWebhook( ctx_ context.Context, client_ graphql.Client, id string, ) (data_ *PipelineCreateWebhookResponse, err_ error) { req_ := &graphql.Request{ OpName: "PipelineCreateWebhook", Query: PipelineCreateWebhook_Operation, Variables: &__PipelineCreateWebhookInput{ Id: id, }, } data_ = &PipelineCreateWebhookResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The mutation executed by RetryJob. const RetryJob_Operation = ` mutation RetryJob ($id: ID!) { jobTypeCommandRetry(input: {id:$id}) { jobTypeCommand { id state url } } } ` func RetryJob( ctx_ context.Context, client_ graphql.Client, id string, ) (data_ *RetryJobResponse, err_ error) { req_ := &graphql.Request{ OpName: "RetryJob", Query: RetryJob_Operation, Variables: &__RetryJobInput{ Id: id, }, } data_ = &RetryJobResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } // The mutation executed by UnblockJob. const UnblockJob_Operation = ` mutation UnblockJob ($id: ID!, $fields: JSON) { jobTypeBlockUnblock(input: {id:$id,fields:$fields}) { jobTypeBlock { id state isUnblockable build { url } } } } ` func UnblockJob( ctx_ context.Context, client_ graphql.Client, id string, fields *string, ) (data_ *UnblockJobResponse, err_ error) { req_ := &graphql.Request{ OpName: "UnblockJob", Query: UnblockJob_Operation, Variables: &__UnblockJobInput{ Id: id, Fields: fields, }, } data_ = &UnblockJobResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( ctx_, req_, resp_, ) return data_, err_ } ================================================ FILE: internal/http/README.md ================================================ # HTTP Client Package This package provides a common HTTP client with standardized headers and error handling for Buildkite API requests. ## Features - Standardized authorization header handling - Common error handling for API responses - Support for different HTTP methods (GET, POST, PUT, DELETE) - JSON request and response handling - Configurable base URL and user agent ## Usage ### Creating a client ```go import ( "github.com/buildkite/cli/v3/internal/http" ) // Basic client with token client := http.NewClient("your-api-token") // Client with custom base URL client := http.NewClient( "your-api-token", http.WithBaseURL("https://api.example.com"), ) // Client with custom user agent client := http.NewClient( "your-api-token", http.WithUserAgent("my-app/1.0"), ) // Client with custom HTTP client client := http.NewClient( "your-api-token", http.WithHTTPClient(customHTTPClient), ) ``` ### Making requests ```go // GET request var response SomeResponseType err := client.Get(ctx, "/endpoint", &response) // POST request with body requestBody := map[string]string{"key": "value"} var response SomeResponseType err := client.Post(ctx, "/endpoint", requestBody, &response) // PUT request err := client.Put(ctx, "/endpoint", requestBody, &response) // DELETE request err := client.Delete(ctx, "/endpoint", &response) // Custom method err := client.Do(ctx, "PATCH", "/endpoint", requestBody, &response) ``` ### Error handling ```go err := client.Get(ctx, "/endpoint", &response) if err != nil { // Check if it's an HTTP error if httpErr, ok := err.(*http.ErrorResponse); ok { fmt.Printf("HTTP error: %d %s\n", httpErr.StatusCode, httpErr.Status) fmt.Printf("Response body: %s\n", httpErr.Body) } else { fmt.Printf("Other error: %v\n", err) } } ``` ================================================ FILE: internal/http/client.go ================================================ package http import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ) // ErrorResponse represents an error response from the API type ErrorResponse struct { StatusCode int Status string URL string Body []byte Headers http.Header } // Error implements the error interface func (e *ErrorResponse) Error() string { msg := fmt.Sprintf("HTTP request failed: %d %s (%s)", e.StatusCode, e.Status, e.URL) if len(e.Body) > 0 { // Truncate body if it's very long for the error message bodyStr := string(e.Body) if len(bodyStr) > 200 { bodyStr = bodyStr[:200] + "..." } msg += fmt.Sprintf(": %s", bodyStr) } return msg } // Client is an HTTP client that handles common operations for Buildkite API requests type Client struct { baseURL string token string userAgent string client *http.Client } // ClientOption is a function that modifies a Client type ClientOption func(*Client) // WithBaseURL sets the base URL for API requests func WithBaseURL(baseURL string) ClientOption { return func(c *Client) { c.baseURL = baseURL } } // WithUserAgent sets the User-Agent header for requests func WithUserAgent(userAgent string) ClientOption { return func(c *Client) { c.userAgent = userAgent } } // WithHTTPClient sets the underlying HTTP client func WithHTTPClient(client *http.Client) ClientOption { return func(c *Client) { c.client = client } } // NewClient creates a new HTTP client with the given token and options func NewClient(token string, opts ...ClientOption) *Client { c := &Client{ baseURL: "https://api.buildkite.com", token: token, userAgent: "buildkite-cli", client: http.DefaultClient, } for _, opt := range opts { opt(c) } return c } // Get performs a GET request to the specified endpoint func (c *Client) Get(ctx context.Context, endpoint string, v interface{}) error { return c.Do(ctx, http.MethodGet, endpoint, nil, v) } // Post performs a POST request to the specified endpoint with the given body func (c *Client) Post(ctx context.Context, endpoint string, body interface{}, v interface{}) error { return c.Do(ctx, http.MethodPost, endpoint, body, v) } // Put performs a PUT request to the specified endpoint with the given body func (c *Client) Put(ctx context.Context, endpoint string, body interface{}, v interface{}) error { return c.Do(ctx, http.MethodPut, endpoint, body, v) } // Delete performs a DELETE request to the specified endpoint func (c *Client) Delete(ctx context.Context, endpoint string, v interface{}) error { return c.Do(ctx, http.MethodDelete, endpoint, nil, v) } // IsNotFound returns true if the error is a 404 Not Found func (e *ErrorResponse) IsNotFound() bool { return e.StatusCode == http.StatusNotFound } // IsUnauthorized returns true if the error is a 401 Unauthorized func (e *ErrorResponse) IsUnauthorized() bool { return e.StatusCode == http.StatusUnauthorized } // IsForbidden returns true if the error is a 403 Forbidden func (e *ErrorResponse) IsForbidden() bool { return e.StatusCode == http.StatusForbidden } // IsBadRequest returns true if the error is a 400 Bad Request func (e *ErrorResponse) IsBadRequest() bool { return e.StatusCode == http.StatusBadRequest } // IsServerError returns true if the error is a 5xx Server Error func (e *ErrorResponse) IsServerError() bool { return e.StatusCode >= 500 } // IsTooManyRequests returns true if the error is a 429 Too Many Requests func (e *ErrorResponse) IsTooManyRequests() bool { return e.StatusCode == http.StatusTooManyRequests } // Do performs an HTTP request with the given method, endpoint, and body. func (c *Client) Do(ctx context.Context, method, endpoint string, body interface{}, v interface{}) error { // Ensure endpoint starts with "/" if !strings.HasPrefix(endpoint, "/") { endpoint = "/" + endpoint } // Parse the endpoint to properly handle path, query string, and fragments parsedEndpoint, err := url.Parse(endpoint) if err != nil { return fmt.Errorf("failed to parse endpoint: %w", err) } // Create the request URL using only the path portion reqURL, err := url.JoinPath(c.baseURL, parsedEndpoint.Path) if err != nil { return fmt.Errorf("failed to create request URL: %w", err) } // Reattach query string if present (properly encoded) if parsedEndpoint.RawQuery != "" { reqURL += "?" + parsedEndpoint.RawQuery } var bodyBytes []byte if body != nil { // We need to nest this in a branch because otherwise // `json.Marshal(nil)` produces `null` instead of `nil`. bodyBytes, err = json.Marshal(body) if err != nil { return fmt.Errorf("failed to marshal request body: %w", err) } } respBody, err := c.send(ctx, method, reqURL, bodyBytes) if err != nil { return err } if v != nil && len(respBody) > 0 { if err := json.Unmarshal(respBody, v); err != nil { return fmt.Errorf("failed to unmarshal response: %w", err) } } return nil } func (c *Client) send(ctx context.Context, method, reqURL string, body []byte) ([]byte, error) { // Create the request req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Set common headers req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) req.Header.Set("User-Agent", c.userAgent) if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") // Execute the request resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() // Read response body respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } // Check for error status if resp.StatusCode >= 400 { return nil, &ErrorResponse{ StatusCode: resp.StatusCode, Status: resp.Status, URL: reqURL, Body: respBody, Headers: resp.Header, } } return respBody, nil } ================================================ FILE: internal/http/client_test.go ================================================ package http import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "testing" ) type testResponse struct { Message string `json:"message"` } func TestClient(t *testing.T) { t.Parallel() t.Run("makes request with authorization header", func(t *testing.T) { t.Parallel() var receivedToken string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedToken = r.Header.Get("Authorization") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(testResponse{Message: "success"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) var resp testResponse err := client.Get(context.Background(), "/test", &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedToken := "Bearer test-token" if receivedToken != expectedToken { t.Errorf("expected Authorization header %q, got %q", expectedToken, receivedToken) } if resp.Message != "success" { t.Errorf("expected response message %q, got %q", "success", resp.Message) } }) t.Run("handles JSON response", func(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(testResponse{Message: "test message"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) var resp testResponse err := client.Get(context.Background(), "/test", &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.Message != "test message" { t.Errorf("expected message %q, got %q", "test message", resp.Message) } }) t.Run("handles error response", func(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"error": "bad request"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) var resp testResponse err := client.Get(context.Background(), "/test", &resp) if err == nil { t.Fatalf("expected error, got nil") } // Error should contain status code and possibly the error message if errStr := err.Error(); errStr == "" { t.Error("expected non-empty error message") } }) t.Run("adds user agent header", func(t *testing.T) { t.Parallel() var receivedUA string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedUA = r.Header.Get("User-Agent") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(testResponse{Message: "success"}) })) defer server.Close() expectedUA := "test-user-agent" client := NewClient("test-token", WithBaseURL(server.URL), WithUserAgent(expectedUA)) var resp testResponse err := client.Get(context.Background(), "/test", &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } if receivedUA != expectedUA { t.Errorf("expected User-Agent header %q, got %q", expectedUA, receivedUA) } }) t.Run("handles POST request with body", func(t *testing.T) { t.Parallel() var receivedBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error receivedBody, err = io.ReadAll(r.Body) if err != nil { t.Fatalf("failed to read request body: %v", err) } w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(testResponse{Message: "success"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) requestBody := map[string]string{"test": "data"} var resp testResponse err := client.Post(context.Background(), "/test", requestBody, &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } // Check that the body was correctly serialized var parsed map[string]string if err := json.Unmarshal(receivedBody, &parsed); err != nil { t.Fatalf("failed to parse received body: %v", err) } if parsed["test"] != "data" { t.Errorf("expected body to contain %q, got %q", "data", parsed["test"]) } }) t.Run("preserves query parameters in endpoint", func(t *testing.T) { t.Parallel() var receivedQuery string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedQuery = r.URL.RawQuery w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(testResponse{Message: "success"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) var resp testResponse err := client.Get(context.Background(), "/builds?branch=main&state=passed", &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedQuery := "branch=main&state=passed" if receivedQuery != expectedQuery { t.Errorf("expected query string %q, got %q", expectedQuery, receivedQuery) } }) t.Run("handles encoded query parameters", func(t *testing.T) { t.Parallel() var receivedQuery string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedQuery = r.URL.RawQuery w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(testResponse{Message: "success"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) var resp testResponse err := client.Get(context.Background(), "/builds?branch=feature%2Ftest%20name", &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedQuery := "branch=feature%2Ftest%20name" if receivedQuery != expectedQuery { t.Errorf("expected query string %q, got %q", expectedQuery, receivedQuery) } }) t.Run("strips fragments from endpoint", func(t *testing.T) { t.Parallel() var receivedPath string var receivedQuery string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedPath = r.URL.Path receivedQuery = r.URL.RawQuery w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(testResponse{Message: "success"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) var resp testResponse err := client.Get(context.Background(), "/builds?branch=main#foo", &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } if receivedPath != "/builds" { t.Errorf("expected path %q, got %q", "/builds", receivedPath) } expectedQuery := "branch=main" if receivedQuery != expectedQuery { t.Errorf("expected query string %q, got %q", expectedQuery, receivedQuery) } }) t.Run("handles endpoint without query parameters", func(t *testing.T) { t.Parallel() var receivedPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedPath = r.URL.Path w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(testResponse{Message: "success"}) })) defer server.Close() client := NewClient("test-token", WithBaseURL(server.URL)) var resp testResponse err := client.Get(context.Background(), "/pipelines/test/builds", &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedPath := "/pipelines/test/builds" if receivedPath != expectedPath { t.Errorf("expected path %q, got %q", expectedPath, receivedPath) } }) } func TestErrorResponse(t *testing.T) { t.Parallel() t.Run("formats status code errors", func(t *testing.T) { t.Parallel() err := &ErrorResponse{ StatusCode: 404, Status: "Not Found", URL: "https://example.com/resource", } expected := "HTTP request failed: 404 Not Found (https://example.com/resource)" if err.Error() != expected { t.Errorf("expected error message %q, got %q", expected, err.Error()) } }) t.Run("includes body in error", func(t *testing.T) { t.Parallel() err := &ErrorResponse{ StatusCode: 400, Status: "Bad Request", URL: "https://example.com/resource", Body: []byte(`{"error":"Invalid input"}`), } expected := "HTTP request failed: 400 Bad Request (https://example.com/resource): {\"error\":\"Invalid input\"}" if err.Error() != expected { t.Errorf("expected error message %q, got %q", expected, err.Error()) } }) t.Run("IsTooManyRequests returns true for 429", func(t *testing.T) { t.Parallel() err := &ErrorResponse{StatusCode: 429} if !err.IsTooManyRequests() { t.Error("expected IsTooManyRequests to return true for 429") } err = &ErrorResponse{StatusCode: 500} if err.IsTooManyRequests() { t.Error("expected IsTooManyRequests to return false for 500") } }) } ================================================ FILE: internal/http/ratelimit.go ================================================ package http import ( "io" "net/http" "strconv" "time" ) const ( // DefaultMaxRateLimitRetries is the default number of times to retry a // rate-limited request. DefaultMaxRateLimitRetries = 3 // defaultFallbackDelay is used when the server returns 429 but the // RateLimit-Reset header is missing or unparseable. defaultFallbackDelay = 10 * time.Second ) // OnRateLimitFunc is called before sleeping for a rate-limit backoff. // attempt is zero-indexed; delay is how long the transport will sleep. type OnRateLimitFunc func(attempt int, delay time.Duration) // RateLimitTransport wraps an http.RoundTripper and automatically retries // requests that receive an HTTP 429 response, sleeping for the duration // indicated by the RateLimit-Reset header. type RateLimitTransport struct { // Transport is the underlying RoundTripper. If nil, http.DefaultTransport // is used. Transport http.RoundTripper // MaxRetries is the maximum number of retry attempts on 429. Zero means // no retries; negative values are treated as zero. MaxRetries int // MaxRetryDelay caps the sleep duration for any single retry. Zero means // no cap is applied. MaxRetryDelay time.Duration // OnRateLimit is an optional callback invoked before each backoff sleep. OnRateLimit OnRateLimitFunc } // NewRateLimitTransport returns a RateLimitTransport wrapping the given // transport with sensible defaults. func NewRateLimitTransport(transport http.RoundTripper) *RateLimitTransport { if transport == nil { transport = http.DefaultTransport } return &RateLimitTransport{ Transport: transport, MaxRetries: DefaultMaxRateLimitRetries, } } // RoundTrip implements http.RoundTripper. On a 429 response it reads the // RateLimit-Reset header (seconds until the rate-limit window resets) and // sleeps for that duration before retrying, up to MaxRetries times. func (t *RateLimitTransport) RoundTrip(req *http.Request) (*http.Response, error) { transport := t.Transport if transport == nil { transport = http.DefaultTransport } for attempt := 0; ; attempt++ { // Reset the request body for retries. if attempt > 0 && req.GetBody != nil { body, err := req.GetBody() if err != nil { return nil, err } req.Body = body } resp, err := transport.RoundTrip(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusTooManyRequests || attempt >= t.MaxRetries { return resp, nil } delay, ok := parseRateLimitReset(resp) if !ok { delay = defaultFallbackDelay } if t.MaxRetryDelay > 0 && delay > t.MaxRetryDelay { delay = t.MaxRetryDelay } // Drain and close the 429 response body before retrying. _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() if t.OnRateLimit != nil { t.OnRateLimit(attempt, delay) } // Sleep for the backoff duration, but honour context cancellation. timer := time.NewTimer(delay) select { case <-req.Context().Done(): timer.Stop() return nil, req.Context().Err() case <-timer.C: } } } // parseRateLimitReset reads the RateLimit-Reset header and returns the // duration to wait plus a boolean indicating whether the value was valid. func parseRateLimitReset(resp *http.Response) (time.Duration, bool) { s := resp.Header.Get("RateLimit-Reset") if s == "" { return 0, false } seconds, err := strconv.Atoi(s) if err != nil || seconds < 0 { return 0, false } return time.Duration(seconds) * time.Second, true } ================================================ FILE: internal/http/ratelimit_test.go ================================================ package http import ( "context" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) func TestRateLimitTransport(t *testing.T) { t.Run("passes through non-429 responses", func(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) })) defer s.Close() rt := NewRateLimitTransport(http.DefaultTransport) req, _ := http.NewRequest("GET", s.URL, nil) resp, err := rt.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } }) t.Run("retries on 429 and succeeds", func(t *testing.T) { var attempts atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n <= 2 { w.Header().Set("RateLimit-Reset", "1") w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("rate limited")) return } w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) })) defer s.Close() var callbackCalls int rt := NewRateLimitTransport(http.DefaultTransport) rt.MaxRetries = 3 rt.OnRateLimit = func(attempt int, delay time.Duration) { callbackCalls++ } req, _ := http.NewRequest("GET", s.URL, nil) resp, err := rt.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200 after retries, got %d", resp.StatusCode) } if got := attempts.Load(); got != 3 { t.Errorf("expected 3 total attempts, got %d", got) } if callbackCalls != 2 { t.Errorf("expected 2 callback calls, got %d", callbackCalls) } }) t.Run("returns 429 after exhausting retries", func(t *testing.T) { var attempts atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.Header().Set("RateLimit-Reset", "1") w.WriteHeader(http.StatusTooManyRequests) })) defer s.Close() rt := NewRateLimitTransport(http.DefaultTransport) rt.MaxRetries = 2 req, _ := http.NewRequest("GET", s.URL, nil) resp, err := rt.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusTooManyRequests { t.Errorf("expected 429 after exhausting retries, got %d", resp.StatusCode) } // 1 initial + 2 retries = 3 total if got := attempts.Load(); got != 3 { t.Errorf("expected 3 total attempts, got %d", got) } }) t.Run("respects context cancellation during backoff", func(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("RateLimit-Reset", "60") w.WriteHeader(http.StatusTooManyRequests) })) defer s.Close() rt := NewRateLimitTransport(http.DefaultTransport) rt.MaxRetries = 3 ctx, cancel := context.WithCancel(context.Background()) // Cancel shortly after the first 429 is received. rt.OnRateLimit = func(attempt int, delay time.Duration) { go func() { time.Sleep(10 * time.Millisecond) cancel() }() } req, _ := http.NewRequestWithContext(ctx, "GET", s.URL, nil) _, err := rt.RoundTrip(req) if err == nil { t.Fatal("expected error from cancelled context, got nil") } if !strings.Contains(err.Error(), "context canceled") { t.Errorf("expected context canceled error, got: %v", err) } }) t.Run("uses fallback delay when header missing", func(t *testing.T) { var attempts atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n == 1 { // No RateLimit-Reset header w.WriteHeader(http.StatusTooManyRequests) return } w.WriteHeader(http.StatusOK) })) defer s.Close() var gotDelay time.Duration ctx, cancel := context.WithCancel(context.Background()) defer cancel() rt := NewRateLimitTransport(http.DefaultTransport) rt.MaxRetries = 1 // Cancel quickly to avoid waiting the full fallback delay. rt.OnRateLimit = func(attempt int, delay time.Duration) { gotDelay = delay cancel() } req, _ := http.NewRequestWithContext(ctx, "GET", s.URL, nil) rt.RoundTrip(req) if gotDelay != defaultFallbackDelay { t.Errorf("expected fallback delay %v, got %v", defaultFallbackDelay, gotDelay) } }) t.Run("uses zero delay when header is zero", func(t *testing.T) { var attempts atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n == 1 { w.Header().Set("RateLimit-Reset", "0") w.WriteHeader(http.StatusTooManyRequests) return } w.WriteHeader(http.StatusOK) })) defer s.Close() var gotDelay time.Duration rt := NewRateLimitTransport(http.DefaultTransport) rt.MaxRetries = 1 rt.OnRateLimit = func(attempt int, delay time.Duration) { gotDelay = delay } req, _ := http.NewRequest("GET", s.URL, nil) resp, err := rt.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if gotDelay != 0 { t.Errorf("expected zero delay, got %v", gotDelay) } }) t.Run("caps delay at MaxRetryDelay", func(t *testing.T) { var attempts atomic.Int32 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n == 1 { w.Header().Set("RateLimit-Reset", "3600") w.WriteHeader(http.StatusTooManyRequests) return } w.WriteHeader(http.StatusOK) })) defer s.Close() rt := NewRateLimitTransport(http.DefaultTransport) rt.MaxRetries = 1 rt.MaxRetryDelay = 10 * time.Millisecond req, _ := http.NewRequest("GET", s.URL, nil) start := time.Now() resp, err := rt.RoundTrip(req) elapsed := time.Since(start) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } if elapsed > 1*time.Second { t.Errorf("expected delay to be capped, but took %v", elapsed) } }) t.Run("replays request body on retry", func(t *testing.T) { var attempts atomic.Int32 var bodies []string s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) bodies = append(bodies, string(b)) n := attempts.Add(1) if n == 1 { w.Header().Set("RateLimit-Reset", "1") w.WriteHeader(http.StatusTooManyRequests) return } w.WriteHeader(http.StatusOK) })) defer s.Close() rt := NewRateLimitTransport(http.DefaultTransport) rt.MaxRetries = 1 body := `{"key":"value"}` req, _ := http.NewRequest("POST", s.URL, strings.NewReader(body)) resp, err := rt.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if len(bodies) != 2 { t.Fatalf("expected 2 requests, got %d", len(bodies)) } for i, got := range bodies { if got != body { t.Errorf("attempt %d: body = %q, want %q", i, got, body) } } }) } func TestParseRateLimitReset(t *testing.T) { tests := []struct { name string header string expected time.Duration ok bool }{ {"valid seconds", "30", 30 * time.Second, true}, {"one second", "1", 1 * time.Second, true}, {"empty", "", 0, false}, {"negative", "-1", 0, false}, {"zero means retry now", "0", 0, true}, {"non-numeric", "abc", 0, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := &http.Response{Header: http.Header{}} if tt.header != "" { resp.Header.Set("RateLimit-Reset", tt.header) } got, ok := parseRateLimitReset(resp) if got != tt.expected { t.Errorf("parseRateLimitReset(%q) = %v, want %v", tt.header, got, tt.expected) } if ok != tt.ok { t.Errorf("parseRateLimitReset(%q) ok = %v, want %v", tt.header, ok, tt.ok) } }) } } ================================================ FILE: internal/http/refresh_transport.go ================================================ package http import ( "context" "fmt" "io" "net/http" "os" "strings" "sync" "github.com/buildkite/cli/v3/pkg/keyring" "github.com/buildkite/cli/v3/pkg/oauth" ) // TokenSource provides thread-safe access to the current access token. // It is shared between auth-injection points (REST, GraphQL) and // RefreshTransport so that a refreshed token is immediately visible // to all subsequent requests. type TokenSource struct { mu sync.RWMutex token string } // NewTokenSource creates a TokenSource initialised with the given token. func NewTokenSource(token string) *TokenSource { return &TokenSource{token: token} } // Token returns the current access token. func (ts *TokenSource) Token() string { ts.mu.RLock() defer ts.mu.RUnlock() return ts.token } // SetToken updates the current access token. func (ts *TokenSource) SetToken(token string) { ts.mu.Lock() defer ts.mu.Unlock() ts.token = token } // AuthTransport injects the Authorization header from a TokenSource // on every outgoing request. It should wrap the base transport so that // RefreshTransport (which sits outside it) can override the header on // retries. type AuthTransport struct { Base http.RoundTripper TokenSource *TokenSource UserAgent string } func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { token := t.TokenSource.Token() if token != "" { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } if t.UserAgent != "" { req.Header.Set("User-Agent", t.UserAgent) } base := t.Base if base == nil { base = http.DefaultTransport } return base.RoundTrip(req) } // RefreshTransport wraps an http.RoundTripper to automatically refresh // expired OAuth access tokens using a stored refresh token. // // On a 401 response it: // 1. Acquires a mutex to serialise concurrent refreshes. // 2. Checks whether the token has already been refreshed by another // goroutine (compare-after-lock). // 3. If not, exchanges the refresh token for new tokens. // 4. Persists the new tokens and updates the shared TokenSource. // 5. Retries the original request with the new token. type RefreshTransport struct { Base http.RoundTripper Org string Keyring *keyring.Keyring TokenSource *TokenSource mu sync.Mutex } func (t *RefreshTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } return http.DefaultTransport } func (t *RefreshTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Buffer the request body so it can be replayed on retry. // http.NewRequest sets GetBody for standard body types, but // custom readers (e.g. from GraphQL clients) may not. bufferRequestBody(req) resp, err := t.base().RoundTrip(req) if err != nil { return resp, err } if resp.StatusCode != http.StatusUnauthorized { return resp, nil } // Only attempt refresh if we have a refresh token refreshToken, rtErr := t.Keyring.GetRefreshToken(t.Org) if rtErr != nil || refreshToken == "" { return resp, nil } // Extract the token that was used for the failed request so we can // detect whether another goroutine already refreshed it. failedToken := extractBearerToken(req.Header.Get("Authorization")) // Attempt token refresh (serialised to prevent concurrent refreshes) t.mu.Lock() newToken, refreshErr := t.doRefresh(req.Context(), failedToken) t.mu.Unlock() if refreshErr != nil { fmt.Fprintf(os.Stderr, "Warning: token refresh failed: %v\n", refreshErr) return resp, nil } // Drain and close the original 401 response body _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() // Clone the request with the new token and retry retryReq := req.Clone(req.Context()) retryReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", newToken)) // Re-create the body for the retry if req.GetBody != nil { body, err := req.GetBody() if err != nil { return nil, fmt.Errorf("failed to get request body for retry: %w", err) } retryReq.Body = body } return t.base().RoundTrip(retryReq) } func (t *RefreshTransport) doRefresh(ctx context.Context, failedToken string) (string, error) { // Compare-after-lock: if the current token differs from the one that // failed, another goroutine already refreshed successfully. Skip the // refresh and use the new token. currentToken := t.TokenSource.Token() if currentToken != "" && currentToken != failedToken { return currentToken, nil } // Re-read the refresh token under the lock — it may have been rotated // by a concurrent refresh. refreshToken, err := t.Keyring.GetRefreshToken(t.Org) if err != nil || refreshToken == "" { return "", fmt.Errorf("no refresh token available") } tokenResp, err := oauth.RefreshAccessToken(ctx, "", "", refreshToken) if err != nil { // Only clear the stored refresh token on explicit grant errors // (invalid/expired/revoked). Transient failures (network, 5xx) // should not destroy the user's session. if isTerminalRefreshError(err) { _ = t.Keyring.DeleteRefreshToken(t.Org) } return "", err } // Persist the new access token if err := t.Keyring.Set(t.Org, tokenResp.AccessToken); err != nil { return "", fmt.Errorf("failed to store refreshed access token: %w", err) } t.TokenSource.SetToken(tokenResp.AccessToken) // Rotate the refresh token if a new one was issued if tokenResp.RefreshToken != "" { if err := t.Keyring.SetRefreshToken(t.Org, tokenResp.RefreshToken); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to store rotated refresh token: %v\n", err) } } return tokenResp.AccessToken, nil } // isTerminalRefreshError returns true for OAuth errors that indicate the // refresh token is permanently invalid and should be cleared. func isTerminalRefreshError(err error) bool { msg := err.Error() return strings.Contains(msg, "invalid_grant") || strings.Contains(msg, "unauthorized_client") || strings.Contains(msg, "invalid_client") } // extractBearerToken extracts the token value from a "Bearer <token>" header. func extractBearerToken(header string) string { if strings.HasPrefix(header, "Bearer ") { return header[len("Bearer "):] } return header } // bufferRequestBody ensures the request body can be replayed for retries. // If the body is nil or already replayable (GetBody is set), this is a no-op. func bufferRequestBody(req *http.Request) { if req.Body == nil || req.GetBody != nil { return } bodyBytes, err := io.ReadAll(req.Body) _ = req.Body.Close() if err != nil { return } req.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader(string(bodyBytes))), nil } } ================================================ FILE: internal/http/refresh_transport_test.go ================================================ package http import ( "errors" "io" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "github.com/buildkite/cli/v3/pkg/keyring" ) func TestRefreshTransport_PassesThroughNon401(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) })) defer server.Close() keyring.MockForTesting() defer keyring.ResetForTesting() kr := keyring.New() _ = kr.Set("test-org", "old-token") _ = kr.SetRefreshToken("test-org", "refresh-token") ts := NewTokenSource("old-token") transport := &RefreshTransport{ Base: http.DefaultTransport, Org: "test-org", Keyring: kr, TokenSource: ts, } req, _ := http.NewRequest("GET", server.URL+"/test", nil) req.Header.Set("Authorization", "Bearer old-token") resp, err := transport.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestRefreshTransport_NoRefreshToken_PassesThrough401(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"message":"unauthorized"}`)) })) defer server.Close() keyring.MockForTesting() defer keyring.ResetForTesting() kr := keyring.New() _ = kr.Set("test-org", "some-token") // No refresh token set ts := NewTokenSource("some-token") transport := &RefreshTransport{ Base: http.DefaultTransport, Org: "test-org", Keyring: kr, TokenSource: ts, } req, _ := http.NewRequest("GET", server.URL+"/test", nil) req.Header.Set("Authorization", "Bearer some-token") resp, err := transport.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401 pass-through, got %d", resp.StatusCode) } } func TestRefreshTransport_CompareAfterLock_SkipsRedundantRefresh(t *testing.T) { // This test uses t.Setenv so cannot be parallel. keyring.MockForTesting() defer keyring.ResetForTesting() kr := keyring.New() _ = kr.Set("test-org", "already-refreshed-token") _ = kr.SetRefreshToken("test-org", "refresh-token") // TokenSource already has the new token (simulating another goroutine // having refreshed it). ts := NewTokenSource("already-refreshed-token") var apiCalls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { apiCalls.Add(1) auth := r.Header.Get("Authorization") if auth == "Bearer already-refreshed-token" { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) return } w.WriteHeader(http.StatusUnauthorized) })) defer server.Close() // Point BUILDKITE_HOST at a dead port so that if doRefresh is // incorrectly called, it fails fast instead of hitting a real server. t.Setenv("BUILDKITE_HOST", "127.0.0.1:1") transport := &RefreshTransport{ Base: http.DefaultTransport, Org: "test-org", Keyring: kr, TokenSource: ts, } // Request with a stale token that triggers 401 req, _ := http.NewRequest("GET", server.URL+"/test", nil) req.Header.Set("Authorization", "Bearer stale-token") resp, err := transport.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 after compare-after-lock skip, got %d", resp.StatusCode) } // Should have made exactly 2 API calls: the initial 401 + the retry if got := apiCalls.Load(); got != 2 { t.Fatalf("expected 2 API calls (initial + retry), got %d", got) } } func TestRefreshTransport_DoesNotDeleteRefreshTokenOnTransientError(t *testing.T) { keyring.MockForTesting() defer keyring.ResetForTesting() kr := keyring.New() _ = kr.Set("test-org", "old-token") _ = kr.SetRefreshToken("test-org", "my-refresh-token") ts := NewTokenSource("old-token") // API server that always returns 401 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer server.Close() // Set BUILDKITE_HOST to a non-existent host to simulate a network error // during the refresh attempt t.Setenv("BUILDKITE_HOST", "127.0.0.1:1") // connection refused transport := &RefreshTransport{ Base: http.DefaultTransport, Org: "test-org", Keyring: kr, TokenSource: ts, } req, _ := http.NewRequest("GET", server.URL+"/test", nil) req.Header.Set("Authorization", "Bearer old-token") resp, err := transport.RoundTrip(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401 pass-through, got %d", resp.StatusCode) } // The refresh token should NOT have been deleted (transient error) rt, rtErr := kr.GetRefreshToken("test-org") if rtErr != nil || rt != "my-refresh-token" { t.Fatalf("expected refresh token to be preserved after transient error, got %q err=%v", rt, rtErr) } } func TestRefreshTransport_BuffersAndRetriesPostBody(t *testing.T) { t.Parallel() keyring.MockForTesting() defer keyring.ResetForTesting() kr := keyring.New() _ = kr.Set("test-org", "old-token") _ = kr.SetRefreshToken("test-org", "refresh-token") ts := NewTokenSource("old-token") var apiCalls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { call := apiCalls.Add(1) if call == 1 { w.WriteHeader(http.StatusUnauthorized) return } // Verify body was replayed on retry body, _ := io.ReadAll(r.Body) _ = body w.WriteHeader(http.StatusOK) })) defer server.Close() transport := &RefreshTransport{ Base: http.DefaultTransport, Org: "test-org", Keyring: kr, TokenSource: ts, } // Simulate a POST with a body that doesn't have GetBody set body := `{"query":"{ viewer { user { name } } }"}` req, _ := http.NewRequest("POST", server.URL+"/graphql", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer old-token") req.Header.Set("Content-Type", "application/json") // Explicitly clear GetBody to simulate a custom reader req.GetBody = nil // doRefresh will fail (no real token server), but we can verify // that bufferRequestBody was called by checking the request has GetBody. // Since the refresh will fail, the 401 is returned, but the body // buffering is the important part to verify. resp, _ := transport.RoundTrip(req) _ = resp // Verify GetBody was set by bufferRequestBody if req.GetBody == nil { t.Fatal("expected GetBody to be set by bufferRequestBody") } } func TestRefreshTransport_ConcurrentRequestsOnlyRefreshOnce(t *testing.T) { // This test uses t.Setenv so cannot be parallel. keyring.MockForTesting() defer keyring.ResetForTesting() kr := keyring.New() _ = kr.Set("test-org", "new-token") _ = kr.SetRefreshToken("test-org", "refresh-token") // TokenSource already has the refreshed token (simulating the first // goroutine having completed the refresh). ts := NewTokenSource("new-token") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if auth == "Bearer stale-token" { w.WriteHeader(http.StatusUnauthorized) return } if auth == "Bearer new-token" { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) return } w.WriteHeader(http.StatusUnauthorized) })) defer server.Close() // Point BUILDKITE_HOST at a dead port so that if doRefresh is // incorrectly called (bypassing compare-after-lock), it fails. t.Setenv("BUILDKITE_HOST", "127.0.0.1:1") transport := &RefreshTransport{ Base: http.DefaultTransport, Org: "test-org", Keyring: kr, TokenSource: ts, } // N goroutines hit 401 with "stale-token" concurrently. // All should use compare-after-lock to skip refresh and retry // with the already-refreshed "new-token". var wg sync.WaitGroup results := make([]int, 5) for i := range 5 { wg.Add(1) go func(idx int) { defer wg.Done() req, _ := http.NewRequest("GET", server.URL+"/test", nil) req.Header.Set("Authorization", "Bearer stale-token") resp, err := transport.RoundTrip(req) if err != nil { results[idx] = -1 return } results[idx] = resp.StatusCode }(i) } wg.Wait() for i, status := range results { if status != http.StatusOK { t.Errorf("goroutine %d: expected 200, got %d", i, status) } } } func TestTokenSource_ThreadSafe(t *testing.T) { t.Parallel() ts := NewTokenSource("initial") var wg sync.WaitGroup for range 100 { wg.Add(2) go func() { defer wg.Done() ts.SetToken("updated") }() go func() { defer wg.Done() _ = ts.Token() }() } wg.Wait() } func TestIsTerminalRefreshError(t *testing.T) { t.Parallel() tests := []struct { err string terminal bool }{ {"token refresh error: invalid_grant - Invalid refresh token", true}, {"token refresh error: unauthorized_client - Client not configured", true}, {"token refresh error: invalid_client - Invalid client", true}, {"refresh token request failed: dial tcp: connection refused", false}, {"refresh token request failed: timeout", false}, {"failed to parse token response: unexpected end of JSON", false}, } for _, tt := range tests { got := isTerminalRefreshError(errors.New(tt.err)) if got != tt.terminal { t.Errorf("isTerminalRefreshError(%q) = %v, want %v", tt.err, got, tt.terminal) } } } ================================================ FILE: internal/io/confirm.go ================================================ package io import ( "fmt" "os" "strings" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) // Confirm prompts the user with a yes/no question. // Returns true if the user confirmed, false otherwise. // // IMPORTANT: Commands using Confirm() must call f.SetGlobalFlags(cmd) in PreRunE. // See factory.SetGlobalFlags() documentation for details. // // Usage: // // confirmed, err := io.Confirm(f, "Do the thing?") // if err != nil { // return err // } // if confirmed { // // do the thing // } func Confirm(f *factory.Factory, prompt string) (bool, error) { // Check if --yes flag is set if f.SkipConfirm { return true, nil } // Check if --no-input flag is set if f.NoInput { return false, fmt.Errorf("interactive input required but --no-input is set") } fmt.Fprintf(os.Stderr, "%s [Y/N]: ", prompt) response, err := ReadLine() if err != nil { return false, err } response = strings.ToLower(response) return response == "y" || response == "yes", nil } ================================================ FILE: internal/io/input.go ================================================ package io import ( "bufio" "io" "os" "strings" "github.com/mattn/go-isatty" ) // HasDataAvailable will return whether the given Reader has data available to read func HasDataAvailable(reader io.Reader) bool { switch f := reader.(type) { case *os.File: return !isatty.IsTerminal(f.Fd()) && !isatty.IsCygwinTerminal(f.Fd()) case *bufio.Reader: return f.Size() > 0 case *strings.Reader: return f.Size() > 0 } return false } ================================================ FILE: internal/io/pager.go ================================================ package io import ( "io" "os" "os/exec" "path/filepath" "strings" "sync" "github.com/anmitsu/go-shlex" "github.com/mattn/go-isatty" ) // Pager returns a writer hooked up to a pager (default: less -R) when stdout is a TTY. // Falls back to stdout when paging is disabled or the pager cannot run. // If pagerCmd is provided, it takes precedence over the PAGER environment variable. func Pager(noPager bool, pagerCmd ...string) (w io.Writer, cleanup func() error) { cleanup = func() error { return nil } if noPager || !isTTY() { return os.Stdout, cleanup } // Determine pager command: explicit arg > PAGER env > default var pagerEnv string if len(pagerCmd) > 0 && pagerCmd[0] != "" { pagerEnv = pagerCmd[0] } else { pagerEnv = os.Getenv("PAGER") } if pagerEnv == "" { pagerEnv = "less -R" } parts, err := shlex.Split(pagerEnv, true) if err != nil || len(parts) == 0 { return os.Stdout, cleanup } pagerBin := parts[0] pagerArgs := parts[1:] pagerPath, err := exec.LookPath(pagerBin) if err != nil { return os.Stdout, cleanup } if isLessPager(pagerPath) && !hasFlag(pagerArgs, "-R", "--RAW-CONTROL-CHARS") { pagerArgs = append(pagerArgs, "-R") } cmd := exec.Command(pagerPath, pagerArgs...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr stdin, err := cmd.StdinPipe() if err != nil { return os.Stdout, cleanup } if err := cmd.Start(); err != nil { stdin.Close() return os.Stdout, func() error { return nil } } var once sync.Once var cleanupErr error cleanup = func() error { once.Do(func() { closeErr := stdin.Close() waitErr := cmd.Wait() if closeErr != nil { cleanupErr = closeErr } else { cleanupErr = waitErr } }) return cleanupErr } return stdin, cleanup } func isTTY() bool { if isatty.IsTerminal(os.Stdout.Fd()) { return true } return isatty.IsCygwinTerminal(os.Stdout.Fd()) } func isLessPager(path string) bool { base := filepath.Base(path) return base == "less" || base == "less.exe" } func hasFlag(args []string, flags ...string) bool { for _, arg := range args { for _, flag := range flags { if arg == flag || strings.HasPrefix(arg, flag+"=") { return true } } } return false } ================================================ FILE: internal/io/pager_test.go ================================================ package io import ( "os" "os/exec" "testing" ) func TestPagerReturnsStdoutWhenNoPagerTrue(t *testing.T) { w, cleanup := Pager(true) defer cleanup() if w != os.Stdout { t.Errorf("expected os.Stdout when noPager=true, got %v", w) } } func TestPagerReturnsStdoutWhenNotTTY(t *testing.T) { w, cleanup := Pager(false) defer cleanup() if w != os.Stdout { t.Errorf("expected os.Stdout when not a TTY, got %v", w) } } func TestPagerReturnsStdoutWhenPagerNotFound(t *testing.T) { originalPager := os.Getenv("PAGER") defer os.Setenv("PAGER", originalPager) os.Setenv("PAGER", "nonexistent-pager-command-12345") w, cleanup := Pager(false) defer cleanup() if w != os.Stdout { t.Errorf("expected os.Stdout when pager not found, got %v", w) } } func TestPagerReturnsStdoutWhenPagerEnvMalformed(t *testing.T) { originalPager := os.Getenv("PAGER") defer os.Setenv("PAGER", originalPager) os.Setenv("PAGER", "less \"unclosed") w, cleanup := Pager(false) defer cleanup() if w != os.Stdout { t.Errorf("expected os.Stdout when PAGER env is malformed, got %v", w) } } func TestPagerReturnsStdoutWhenPagerEnvEmpty(t *testing.T) { originalPager := os.Getenv("PAGER") defer os.Setenv("PAGER", originalPager) os.Setenv("PAGER", "") w, cleanup := Pager(false) defer cleanup() if w != os.Stdout { t.Errorf("expected os.Stdout, got %v", w) } } func TestPagerCleanupIsIdempotent(t *testing.T) { originalPager := os.Getenv("PAGER") defer os.Setenv("PAGER", originalPager) os.Setenv("PAGER", "nonexistent-pager") _, cleanup := Pager(false) err1 := cleanup() err2 := cleanup() err3 := cleanup() if err1 != nil { t.Errorf("first cleanup returned error: %v", err1) } if err2 != nil { t.Errorf("second cleanup returned error: %v", err2) } if err3 != nil { t.Errorf("third cleanup returned error: %v", err3) } } func TestPagerWithCatCommand(t *testing.T) { if _, err := exec.LookPath("cat"); err != nil { t.Skip("cat command not found") } originalPager := os.Getenv("PAGER") defer os.Setenv("PAGER", originalPager) os.Setenv("PAGER", "cat") w, cleanup := Pager(false) defer cleanup() if w != os.Stdout { t.Errorf("expected os.Stdout in non-TTY, got %v", w) } } func TestIsLessPager(t *testing.T) { tests := []struct { name string path string expected bool }{ { name: "Unix less", path: "/usr/bin/less", expected: true, }, { name: "Windows less.exe", path: "less.exe", expected: true, }, { name: "less in current dir", path: "./less", expected: true, }, { name: "not less - cat", path: "/usr/bin/cat", expected: false, }, { name: "not less - more", path: "/usr/bin/more", expected: false, }, { name: "substring match should fail", path: "/usr/bin/lessjs", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isLessPager(tt.path) if result != tt.expected { t.Errorf("isLessPager(%q) = %v, expected %v", tt.path, result, tt.expected) } }) } } func TestHasFlag(t *testing.T) { tests := []struct { name string args []string flags []string expected bool }{ { name: "flag present", args: []string{"-R", "-X"}, flags: []string{"-R"}, expected: true, }, { name: "flag not present", args: []string{"-X", "-F"}, flags: []string{"-R"}, expected: false, }, { name: "multiple flags, one matches", args: []string{"-R", "-X"}, flags: []string{"-R", "--RAW-CONTROL-CHARS"}, expected: true, }, { name: "long flag matches", args: []string{"--RAW-CONTROL-CHARS"}, flags: []string{"-R", "--RAW-CONTROL-CHARS"}, expected: true, }, { name: "flag with value using equals", args: []string{"--option=value"}, flags: []string{"--option"}, expected: true, }, { name: "empty args", args: []string{}, flags: []string{"-R"}, expected: false, }, { name: "empty flags", args: []string{"-R"}, flags: []string{}, expected: false, }, { name: "substring should not match", args: []string{"-RX"}, flags: []string{"-R"}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := hasFlag(tt.args, tt.flags...) if result != tt.expected { t.Errorf("hasFlag(%v, %v) = %v, expected %v", tt.args, tt.flags, result, tt.expected) } }) } } func TestPagerAddsRawFlagToLess(t *testing.T) { tests := []struct { name string pagerPath string initialArgs []string shouldAddFlag bool }{ { name: "less without -R should add it", pagerPath: "/usr/bin/less", initialArgs: []string{"-X"}, shouldAddFlag: true, }, { name: "less with -R should not add it", pagerPath: "/usr/bin/less", initialArgs: []string{"-R", "-X"}, shouldAddFlag: false, }, { name: "less with --RAW-CONTROL-CHARS should not add -R", pagerPath: "/usr/bin/less", initialArgs: []string{"--RAW-CONTROL-CHARS"}, shouldAddFlag: false, }, { name: "non-less pager should not add -R", pagerPath: "/usr/bin/cat", initialArgs: []string{}, shouldAddFlag: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { shouldAdd := isLessPager(tt.pagerPath) && !hasFlag(tt.initialArgs, "-R", "--RAW-CONTROL-CHARS") if shouldAdd != tt.shouldAddFlag { t.Errorf("expected shouldAdd=%v, got %v", tt.shouldAddFlag, shouldAdd) } }) } } func TestPagerWriteAndCleanup(t *testing.T) { // Use cat as a simple pager that will work in tests if _, err := exec.LookPath("cat"); err != nil { t.Skip("cat command not found") } originalPager := os.Getenv("PAGER") defer os.Setenv("PAGER", originalPager) os.Setenv("PAGER", "cat") w, cleanup := Pager(false) defer cleanup() if w == nil { t.Fatal("expected non-nil writer") } // Test that cleanup doesn't return an error if err := cleanup(); err != nil { t.Errorf("cleanup returned error: %v", err) } } func TestPagerCleanupAfterFailedStart(t *testing.T) { originalPager := os.Getenv("PAGER") defer os.Setenv("PAGER", originalPager) os.Setenv("PAGER", "false") w, cleanup := Pager(false) if w != os.Stdout { t.Errorf("expected os.Stdout, got %v", w) } if err := cleanup(); err != nil { t.Errorf("cleanup returned error: %v", err) } if err := cleanup(); err != nil { t.Errorf("second cleanup returned error: %v", err) } } ================================================ FILE: internal/io/progress.go ================================================ package io import ( "fmt" "strings" ) func ProgressBar(completed, total, width int) string { if width <= 0 { return "[]" } if total <= 0 { return "[" + strings.Repeat("░", width) + "]" } if completed < 0 { completed = 0 } filled := min(completed*width/total, width) return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" } func ProgressLine(label string, completed, total, succeeded, failed, barWidth int) string { if total == 0 { return fmt.Sprintf("%s [no items]", label) } bar := ProgressBar(completed, total, barWidth) percent := min(completed*100/total, 100) return fmt.Sprintf("%s %s %3d%% %d/%d succeeded:%d failed:%d", label, bar, percent, completed, total, succeeded, failed) } ================================================ FILE: internal/io/progress_test.go ================================================ package io import "testing" func TestProgressBar(t *testing.T) { t.Parallel() tests := []struct { name string completed int total int width int expected string }{ {"half filled", 5, 10, 10, "[█████░░░░░]"}, {"empty with zero total", 0, 0, 4, "[░░░░]"}, {"full bar", 10, 10, 10, "[██████████]"}, {"overflow clamped", 15, 10, 10, "[██████████]"}, {"zero width", 5, 10, 0, "[]"}, {"negative completed", -5, 10, 10, "[░░░░░░░░░░]"}, {"one char width", 1, 2, 1, "[░]"}, {"complete one char", 2, 2, 1, "[█]"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := ProgressBar(tt.completed, tt.total, tt.width) if got != tt.expected { t.Errorf("ProgressBar(%d, %d, %d) = %q, want %q", tt.completed, tt.total, tt.width, got, tt.expected) } }) } } func TestProgressLine(t *testing.T) { t.Parallel() tests := []struct { name string label string completed int total int succeeded int failed int barWidth int expected string }{ { "partial progress", "Work", 3, 10, 2, 1, 6, "Work [█░░░░░] 30% 3/10 succeeded:2 failed:1", }, { "no items", "Work", 0, 0, 0, 0, 6, "Work [no items]", }, { "complete", "Task", 10, 10, 10, 0, 10, "Task [██████████] 100% 10/10 succeeded:10 failed:0", }, { "all failed", "Ops", 5, 5, 0, 5, 5, "Ops [█████] 100% 5/5 succeeded:0 failed:5", }, { "mixed results at 50%", "Deploy", 5, 10, 3, 2, 8, "Deploy [████░░░░] 50% 5/10 succeeded:3 failed:2", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := ProgressLine(tt.label, tt.completed, tt.total, tt.succeeded, tt.failed, tt.barWidth) if got != tt.expected { t.Errorf("got %q, want %q", got, tt.expected) } }) } } ================================================ FILE: internal/io/prompt.go ================================================ package io import ( "fmt" "os" "strconv" "strings" "github.com/mattn/go-runewidth" ) const ( typeOrganizationMessage = "Pick an organization" typePipelineMessage = "Select a pipeline" ) // PromptForOne will show the list of options to the user, allowing them to select one to return. // It's possible for them to choose none or cancel the selection, resulting in an error. // If noInput is true, it will fail instead of prompting. // If there's no TTY available, it will also fail instead of prompting. // // For global flag support requirements, see the Confirm() function documentation. func PromptForOne(resource string, options []string, noInput bool) (string, error) { if noInput { return "", fmt.Errorf("interactive input required but --no-input flag is set") } // Check if we have a TTY available - if not, treat it as if noInput is true if !isTerminal(os.Stdin) { return "", fmt.Errorf("interactive input required but no TTY available") } var message string switch resource { case "pipeline": message = typePipelineMessage case "organization": message = typeOrganizationMessage default: message = "Please select one of the options below" } if len(options) == 0 { return "", fmt.Errorf("no options available") } fmt.Printf("%s:\n", message) for i, option := range options { fmt.Printf(" %d. %s\n", i+1, option) } prompt := fmt.Sprintf("Enter number (1-%d): ", len(options)) fmt.Print(prompt) response, err := ReadLine() if err != nil { return "", err } num, err := strconv.Atoi(response) if err != nil || num < 1 || num > len(options) { return "", fmt.Errorf("invalid selection") } clearPreviousLines(os.Stdout, renderedLineCount(message, options, prompt+response, terminalWidth(os.Stdout))) return options[num-1], nil } func renderedLineCount(message string, options []string, prompt string, width int) int { lines := wrappedLineCount(message+":", width) for i, option := range options { lines += wrappedLineCount(fmt.Sprintf(" %d. %s", i+1, option), width) } lines += wrappedLineCount(prompt, width) return lines } func wrappedLineCount(s string, width int) int { if width <= 0 { return 1 } lineWidth := runewidth.StringWidth(s) if lineWidth == 0 { return 1 } return (lineWidth-1)/width + 1 } // PromptForInput prompts the user to enter a string value. // If a default value is provided, it will be shown in brackets and used if the user presses enter. // If noInput is true, it will return the default value or an error if no default is provided. func PromptForInput(prompt, defaultVal string, noInput bool) (string, error) { if noInput { if defaultVal != "" { return defaultVal, nil } return "", fmt.Errorf("interactive input required but --no-input flag is set") } if defaultVal != "" { fmt.Printf("%s [%s]: ", prompt, defaultVal) } else { fmt.Printf("%s: ", prompt) } response, err := ReadLine() if err != nil { return "", err } response = strings.TrimSpace(response) if response == "" && defaultVal != "" { return defaultVal, nil } if response == "" { return "", fmt.Errorf("no value provided for %s", prompt) } return response, nil } ================================================ FILE: internal/io/prompt_test.go ================================================ package io import "testing" func TestWrappedLineCount(t *testing.T) { t.Parallel() tests := []struct { name string input string width int want int }{ {name: "empty string", input: "", width: 80, want: 1}, {name: "fits on one line", input: "hello", width: 80, want: 1}, {name: "wraps across two lines", input: "1234567890", width: 5, want: 2}, {name: "invalid width falls back to one line", input: "hello", width: 0, want: 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := wrappedLineCount(tt.input, tt.width); got != tt.want { t.Fatalf("wrappedLineCount(%q, %d) = %d, want %d", tt.input, tt.width, got, tt.want) } }) } } func TestRenderedLineCount(t *testing.T) { t.Parallel() got := renderedLineCount("Select a pipeline", []string{"first", "second"}, "Enter number (1-2): 2", 80) want := 4 if got != want { t.Fatalf("renderedLineCount() = %d, want %d", got, want) } } ================================================ FILE: internal/io/readline.go ================================================ package io import ( "bufio" "os" "strings" "syscall" "golang.org/x/term" ) // ReadLine reads a line of input from stdin with terminal support. // If running in a TTY, it uses x/term for better line editing (backspace, arrows, etc.). // If not in a TTY (e.g., piped input), it falls back to bufio. func ReadLine() (string, error) { fd := int(os.Stdin.Fd()) // Check if we're in a TTY if !term.IsTerminal(fd) { // Not a TTY, use simple bufio reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { return "", err } return strings.TrimSpace(line), nil } // TTY - use x/term for better editing oldState, err := term.MakeRaw(fd) if err != nil { // Fallback to bufio if raw mode fails reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { return "", err } return strings.TrimSpace(line), nil } terminal := term.NewTerminal(os.Stdin, "") line, err := terminal.ReadLine() _ = term.Restore(fd, oldState) if err != nil { return "", err } return strings.TrimSpace(line), nil } // ReadPassword reads a password from stdin without echoing. // This is a convenience wrapper around term.ReadPassword. func ReadPassword() (string, error) { passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { return "", err } return string(passwordBytes), nil } ================================================ FILE: internal/io/spinner.go ================================================ package io import ( "fmt" "os" "time" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/mattn/go-isatty" ) func SpinWhile(f *factory.Factory, name string, action func() error) error { // If quiet mode is on or not a terminal, just run the action if f.Quiet || !isatty.IsTerminal(os.Stdout.Fd()) { return action() } done := make(chan struct{}) finished := make(chan struct{}) go func() { ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() i := 0 chars := []string{". ", ".. ", "..."} maxLen := 0 for { select { case <-done: // Clear the line by overwriting with spaces clearLine := "\r" + fmt.Sprintf("%*s", maxLen, "") + "\r" fmt.Fprint(os.Stderr, clearLine) close(finished) return case <-ticker.C: line := fmt.Sprintf("%s %s", name, chars[i%len(chars)]) if len(line) > maxLen { maxLen = len(line) } fmt.Fprintf(os.Stderr, "\r%s", line) i++ } } }() actionErr := action() close(done) <-finished // Wait for spinner to finish clearing return actionErr } ================================================ FILE: internal/io/spinner_test.go ================================================ package io import ( "os" "testing" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/mattn/go-isatty" ) func TestSpinWhileWithoutTTY(t *testing.T) { // Test that SpinWhile works without TTY actionCalled := false f := &factory.Factory{} err := SpinWhile(f, "Test action", func() error { actionCalled = true return nil }) if err != nil { t.Errorf("SpinWhile should not return error: %v", err) } if !actionCalled { t.Error("Action should have been called") } } func TestSpinWhileActionIsExecuted(t *testing.T) { // Test that the action is always executed regardless of TTY status counter := 0 f := &factory.Factory{} err := SpinWhile(f, "Test action", func() error { counter++ return nil }) if err != nil { t.Errorf("SpinWhile should not return error: %v", err) } if counter != 1 { t.Errorf("Action should have been called exactly once, got %d", counter) } } func TestSpinWhileWithError(t *testing.T) { // Test SpinWhile when action panics or has issues actionCalled := false f := &factory.Factory{} err := SpinWhile(f, "Test action with panic recovery", func() error { actionCalled = true // Don't actually panic in test, just test normal flow return nil }) if err != nil { t.Errorf("SpinWhile should not return error for normal action: %v", err) } if !actionCalled { t.Error("Action should have been called") } } func TestSpinWhileTTYDetection(t *testing.T) { // Test that TTY detection works as expected // This test documents the behavior rather than forcing specific outcomes isTTY := isatty.IsTerminal(os.Stdout.Fd()) actionCalled := false f := &factory.Factory{} err := SpinWhile(f, "TTY detection test", func() error { actionCalled = true return nil }) if err != nil { t.Errorf("SpinWhile should not return error: %v", err) } if !actionCalled { t.Error("Action should have been called regardless of TTY status") } // Document the current TTY status for debugging t.Logf("Current TTY status: %v", isTTY) } func TestSpinWhileQuiet(t *testing.T) { // Test that SpinWhile works with Quiet mode actionCalled := false f := &factory.Factory{Quiet: true} err := SpinWhile(f, "Test action", func() error { actionCalled = true return nil }) if err != nil { t.Errorf("SpinWhile should not return error: %v", err) } if !actionCalled { t.Error("Action should have been called") } } ================================================ FILE: internal/io/terminal.go ================================================ package io import ( "fmt" "os" "github.com/mattn/go-isatty" "golang.org/x/term" ) const clearPreviousLineANSI = "\x1b[1A\r\x1b[2K" func isTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } func terminalWidth(f *os.File) int { width, _, err := term.GetSize(int(f.Fd())) if err != nil || width <= 0 { return 80 } return width } func clearPreviousLines(f *os.File, lines int) { if lines <= 0 || !isTerminal(f) { return } for range lines { fmt.Fprint(f, clearPreviousLineANSI) } } ================================================ FILE: internal/job/view.go ================================================ package job import ( "github.com/buildkite/cli/v3/internal/build/view" buildkite "github.com/buildkite/go-buildkite/v4" ) type Job buildkite.Job // JobSummary renders a job summary func JobSummary(job Job) string { return job.Summarise() } // Summarise renders a summary of the job func (j Job) Summarise() string { // Convert the internal Job type back to buildkite.Job for rendering bkJob := buildkite.Job(j) return view.RenderJobSummary(bkJob) } ================================================ FILE: internal/organization/organization.graphql ================================================ query GetOrganizationID ($slug: ID!) { organization(slug: $slug){ id } } ================================================ FILE: internal/pipeline/pipeline.go ================================================ package pipeline // Pipeline is a struct containing information about a pipeline for a resolver to return type Pipeline struct { Name string Org string } ================================================ FILE: internal/pipeline/resolver/cli.go ================================================ package resolver import ( "context" "fmt" "net/url" "strings" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline" ) func ResolveFromPositionalArgument(args []string, index int, conf *config.Config) PipelineResolverFn { return func(context.Context) (*pipeline.Pipeline, error) { // if args does not have values, skip this resolver if len(args) < 1 { return nil, nil } // if the index is out of bounds if (len(args) - 1) < index { return nil, nil } org, name := parsePipelineArg(args[index], conf) // if we get here, we should be able to parse the value and return an error if not // this is because a user has explicitly given an input value for us to use - we shoulnt ignore it on error if org == "" || name == "" { return nil, fmt.Errorf("unable to parse the input pipeline argument: \"%s\"", args[index]) } return &pipeline.Pipeline{Name: name, Org: org}, nil } } // parsePipelineArg resolve an input string in varying formats to an organization and pipeline pair // some example input formats are: // - a web URL: https://buildkite.com/<org>/<pipeline slug>/builds/... // - a slug: <org>/<pipeline slug> // - a pipeline slug by itself func parsePipelineArg(arg string, conf *config.Config) (org, pipeline string) { pipelineIsURL := strings.Contains(arg, ":") pipelineIsSlug := !pipelineIsURL && strings.Contains(arg, "/") if pipelineIsURL { url, err := url.Parse(arg) if err != nil { return "", "" } // eg: url.Path = /buildkite/buildkite-cli part := strings.Split(url.Path, "/") if len(part) < 3 { return "", "" } org, pipeline = part[1], part[2] } else if pipelineIsSlug { part := strings.Split(arg, "/") if len(part) < 2 { return "", "" } org, pipeline = part[0], part[1] } else { org = conf.OrganizationSlug() pipeline = arg } return org, pipeline } ================================================ FILE: internal/pipeline/resolver/cli_test.go ================================================ package resolver_test import ( "context" "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/spf13/afero" ) func TestParsePipelineArg(t *testing.T) { t.Parallel() testcases := map[string]struct { url, org, pipeline string }{ "org_pipeline_slug": { url: "buildkite/cli", org: "buildkite", pipeline: "cli", }, "pipeline_slug": { url: "abcd", org: "testing", pipeline: "abcd", }, "url": { url: "https://buildkite.com/buildkite/buildkite-cli", org: "buildkite", pipeline: "buildkite-cli", }, } for name, testcase := range testcases { testcase := testcase t.Run(name, func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) f := resolver.ResolveFromPositionalArgument([]string{testcase.url}, 0, conf) pipeline, err := f(context.Background()) if err != nil { t.Error(err) } if pipeline.Org != testcase.org { t.Error("parsed organization slug did not match expected") } if pipeline.Name != testcase.pipeline { t.Error("parsed pipeline name did not match expected") } }) } t.Run("pipeline slug uses configured org", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) f := resolver.ResolveFromPositionalArgument([]string{"my-pipeline"}, 0, conf) pipeline, err := f(context.Background()) if err != nil { t.Error(err) } if pipeline.Org != "testing" { t.Errorf("expected org to be 'testing', got '%s'", pipeline.Org) } if pipeline.Name != "my-pipeline" { t.Errorf("expected pipeline to be 'my-pipeline', got '%s'", pipeline.Name) } }) t.Run("Returns error if failed parsing", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) f := resolver.ResolveFromPositionalArgument([]string{"https://buildkite.com/"}, 0, conf) pipeline, err := f(context.Background()) if err == nil { t.Error("Should have failed parsing pipeline") } if pipeline != nil { t.Error("No pipeline should be returned") } }) } ================================================ FILE: internal/pipeline/resolver/config.go ================================================ package resolver import ( "context" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline" ) func ResolveFromConfig(conf *config.Config, picker PipelinePicker) PipelineResolverFn { return func(context.Context) (*pipeline.Pipeline, error) { pipelines := conf.PreferredPipelines() if len(pipelines) == 0 { return nil, nil } return picker(pipelines), nil } } ================================================ FILE: internal/pipeline/resolver/config_test.go ================================================ package resolver import ( "context" "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/spf13/afero" ) func TestResolvePipelineFromConfig(t *testing.T) { t.Parallel() t.Run("no pipelines from config", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) resolve := ResolveFromConfig(conf, PassthruPicker) selected, err := resolve(context.Background()) if err != nil { t.Errorf("failed to resolve from config") } if selected != nil { t.Errorf("pipeline must be nil") } }) t.Run("Resolve to one pipeline", func(t *testing.T) { t.Parallel() pipelines := []pipeline.Pipeline{{Name: "pipeline1"}} conf := config.New(afero.NewMemMapFs(), nil) conf.SetPreferredPipelines(pipelines) resolve := ResolveFromConfig(conf, PassthruPicker) selected, err := resolve(context.Background()) if err != nil { t.Errorf("failed to resolve from config") } if selected == nil { t.Errorf("pipeline must not be nil") } if selected != nil && selected.Name != pipelines[0].Name { t.Errorf("pipeline name must be pipeline1") } }) t.Run("Resolve to many pipelines", func(t *testing.T) { t.Parallel() pipelines := []pipeline.Pipeline{{Name: "pipeline1"}, {Name: "pipeline2"}, {Name: "pipeline3"}} conf := config.New(afero.NewMemMapFs(), nil) conf.SetPreferredPipelines(pipelines) resolve := ResolveFromConfig(conf, PassthruPicker) selected, err := resolve(context.Background()) if err != nil { t.Errorf("failed to resolve from config") } if selected == nil { t.Errorf("pipeline must not be nil") } if selected != nil && selected.Name != pipelines[0].Name { t.Errorf("pipeline name should resolve temporarily to pipeline1") } }) } ================================================ FILE: internal/pipeline/resolver/flag.go ================================================ package resolver import ( "context" "fmt" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline" ) func ResolveFromFlag(flag string, conf *config.Config) PipelineResolverFn { return func(context.Context) (*pipeline.Pipeline, error) { // if the flag is empty, pass through if flag == "" { return nil, nil } org, name := parsePipelineArg(flag, conf) // if we get here, we should be able to parse the value and return an error if not // this is because a user has explicitly given an input value for us to use - we shoulnt ignore it on error if org == "" || name == "" { return nil, fmt.Errorf("unable to parse the input pipeline argument: \"%s\"", flag) } return &pipeline.Pipeline{Name: name, Org: org}, nil } } ================================================ FILE: internal/pipeline/resolver/flag_test.go ================================================ package resolver_test import ( "context" "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/spf13/afero" ) func TestResolveFromFlag(t *testing.T) { t.Parallel() t.Run("empty flag returns nil", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) f := resolver.ResolveFromFlag("", conf) pipeline, err := f(context.Background()) if err != nil { t.Errorf("unexpected error: %v", err) } if pipeline != nil { t.Error("expected nil pipeline for empty flag") } }) t.Run("pipeline slug uses config org", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) f := resolver.ResolveFromFlag("my-pipeline", conf) pipeline, err := f(context.Background()) if err != nil { t.Errorf("unexpected error: %v", err) } if pipeline.Org != "testing" { t.Errorf("expected org 'testing', got '%s'", pipeline.Org) } if pipeline.Name != "my-pipeline" { t.Errorf("expected pipeline 'my-pipeline', got '%s'", pipeline.Name) } }) t.Run("org/pipeline slug extracts org", func(t *testing.T) { t.Parallel() conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization("testing", true) f := resolver.ResolveFromFlag("other-org/my-pipeline", conf) pipeline, err := f(context.Background()) if err != nil { t.Errorf("unexpected error: %v", err) } if pipeline.Org != "other-org" { t.Errorf("expected org 'other-org', got '%s'", pipeline.Org) } if pipeline.Name != "my-pipeline" { t.Errorf("expected pipeline 'my-pipeline', got '%s'", pipeline.Name) } }) } ================================================ FILE: internal/pipeline/resolver/picker.go ================================================ package resolver import ( "slices" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/pkg/cmd/factory" ) // PipelinePicker is a function used to pick a pipeline from a list. // // It is indended to be used from pipeline resolvers that resolve multiple pipelines. type PipelinePicker func([]pipeline.Pipeline) *pipeline.Pipeline func PassthruPicker(p []pipeline.Pipeline) *pipeline.Pipeline { return &p[0] } // PickOneWithFactory returns a picker that uses the factory's NoInput flag. // When multiple pipelines are found and NoInput is true, it fails instead of prompting. func PickOneWithFactory(f *factory.Factory) PipelinePicker { return func(pipelines []pipeline.Pipeline) *pipeline.Pipeline { if len(pipelines) == 0 { return nil } // no need to prompt for only one option if len(pipelines) == 1 { return &pipelines[0] } names := make([]string, len(pipelines)) for i, p := range pipelines { names[i] = p.Name } chosen, err := io.PromptForOne("pipeline", names, f.NoInput) if err != nil { return nil } // Find which pipeline was chosen index := slices.IndexFunc(pipelines, func(p pipeline.Pipeline) bool { return p.Name == chosen }) if index < 0 { // Shouldn't happen, just in case return nil } return &pipelines[index] } } // CachedPicker returns a PipelinePicker that saves the given pipelines to local config as well as running the provider // picker. func CachedPicker(conf *config.Config, picker PipelinePicker) PipelinePicker { return func(pipelines []pipeline.Pipeline) *pipeline.Pipeline { // run the picker first because we want to put the chosen on at the top of the saved list chosen := picker(pipelines) // if chosen is nil, either there were no pipelines to begin with, or the user cancelled the picker, so we // probably shouldnt save them to config if chosen == nil { return nil } // pointers and slices are getting in our way here, so copy the current pipeline pointed to by chosen into a // temporary variable to later return, as the value chosen points to is going to change when we rearrange the // pipelines slice tmp := *chosen index := slices.IndexFunc(pipelines, func(p pipeline.Pipeline) bool { return tmp.Name == p.Name }) pipelines[0], pipelines[index] = tmp, pipelines[0] // best-effort: cache the selection if possible _ = conf.SetPreferredPipelines(pipelines) return &tmp } } ================================================ FILE: internal/pipeline/resolver/picker_test.go ================================================ package resolver_test import ( "os" "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/goccy/go-yaml" "github.com/spf13/afero" ) type savedConfig struct { SelectedOrg string `yaml:"selected_org"` Pipelines []string `yaml:"pipelines"` } func readSavedConfig(t *testing.T, fs afero.Fs) savedConfig { b, err := afero.ReadFile(fs, ".bk.yaml") if err != nil { if os.IsNotExist(err) { return savedConfig{} } t.Fatalf("failed to read config: %v", err) } var cfg savedConfig if len(b) == 0 { return cfg } if err := yaml.Unmarshal(b, &cfg); err != nil { t.Fatalf("failed to unmarshal config: %v", err) } return cfg } func TestPickers(t *testing.T) { t.Parallel() t.Run("cached picker will save to local config", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() conf := config.New(fs, nil) pipelines := []pipeline.Pipeline{ {Name: "pipeline", Org: "org"}, } picked := resolver.CachedPicker(conf, resolver.PassthruPicker)(pipelines) if picked == nil { t.Fatal("Should not have received nil from picker") } saved := readSavedConfig(t, fs) if len(saved.Pipelines) != 1 || saved.Pipelines[0] != "pipeline" { t.Fatalf("Local config pipelines do not match expected: %#v", saved.Pipelines) } }) t.Run("cached picker doesnt save if user makes no choice", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() conf := config.New(fs, nil) pipelines := []pipeline.Pipeline{} resolver.CachedPicker(conf, func(p []pipeline.Pipeline) *pipeline.Pipeline { return nil })(pipelines) b, _ := afero.ReadFile(fs, ".bk.yaml") expected := "" if string(b) != expected { t.Fatalf("Local config file does not match expected: %s", string(b)) } }) t.Run("cached picker saves correct pipeline first", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() conf := config.New(fs, nil) pipelines := []pipeline.Pipeline{ {Name: "first"}, {Name: "second"}, {Name: "third"}, } resolver.CachedPicker(conf, func(p []pipeline.Pipeline) *pipeline.Pipeline { return &p[1] })(pipelines) saved := readSavedConfig(t, fs) expected := []string{"second", "first", "third"} if len(saved.Pipelines) != len(expected) { t.Fatalf("Local config pipelines length mismatch: got %d want %d", len(saved.Pipelines), len(expected)) } for i, name := range expected { if saved.Pipelines[i] != name { t.Fatalf("Local config pipelines mismatch at %d: got %q want %q", i, saved.Pipelines[i], name) } } }) } ================================================ FILE: internal/pipeline/resolver/repository.go ================================================ package resolver import ( "context" "errors" "os/exec" "strings" bkIO "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" git "github.com/go-git/go-git/v5" ) // ResolveFromRepository finds pipelines based on the current repository. // // It queries the API for all pipelines in the organization that match the repository's URL. // It delegates picking one from the list of matches to the `picker`. func ResolveFromRepository(f *factory.Factory, picker PipelinePicker) PipelineResolverFn { return resolveFromRepositoryWithOrg(f, picker, f.Config.OrganizationSlug()) } // ResolveFromRepositoryInOrg finds pipelines in a specific organization based // on the current repository. func ResolveFromRepositoryInOrg(f *factory.Factory, picker PipelinePicker, org string) PipelineResolverFn { return resolveFromRepositoryWithOrg(f, picker, org) } func resolveFromRepositoryWithOrg(f *factory.Factory, picker PipelinePicker, org string) PipelineResolverFn { return func(ctx context.Context) (*pipeline.Pipeline, error) { var pipelines []pipeline.Pipeline if err := bkIO.SpinWhile(f, "Resolving pipeline", func() error { var apiErr error pipelines, apiErr = resolveFromRepository(ctx, f, org) return apiErr }); err != nil { return nil, err } if len(pipelines) == 0 { return nil, nil } pipeline := picker(pipelines) if pipeline == nil { return nil, nil } return pipeline, nil } } func resolveFromRepository(ctx context.Context, f *factory.Factory, org string) ([]pipeline.Pipeline, error) { repos, err := getRepoURLs(f.GitRepository) if err != nil { return nil, err } if len(repos) == 0 { repos, err = getRepoURLsFromGit(ctx) if err != nil { return nil, err } } return filterPipelines(ctx, repos, org, f.RestAPIClient) } func filterPipelines(ctx context.Context, repoURLs []string, org string, client *buildkite.Client) ([]pipeline.Pipeline, error) { var currentPipelines []pipeline.Pipeline page := 1 per_page := 30 for _, repoURL := range repoURLs { for more_pipelines := true; more_pipelines; { opts := buildkite.PipelineListOptions{ ListOptions: buildkite.ListOptions{ Page: page, PerPage: per_page, }, Repository: repoURL, } pipelines, resp, err := client.Pipelines.List(ctx, org, &opts) if err != nil { return nil, err } for _, p := range pipelines { for _, u := range repoURLs { gitUrl := u[strings.LastIndex(u, "/")+1:] if strings.Contains(p.Repository, gitUrl) { currentPipelines = append(currentPipelines, pipeline.Pipeline{Name: p.Slug, Org: org}) } } } if resp.NextPage == 0 { more_pipelines = false } else { page = resp.NextPage } } } return currentPipelines, nil } func getRepoURLs(r *git.Repository) ([]string, error) { if r == nil { return nil, nil // could not resolve to any repository, proceed to another resolver } c, err := r.Config() if err != nil { return nil, err } if _, ok := c.Remotes["origin"]; !ok { return nil, nil // repo's "origin" remote does not exist, proceed to another resolver } return c.Remotes["origin"].URLs, nil } func getRepoURLsFromGit(ctx context.Context) ([]string, error) { cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "--all", "origin") output, err := cmd.Output() if err != nil { var exitErr *exec.ExitError var execErr *exec.Error if errors.As(err, &exitErr) || errors.As(err, &execErr) { return nil, nil } return nil, err } var urls []string for _, line := range strings.Split(string(output), "\n") { url := strings.TrimSpace(line) if url == "" { continue } urls = append(urls, url) } return urls, nil } ================================================ FILE: internal/pipeline/resolver/repository_test.go ================================================ package resolver import ( "context" "net/http" "net/http/httptest" "testing" "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v4" git "github.com/go-git/go-git/v5" gitconfig "github.com/go-git/go-git/v5/config" "github.com/spf13/afero" ) func TestResolvePipelinesFromPath(t *testing.T) { t.Parallel() ctx := context.Background() const testOrg = "testOrg" t.Run("no pipelines found", func(t *testing.T) { t.Parallel() // mock a response that doesn't match the current repository url s := mockHTTPServer(`[{"slug": "my-pipeline", "repository": "git@github.com:buildkite/test.git"}]`) t.Cleanup(s.Close) f := testFactory(t, s.URL, testOrg, testRepository(t, "https://github.com/buildkite/cli.git")) pipelines, err := resolveFromRepository(ctx, f, testOrg) if err != nil { t.Errorf("Error: %s", err) } if len(pipelines) != 0 { t.Errorf("Expected 0 pipeline, got %d", len(pipelines)) } }) t.Run("one pipeline", func(t *testing.T) { t.Parallel() // mock an http client response with a single pipeline matching the current repo url s := mockHTTPServer(`[{"slug": "my-pipeline", "repository": "git@github.com:buildkite/cli.git"}]`) t.Cleanup(s.Close) f := testFactory(t, s.URL, testOrg, testRepository(t, "https://github.com/buildkite/cli.git")) pipelines, err := resolveFromRepository(ctx, f, testOrg) if err != nil { t.Errorf("Error: %s", err) } if len(pipelines) != 1 { t.Errorf("Expected 1 pipeline, got %d", len(pipelines)) } }) t.Run("multiple pipelines", func(t *testing.T) { t.Parallel() // mock an http client response with 2 pipelines matching the current repo url s := mockHTTPServer(`[{"slug": "my-pipeline", "repository": "git@github.com:buildkite/cli.git"}, {"slug": "my-pipeline-2", "repository": "git@github.com:buildkite/cli.git"}]`) t.Cleanup(s.Close) f := testFactory(t, s.URL, testOrg, testRepository(t, "https://github.com/buildkite/cli.git")) pipelines, err := resolveFromRepository(ctx, f, testOrg) if err != nil { t.Errorf("Error: %s", err) } if len(pipelines) != 2 { t.Errorf("Expected 2 pipeline, got %d", len(pipelines)) } }) t.Run("no repository found", func(t *testing.T) { s := mockHTTPServer(`[{"slug": "", "repository": ""}]`) t.Cleanup(s.Close) f := testFactory(t, s.URL, testOrg, nil) pipelines, err := resolveFromRepository(ctx, f, testOrg) if pipelines != nil { t.Errorf("Expected nil, got %v", pipelines) } if err != nil { t.Errorf("Expected nil, got error: %s", err) } }) t.Run("no remote repository found", func(t *testing.T) { s := mockHTTPServer(`[{"slug": "", "repository": ""}]`) t.Cleanup(s.Close) f := testFactory(t, s.URL, testOrg, testRepository(t)) pipelines, err := resolveFromRepository(ctx, f, testOrg) if pipelines != nil { t.Errorf("Expected nil, got %v", pipelines) } if err != nil { t.Errorf("Expected nil, got error: %s", err) } }) } func TestResolvePipelinesFromGitFallback(t *testing.T) { ctx := context.Background() const testOrg = "testOrg" s := mockHTTPServer(`[{"slug": "cli-resolver-smoke", "repository": "git@github.com:buildkite/cli.git"}]`) t.Cleanup(s.Close) repo := testRepository(t, "https://github.com/buildkite/cli.git") wt, err := repo.Worktree() if err != nil { t.Fatalf("Worktree returned error: %v", err) } t.Chdir(wt.Filesystem.Root()) f := testFactory(t, s.URL, testOrg, nil) pipelines, err := resolveFromRepository(ctx, f, testOrg) if err != nil { t.Errorf("Error: %s", err) } if len(pipelines) != 1 { t.Errorf("Expected 1 pipeline, got %d", len(pipelines)) } if len(pipelines) == 1 && pipelines[0].Name != "cli-resolver-smoke" { t.Errorf("Expected cli-resolver-smoke pipeline, got %s", pipelines[0].Name) } } func testRepository(t *testing.T, remoteURLs ...string) *git.Repository { t.Helper() repo, err := git.PlainInit(t.TempDir(), false) if err != nil { t.Fatalf("PlainInit returned error: %v", err) } if len(remoteURLs) == 0 { return repo } _, err = repo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: remoteURLs}) if err != nil { t.Fatalf("CreateRemote returned error: %v", err) } return repo } func testFactory(t *testing.T, serverURL string, org string, repo *git.Repository) *factory.Factory { t.Helper() bkClient, err := buildkite.NewOpts(buildkite.WithBaseURL(serverURL)) if err != nil { t.Errorf("Error creating buildkite client: %s", err) } conf := config.New(afero.NewMemMapFs(), nil) conf.SelectOrganization(org, true) return &factory.Factory{ Config: conf, RestAPIClient: bkClient, GitRepository: repo, } } func mockHTTPServer(response string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(response)) })) } ================================================ FILE: internal/pipeline/resolver/resolver.go ================================================ package resolver import ( "context" "errors" "github.com/buildkite/cli/v3/internal/pipeline" ) // PipelineResolverFn is a function for the purpose of finding a pipeline. It returns an error if an irrecoverable // scenario happens and should halt execution. Otherwise if the resolver does not find a pipeline, it should return // (nil, nil) to indicate this. ie. no error occurred, but no pipeline was found either. type PipelineResolverFn func(context.Context) (*pipeline.Pipeline, error) type AggregateResolver []PipelineResolverFn // Resolve is a PipelineResolverFn that wraps up a list of resolvers to loop through to try find a pipeline. The first // pipeline that is found will be returned, if none are found if won't return an error to match the expectation of a // PipelineResolveFn // // This is safe to call multiple times. The same result will be returned. func (pr AggregateResolver) Resolve(ctx context.Context) (*pipeline.Pipeline, error) { for _, resolve := range pr { p, err := resolve(ctx) if err != nil { return nil, err } if p != nil { return p, nil } } return nil, nil } // NewAggregateResolver creates an AggregregateResolver from a list of PipelineResolverFn, appending a final resolver // for capturing the case that no resolvers find a pipeline func NewAggregateResolver(resolvers ...PipelineResolverFn) AggregateResolver { // add a final error resolver to the chain in case no other resolvers find a pipeline return append(resolvers, errorResolver) } func errorResolver(context.Context) (*pipeline.Pipeline, error) { return nil, errors.New("failed to resolve a pipeline") } // WithOrg wraps a resolver and forces any resolved pipeline to use the // provided organization. func WithOrg(org string, resolve PipelineResolverFn) PipelineResolverFn { if org == "" { return resolve } return func(ctx context.Context) (*pipeline.Pipeline, error) { p, err := resolve(ctx) if err != nil || p == nil { return p, err } p.Org = org return p, nil } } ================================================ FILE: internal/pipeline/resolver/resolver_test.go ================================================ package resolver_test import ( "context" "testing" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/internal/pipeline/resolver" ) func TestAggregateResolver(t *testing.T) { t.Parallel() t.Run("it loops over resolvers until one returns", func(t *testing.T) { t.Parallel() agg := resolver.AggregateResolver{ func(context.Context) (*pipeline.Pipeline, error) { return nil, nil }, func(context.Context) (*pipeline.Pipeline, error) { return &pipeline.Pipeline{Name: "test"}, nil }, } p, err := agg.Resolve(context.Background()) if p.Name != "test" { t.Fatalf("Resolve function did not return expected value: %s", p.Name) } if err != nil { t.Fatal("Resolve returned an error") } }) t.Run("returns nil if nothing resolves", func(t *testing.T) { t.Parallel() agg := resolver.AggregateResolver{} p, err := agg.Resolve(context.Background()) if p != nil && err != nil { t.Fatal("Resolve did not return nil") } }) } func TestWithOrg(t *testing.T) { t.Parallel() t.Run("returns original resolver when org is empty", func(t *testing.T) { t.Parallel() resolve := resolver.WithOrg("", func(context.Context) (*pipeline.Pipeline, error) { return &pipeline.Pipeline{Org: "config-org", Name: "pipeline"}, nil }) p, err := resolve(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if p.Org != "config-org" { t.Fatalf("expected org config-org, got %s", p.Org) } }) t.Run("overrides resolved organization", func(t *testing.T) { t.Parallel() resolve := resolver.WithOrg("override-org", func(context.Context) (*pipeline.Pipeline, error) { return &pipeline.Pipeline{Org: "config-org", Name: "pipeline"}, nil }) p, err := resolve(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if p.Org != "override-org" { t.Fatalf("expected org override-org, got %s", p.Org) } }) } ================================================ FILE: internal/preflight/branch_build.go ================================================ package preflight import ( "context" "fmt" "net/url" "strconv" "strings" buildstate "github.com/buildkite/cli/v3/internal/build/state" buildkite "github.com/buildkite/go-buildkite/v4" ) // BranchBuild represents a preflight branch and its associated build status. type BranchBuild struct { Branch string Ref string Build *buildkite.Build } // IsCompleted returns true if the associated build has reached a terminal state // (passed, failed, canceled, etc.), or if no build was found for the branch. func (bb BranchBuild) IsCompleted() bool { if bb.Build == nil { return true } return buildstate.IsTerminal(buildstate.State(bb.Build.State)) } // ListRemotePreflightBranches returns all remote branches matching bk/preflight/*. func ListRemotePreflightBranches(dir string, debug bool) ([]BranchBuild, error) { return lsRemotePreflightBranches(dir, "refs/heads/bk/preflight/*", debug) } // LookupRemotePreflightBranch returns the remote bk/preflight/<uuid> branch if it // exists, or nil if no such branch is present on the remote. func LookupRemotePreflightBranch(dir, uuid string, debug bool) (*BranchBuild, error) { branches, err := lsRemotePreflightBranches(dir, "refs/heads/bk/preflight/"+uuid, debug) if err != nil { return nil, err } if len(branches) == 0 { return nil, nil } return &branches[0], nil } // lsRemotePreflightBranches runs ls-remote against origin with the given ref // pattern and parses the results into BranchBuild entries. func lsRemotePreflightBranches(dir, pattern string, debug bool) ([]BranchBuild, error) { out, err := gitOutput(dir, nil, debug, "ls-remote", "origin", pattern) if err != nil { return nil, fmt.Errorf("listing remote preflight branches: %w", err) } if out == "" { return nil, nil } var results []BranchBuild for line := range strings.SplitSeq(out, "\n") { if line == "" { continue } parts := strings.Fields(line) if len(parts) < 2 { continue } ref := parts[1] branch := strings.TrimPrefix(ref, "refs/heads/") results = append(results, BranchBuild{Branch: branch, Ref: ref}) } return results, nil } // maxResolveBuildPages is the maximum number of API pages to fetch when // resolving builds. This prevents runaway pagination when orphaned branches // have no matching builds. const ( maxResolveBuildPages = 10 resolveBuildsPerPage = 100 maxResolveBuildQueryLength = 6000 ) // ResolveBuilds looks up the most recent build for each preflight branch and // populates the Build field. Branches with no matching build retain a nil Build. func ResolveBuilds(ctx context.Context, client *buildkite.Client, org, pipeline string, branches []BranchBuild) error { if len(branches) == 0 { return nil } resolved := make(map[string]*buildkite.Build, len(branches)) for _, branchBatch := range resolveBuildBranchBatches(branches) { opts := &buildkite.BuildsListOptions{ Branch: branchBatch, ListOptions: buildkite.ListOptions{PerPage: resolveBuildsPerPage}, } for page := 0; page < maxResolveBuildPages; page++ { builds, resp, err := client.Builds.ListByPipeline(ctx, org, pipeline, opts) if err != nil { return fmt.Errorf("listing builds for preflight branches: %w", err) } for i := range builds { if _, exists := resolved[builds[i].Branch]; !exists { resolved[builds[i].Branch] = &builds[i] } } if len(builds) == 0 || len(resolved) >= len(branches) || resp.NextPage == 0 { break } opts.Page = resp.NextPage } if len(resolved) >= len(branches) { break } } for i := range branches { branches[i].Build = resolved[branches[i].Branch] } return nil } func resolveBuildBranchBatches(branches []BranchBuild) [][]string { if len(branches) == 0 { return nil } batches := make([][]string, 0, 1) current := make([]string, 0, len(branches)) for i := range branches { branch := branches[i].Branch if len(current) > 0 && resolveBuildQueryLength(append(current, branch)) > maxResolveBuildQueryLength { batches = append(batches, current) current = []string{branch} continue } current = append(current, branch) } if len(current) > 0 { batches = append(batches, current) } return batches } func resolveBuildQueryLength(branches []string) int { query := url.Values{ "branch[]": append([]string(nil), branches...), } query.Set("per_page", strconv.Itoa(resolveBuildsPerPage)) query.Set("page", strconv.Itoa(maxResolveBuildPages)) return len(query.Encode()) } ================================================ FILE: internal/preflight/branch_build_test.go ================================================ package preflight import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestResolveBuilds_BatchesRequestsToAvoidLongQuery(t *testing.T) { const maxRawQueryLen = 6500 var requestQueryLens []int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.URL.Path, "/builds") { http.NotFound(w, r) return } requestQueryLens = append(requestQueryLens, len(r.URL.RawQuery)) if len(r.URL.RawQuery) > maxRawQueryLen { http.Error(w, `{"message":"request uri too long"}`, http.StatusRequestURITooLong) return } w.Header().Set("Content-Type", "application/json") builds := make([]buildkite.Build, 0, len(r.URL.Query()["branch[]"])) for _, branch := range r.URL.Query()["branch[]"] { builds = append(builds, buildkite.Build{Branch: branch, State: "passed"}) } if err := json.NewEncoder(w).Encode(builds); err != nil { t.Errorf("encoding response: %v", err) } })) defer server.Close() client, err := buildkite.NewOpts(buildkite.WithBaseURL(server.URL)) if err != nil { t.Fatalf("creating buildkite client: %v", err) } branches := make([]BranchBuild, 80) for i := range branches { branches[i] = BranchBuild{ Branch: fmt.Sprintf("bk/preflight/%s-%02d", strings.Repeat("x", 96), i), } } if err := ResolveBuilds(context.Background(), client, "test-org", "test-pipeline", branches); err != nil { t.Fatalf("ResolveBuilds() error: %v", err) } if len(requestQueryLens) < 2 { t.Fatalf("expected ResolveBuilds to batch requests, got %d request(s)", len(requestQueryLens)) } for _, queryLen := range requestQueryLens { if queryLen > maxRawQueryLen { t.Fatalf("expected each request query to stay under %d bytes, got %d", maxRawQueryLen, queryLen) } } for i := range branches { if branches[i].Build == nil { t.Fatalf("expected branch %q to have a resolved build", branches[i].Branch) } if branches[i].Build.Branch != branches[i].Branch { t.Fatalf("expected build for %q, got %q", branches[i].Branch, branches[i].Build.Branch) } } } ================================================ FILE: internal/preflight/cleanup.go ================================================ package preflight import ( "fmt" "strings" ) // Cleanup deletes the preflight branch from the remote. // If the branch no longer exists on the remote, it is treated as success. func Cleanup(dir string, ref string, debug bool) error { out, err := gitOutput(dir, nil, debug, "ls-remote", "origin", ref) if err != nil { return err } if out == "" { return nil } refspec := fmt.Sprintf(":%s", ref) return gitRun(dir, nil, debug, "push", "origin", refspec) } // CleanupRefs deletes multiple refs from the remote in a single git push. // Refs that no longer exist on the remote are silently ignored. func CleanupRefs(dir string, refs []string, debug bool) error { if len(refs) == 0 { return nil } out, err := gitOutput(dir, nil, debug, "ls-remote", "origin", "refs/heads/bk/preflight/*") if err != nil { return err } remote := make(map[string]struct{}) for line := range strings.SplitSeq(out, "\n") { parts := strings.Fields(line) if len(parts) >= 2 { remote[parts[1]] = struct{}{} } } args := make([]string, 0, 2+len(refs)) args = append(args, "push", "origin") for _, ref := range refs { if _, exists := remote[ref]; exists { args = append(args, fmt.Sprintf(":%s", ref)) } } if len(args) == 2 { return nil } return gitRun(dir, nil, debug, args...) } ================================================ FILE: internal/preflight/cleanup_test.go ================================================ package preflight import ( "testing" buildkite "github.com/buildkite/go-buildkite/v4" "github.com/google/uuid" ) func TestBranchBuild_IsCompleted(t *testing.T) { tests := []struct { name string build *buildkite.Build want bool }{ {"nil build", nil, true}, {"passed", &buildkite.Build{State: "passed"}, true}, {"failed", &buildkite.Build{State: "failed"}, true}, {"canceled", &buildkite.Build{State: "canceled"}, true}, {"running", &buildkite.Build{State: "running"}, false}, {"scheduled", &buildkite.Build{State: "scheduled"}, false}, {"failing", &buildkite.Build{State: "failing"}, false}, {"blocked", &buildkite.Build{State: "blocked"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bb := BranchBuild{Branch: "bk/preflight/test", Build: tt.build} if got := bb.IsCompleted(); got != tt.want { t.Errorf("IsCompleted() = %v, want %v", got, tt.want) } }) } } func TestCleanup(t *testing.T) { worktree := initTestRepo(t) preflightID := uuid.MustParse("00000000-0000-0000-0000-000000000010") result, err := Snapshot(worktree, preflightID) if err != nil { t.Fatalf("Snapshot() error: %v", err) } // Verify the remote branch exists before cleanup. out := runGit(t, worktree, "ls-remote", "origin", result.Ref) if out == "" { t.Fatal("expected remote branch to exist before cleanup") } if err := Cleanup(worktree, result.Ref, false); err != nil { t.Fatalf("Cleanup() error: %v", err) } // Verify the remote branch no longer exists. out = runGit(t, worktree, "ls-remote", "origin", result.Ref) if out != "" { t.Errorf("expected remote branch to be deleted, got %q", out) } } func TestCleanup_AlreadyDeleted(t *testing.T) { worktree := initTestRepo(t) preflightID := uuid.MustParse("00000000-0000-0000-0000-000000000011") result, err := Snapshot(worktree, preflightID) if err != nil { t.Fatalf("Snapshot() error: %v", err) } // Delete the branch manually first. runGit(t, worktree, "push", "origin", "--delete", result.Ref) // Cleanup should succeed even though the branch is already gone. if err := Cleanup(worktree, result.Ref, false); err != nil { t.Fatalf("Cleanup() should succeed when branch already deleted, got: %v", err) } } ================================================ FILE: internal/preflight/exit_policy.go ================================================ package preflight import ( "fmt" "slices" "strings" bkErrors "github.com/buildkite/cli/v3/internal/errors" ) type ExitPolicy int const ( ExitOnBuildFailing ExitPolicy = iota ExitOnBuildTerminal ) func (p *ExitPolicy) UnmarshalText(text []byte) error { value := string(text) name, _, _ := strings.Cut(value, ":") switch name { case "build-failing": *p = ExitOnBuildFailing case "build-terminal": *p = ExitOnBuildTerminal default: return bkErrors.NewValidationError(fmt.Errorf("unsupported --exit-on value %q", value), "invalid exit condition") } return nil } func EffectiveExitPolicy(policies []ExitPolicy) ExitPolicy { if slices.Contains(policies, ExitOnBuildTerminal) { return ExitOnBuildTerminal } return ExitOnBuildFailing } func ValidateExitPolicies(policies []ExitPolicy, watch bool) error { if len(policies) > 0 && !watch { return bkErrors.NewValidationError(fmt.Errorf("--exit-on requires --watch"), "exit conditions require watch mode") } if slices.Contains(policies, ExitOnBuildFailing) && slices.Contains(policies, ExitOnBuildTerminal) { return bkErrors.NewValidationError(fmt.Errorf("build-failing and build-terminal cannot be used together"), "invalid exit conditions") } return nil } ================================================ FILE: internal/preflight/git.go ================================================ package preflight import ( "context" "fmt" "os" "os/exec" "slices" "strings" ) // gitCmdContext creates an exec.Command for git with the given dir and env pre-configured. func gitCmdContext(ctx context.Context, dir string, env []string, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir cmd.Env = env return cmd } // gitRun runs a git command, discarding output on success. func gitRun(dir string, env []string, debug bool, args ...string) error { return gitRunContext(context.Background(), dir, env, debug, args...) } // gitRunContext runs a git command, discarding output on success. func gitRunContext(ctx context.Context, dir string, env []string, debug bool, args ...string) error { cmd := gitCmdContext(ctx, dir, env, args...) if out, err := cmd.CombinedOutput(); err != nil { if debug { os.Stderr.Write(out) } if ctxErr := ctx.Err(); ctxErr != nil { return fmt.Errorf("git %s: %w", strings.Join(args, " "), ctxErr) } return fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } return nil } // gitOutput runs a git command and returns its trimmed stdout. func gitOutput(dir string, env []string, debug bool, args ...string) (string, error) { return gitOutputContext(context.Background(), dir, env, debug, args...) } // gitOutputContext runs a git command and returns its trimmed stdout. func gitOutputContext(ctx context.Context, dir string, env []string, debug bool, args ...string) (string, error) { cmd := gitCmdContext(ctx, dir, env, args...) out, err := cmd.Output() if err != nil { if debug { if ee, ok := err.(*exec.ExitError); ok { os.Stderr.Write(ee.Stderr) } } if ctxErr := ctx.Err(); ctxErr != nil { return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), ctxErr) } return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } return strings.TrimSpace(string(out)), nil } // RepositoryRoot returns the top-level path for the git repository containing dir. func RepositoryRoot(dir string, debug bool) (string, error) { return gitOutput(dir, nil, debug, "rev-parse", "--show-toplevel") } // SourceContext describes the original git state that preflight was created from. type SourceContext struct { Branch string Commit string } // ResolveSourceContext returns the current branch name (if any) and HEAD commit. func ResolveSourceContext(dir string, debug bool) (SourceContext, error) { branch, err := gitOutput(dir, nil, debug, "branch", "--show-current") if err != nil { return SourceContext{}, err } commit, err := gitOutput(dir, nil, debug, "rev-parse", "HEAD") if err != nil { return SourceContext{}, err } return SourceContext{Branch: branch, Commit: commit}, nil } // tempIndexEnv returns a copy of the current environment with GIT_INDEX_FILE // set to path, stripping any existing GIT_INDEX_FILE entry. This is used to // direct git commands at a temporary index without affecting the real one. func tempIndexEnv(path string) []string { env := slices.DeleteFunc(os.Environ(), func(e string) bool { return strings.HasPrefix(e, "GIT_INDEX_FILE=") }) return append(env, "GIT_INDEX_FILE="+path) } ================================================ FILE: internal/preflight/run_summary.go ================================================ package preflight import ( "context" "fmt" "net/url" "strings" buildkite "github.com/buildkite/go-buildkite/v4" ) type SummaryOptions struct { IncludeFailures bool } type SummaryResult struct { Tests SummaryTests `json:"tests"` } type SummaryTests struct { Runs map[string]SummaryTestRun `json:"runs"` Failures []SummaryTestFailure `json:"failures"` } type SummaryTestRun struct { RunID string `json:"run_id"` SuiteName string `json:"suite_name,omitempty"` SuiteSlug string `json:"suite_slug"` Passed int `json:"passed"` Failed int `json:"failed"` Skipped int `json:"skipped"` } type SummaryTestFailure struct { RunID string `json:"run_id"` SuiteName string `json:"suite_name,omitempty"` SuiteSlug string `json:"suite_slug"` Name string `json:"name"` Location string `json:"location"` Message string `json:"message"` FailureReason string `json:"failure_reason"` FailureDetail []SummaryFailureDetail `json:"failure_detail"` } type SummaryFailureDetail struct { Backtrace []string `json:"backtrace"` Expanded []string `json:"expanded"` } type RunSummaryService struct { client *buildkite.Client } type RunSummaryGetOptions struct { Result string IncludeFailures bool State string } type RunSummaryResponse struct { Tests RunSummaryTests `json:"tests"` } type RunSummaryTests struct { Runs map[string]RunSummaryRun `json:"runs"` Failures []RunSummaryFailure `json:"failures"` } type RunSummaryRun struct { Suite RunSummarySuite `json:"suite"` Passed int `json:"passed"` Failed int `json:"failed"` Skipped int `json:"skipped"` } type RunSummarySuite struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` } type RunSummaryFailure struct { RunID string `json:"run_id"` SuiteName string `json:"suite_name"` SuiteSlug string `json:"suite_slug"` Name string `json:"name"` Location string `json:"location"` FailureReason string `json:"failure_reason"` LatestFail *RunSummaryLatestFail `json:"latest_fail,omitempty"` } type RunSummaryLatestFail struct { FailureReason string `json:"failure_reason"` FailureExpanded []buildkite.FailureExpanded `json:"failure_expanded,omitempty"` } func NewRunSummaryService(client *buildkite.Client) *RunSummaryService { return &RunSummaryService{client: client} } func (s *RunSummaryService) Get(ctx context.Context, org, buildID string, opt *RunSummaryGetOptions) (*RunSummaryResponse, error) { query := url.Values{} if opt != nil { if opt.Result != "" { query.Set("result", opt.Result) } if opt.IncludeFailures { query.Set("include", "latest_fail") } if opt.State != "" { query.Set("state", opt.State) } } u := fmt.Sprintf("v2/analytics/organizations/%s/builds/%s/preflight/v1", org, buildID) if encoded := query.Encode(); encoded != "" { u += "?" + encoded } req, err := s.client.NewRequest(ctx, "GET", u, nil) if err != nil { return nil, err } var summary RunSummaryResponse _, err = s.client.Do(req, &summary) if err != nil { return nil, err } return &summary, nil } func (r RunSummaryResponse) SummaryResult() SummaryResult { tests := make(map[string]SummaryTestRun, len(r.Tests.Runs)) for runID, run := range r.Tests.Runs { tests[runID] = SummaryTestRun{ RunID: runID, SuiteName: strings.TrimSpace(run.Suite.Name), SuiteSlug: strings.TrimSpace(run.Suite.Slug), Passed: run.Passed, Failed: run.Failed, Skipped: run.Skipped, } } failures := make([]SummaryTestFailure, 0, len(r.Tests.Failures)) for _, failure := range r.Tests.Failures { failures = append(failures, failure.summaryFailure()) } return SummaryResult{Tests: SummaryTests{Runs: tests, Failures: failures}} } func (f RunSummaryFailure) summaryFailure() SummaryTestFailure { result := SummaryTestFailure{ RunID: strings.TrimSpace(f.RunID), SuiteName: strings.TrimSpace(f.SuiteName), SuiteSlug: strings.TrimSpace(f.SuiteSlug), Name: strings.TrimSpace(f.Name), Location: f.Location, FailureReason: f.FailureReason, FailureDetail: []SummaryFailureDetail{}, } if f.LatestFail == nil { result.Message = f.FailureReason return result } result.Message = f.LatestFail.FailureReason if result.FailureReason == "" { result.FailureReason = f.LatestFail.FailureReason } for _, detail := range f.LatestFail.FailureExpanded { result.FailureDetail = append(result.FailureDetail, SummaryFailureDetail{ Backtrace: detail.Backtrace, Expanded: detail.Expanded, }) } return result } ================================================ FILE: internal/preflight/run_summary_test.go ================================================ package preflight import "testing" func TestRunSummaryResponse_SummaryResult_PreservesRunsByRunID(t *testing.T) { t.Parallel() result := RunSummaryResponse{ Tests: RunSummaryTests{ Runs: map[string]RunSummaryRun{ "run-1": {Suite: RunSummarySuite{Name: "RSpec", Slug: "rspec"}, Passed: 10, Failed: 1, Skipped: 2}, "run-2": {Suite: RunSummarySuite{Name: "RSpec", Slug: "rspec"}, Passed: 12, Failed: 0, Skipped: 1}, }, Failures: []RunSummaryFailure{{ RunID: "run-1", SuiteName: "RSpec", SuiteSlug: "rspec", Name: "example spec", FailureReason: "boom", }}, }, }.SummaryResult() if len(result.Tests.Runs) != 2 { t.Fatalf("expected 2 runs, got %d", len(result.Tests.Runs)) } run1, ok := result.Tests.Runs["run-1"] if !ok { t.Fatal("expected run-1 summary") } if run1.RunID != "run-1" || run1.SuiteName != "RSpec" || run1.SuiteSlug != "rspec" || run1.Passed != 10 || run1.Failed != 1 || run1.Skipped != 2 { t.Fatalf("unexpected run-1 summary: %+v", run1) } run2, ok := result.Tests.Runs["run-2"] if !ok { t.Fatal("expected run-2 summary") } if run2.RunID != "run-2" || run2.SuiteName != "RSpec" || run2.SuiteSlug != "rspec" || run2.Passed != 12 || run2.Failed != 0 || run2.Skipped != 1 { t.Fatalf("unexpected run-2 summary: %+v", run2) } if len(result.Tests.Failures) != 1 { t.Fatalf("expected 1 failure, got %d", len(result.Tests.Failures)) } if result.Tests.Failures[0].RunID != "run-1" || result.Tests.Failures[0].SuiteName != "RSpec" { t.Fatalf("expected failure run_id to be preserved, got %+v", result.Tests.Failures[0]) } } ================================================ FILE: internal/preflight/snapshot.go ================================================ package preflight import ( "context" "fmt" "os" "strings" "github.com/google/uuid" ) // FileChange represents a single file changed in the snapshot. type FileChange struct { Status string // e.g. "M", "A", "D", "R" Path string } // SnapshotResult holds the output of a successful snapshot operation. type SnapshotResult struct { Commit string Ref string Branch string Files []FileChange } func (r SnapshotResult) ShortCommit() string { if len(r.Commit) >= 10 { return r.Commit[:10] } return r.Commit } // StatusSymbol returns a human-readable symbol for the file change status. func (f FileChange) StatusSymbol() string { switch f.Status { case "A": return "+" case "D": return "-" default: return "~" } } type snapshotConfig struct { debug bool } // SnapshotOption configures Snapshot behavior. type SnapshotOption func(*snapshotConfig) // WithDebug enables verbose git output on failure. func WithDebug() SnapshotOption { return func(cfg *snapshotConfig) { cfg.debug = true } } // Snapshot pushes the current working tree state to a remote preflight ref. // It always creates a distinct commit on top of HEAD (even when the worktree // is clean) without touching the real git index. func Snapshot(dir string, preflightID uuid.UUID, opts ...SnapshotOption) (*SnapshotResult, error) { return SnapshotContext(context.Background(), dir, preflightID, opts...) } // SnapshotContext pushes the current working tree state to a remote preflight // ref, aborting in-flight git commands when ctx is canceled. func SnapshotContext(ctx context.Context, dir string, preflightID uuid.UUID, opts ...SnapshotOption) (*SnapshotResult, error) { cfg := &snapshotConfig{} for _, opt := range opts { opt(cfg) } tmp, err := os.CreateTemp("", "git-index-*") if err != nil { return nil, fmt.Errorf("create temp index: %w", err) } tmpIndex := tmp.Name() tmp.Close() defer os.Remove(tmpIndex) env := tempIndexEnv(tmpIndex) // Seed the temp index from HEAD. if err := gitRunContext(ctx, dir, env, cfg.debug, "read-tree", "HEAD"); err != nil { return nil, err } // Stage the entire worktree into the temp index. if err := gitRunContext(ctx, dir, env, cfg.debug, "add", "-A"); err != nil { return nil, err } // Diff the temp index against HEAD to find changed files. files, err := diffFilesContext(ctx, dir, env, cfg.debug) if err != nil { return nil, err } head, err := gitOutputContext(ctx, dir, env, cfg.debug, "rev-parse", "HEAD") if err != nil { return nil, err } branch := fmt.Sprintf("bk/preflight/%s", preflightID.String()) ref := fmt.Sprintf("refs/heads/%s", branch) // Always write a tree and create a new commit, even when there are no // local changes. This ensures the preflight branch always points to a // distinct commit (not shared with HEAD), which allows commit statuses to // be attributed to the preflight run rather than the base commit. tree, err := gitOutputContext(ctx, dir, env, cfg.debug, "write-tree") if err != nil { return nil, err } msg := fmt.Sprintf("Preflight snapshot\n\nPreflight Run ID: %s\nBase Commit: %s", preflightID, head) commit, err := gitOutputContext(ctx, dir, env, cfg.debug, "commit-tree", tree, "-p", head, "-m", msg) if err != nil { return nil, err } // Push the commit to the remote branch. refspec := fmt.Sprintf("%s:%s", commit, ref) if err := gitRunContext(ctx, dir, env, cfg.debug, "push", "origin", refspec); err != nil { return nil, err } return &SnapshotResult{ Commit: commit, Ref: ref, Branch: branch, Files: files, }, nil } // diffFiles returns the list of files changed between HEAD and the temp index. // It uses -z for null-terminated output to correctly handle renames, copies, // and filenames containing spaces or special characters. func diffFiles(dir string, env []string, debug bool) ([]FileChange, error) { return diffFilesContext(context.Background(), dir, env, debug) } func diffFilesContext(ctx context.Context, dir string, env []string, debug bool) ([]FileChange, error) { out, err := gitOutputContext(ctx, dir, env, debug, "diff-index", "--cached", "--name-status", "-z", "-M", "HEAD") if err != nil { return nil, err } if out == "" { return nil, nil } // With -z, git outputs NUL-separated tokens: // status \0 path \0 — for M, A, D, etc. // status \0 old_path \0 new_path \0 — for R (rename) and C (copy) tokens := strings.Split(out, "\x00") var files []FileChange for i := 0; i < len(tokens); i++ { status := tokens[i] if status == "" { continue } code := status[:1] i++ if i >= len(tokens) { break } path := tokens[i] if code == "R" || code == "C" { // Skip old path, use the new path. i++ if i >= len(tokens) { break } path = tokens[i] } files = append(files, FileChange{ Status: code, Path: path, }) } return files, nil } ================================================ FILE: internal/preflight/snapshot_test.go ================================================ package preflight import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/google/uuid" ) // initTestRepo creates a real git repository in a temp directory with an // initial commit and a bare "origin" remote so that Snapshot can push. // It returns the worktree path and a cleanup-aware test helper. func initTestRepo(t *testing.T) string { t.Helper() dir := t.TempDir() worktree := filepath.Join(dir, "work") bare := filepath.Join(dir, "origin.git") // Create the bare remote. runGit(t, "", "init", "--bare", bare) // Create the working repo. runGit(t, "", "init", worktree) runGit(t, worktree, "config", "user.email", "test@test.com") runGit(t, worktree, "config", "user.name", "Test") runGit(t, worktree, "config", "commit.gpgsign", "false") // Create an initial commit so HEAD exists. initial := filepath.Join(worktree, "README.md") if err := os.WriteFile(initial, []byte("# test\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, worktree, "add", ".") runGit(t, worktree, "commit", "-m", "initial commit") // Add the bare repo as origin. runGit(t, worktree, "remote", "add", "origin", bare) return worktree } func runGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) } return strings.TrimSpace(string(out)) } func TestSnapshot_CommittedChanges(t *testing.T) { worktree := initTestRepo(t) // Add a tracked file change (but don't commit it). if err := os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# updated\n"), 0o644); err != nil { t.Fatal(err) } preflightID := uuid.MustParse("00000000-0000-0000-0000-000000000001") result, err := Snapshot(worktree, preflightID) if err != nil { t.Fatalf("Snapshot() error: %v", err) } if len(result.Commit) != 40 { t.Errorf("expected 40-char SHA, got %q (len %d)", result.Commit, len(result.Commit)) } // The commit should exist in the repo. runGit(t, worktree, "cat-file", "-t", result.Commit) // The snapshot tree should contain the updated content. content := runGit(t, worktree, "show", result.Commit+":README.md") if content != "# updated" { t.Errorf("snapshot content = %q, want %q", content, "# updated") } // The remote branch should have been pushed. remoteCommit := runGit(t, worktree, "ls-remote", "origin", result.Ref) if !strings.Contains(remoteCommit, result.Commit) { t.Errorf("remote branch does not contain commit %s, got %q", result.Commit, remoteCommit) } } func TestSnapshotContext_CancelsPush(t *testing.T) { worktree := initTestRepo(t) if err := os.WriteFile(filepath.Join(worktree, "new-file.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } gitPath, err := exec.LookPath("git") if err != nil { t.Fatalf("finding git: %v", err) } fakeBin := t.TempDir() pushStarted := filepath.Join(fakeBin, "push-started") fakeGit := filepath.Join(fakeBin, "git") if err := os.WriteFile(fakeGit, []byte(`#!/bin/sh if [ "$1" = "push" ]; then touch "$PUSH_STARTED" exec /bin/sleep 10 fi exec "$REAL_GIT" "$@" `), 0o755); err != nil { t.Fatalf("writing fake git: %v", err) } t.Setenv("REAL_GIT", gitPath) t.Setenv("PUSH_STARTED", pushStarted) t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { _, err := SnapshotContext(ctx, worktree, uuid.MustParse("00000000-0000-0000-0000-000000000020")) errCh <- err }() deadline := time.After(2 * time.Second) for { if _, err := os.Stat(pushStarted); err == nil { break } else if !os.IsNotExist(err) { t.Fatalf("checking push marker: %v", err) } select { case err := <-errCh: t.Fatalf("SnapshotContext returned before push was canceled: %v", err) case <-deadline: t.Fatal("timed out waiting for git push to start") case <-time.After(10 * time.Millisecond): } } cancel() select { case err := <-errCh: if !errors.Is(err, context.Canceled) { t.Fatalf("expected context canceled error, got: %v", err) } case <-time.After(2 * time.Second): t.Fatal("SnapshotContext did not return promptly after cancellation") } } func TestSnapshot_UntrackedFiles(t *testing.T) { worktree := initTestRepo(t) // Add a brand new untracked file. if err := os.WriteFile(filepath.Join(worktree, "new-file.txt"), []byte("hello\n"), 0o644); err != nil { t.Fatal(err) } preflightID := uuid.MustParse("00000000-0000-0000-0000-000000000002") result, err := Snapshot(worktree, preflightID) if err != nil { t.Fatalf("Snapshot() error: %v", err) } // The snapshot should include the untracked file. content := runGit(t, worktree, "show", result.Commit+":new-file.txt") if content != "hello" { t.Errorf("untracked file content = %q, want %q", content, "hello") } } func TestSnapshot_DoesNotModifyRealIndex(t *testing.T) { worktree := initTestRepo(t) // Create an untracked file. if err := os.WriteFile(filepath.Join(worktree, "untracked.txt"), []byte("data\n"), 0o644); err != nil { t.Fatal(err) } // Record the index state before snapshot. statusBefore := runGit(t, worktree, "status", "--porcelain") _, err := Snapshot(worktree, uuid.MustParse("00000000-0000-0000-0000-000000000003")) if err != nil { t.Fatalf("Snapshot() error: %v", err) } // The real index should be unchanged. statusAfter := runGit(t, worktree, "status", "--porcelain") if statusBefore != statusAfter { t.Errorf("git status changed after Snapshot:\nbefore: %q\nafter: %q", statusBefore, statusAfter) } } func TestSnapshot_UniquePreflightIDs(t *testing.T) { worktree := initTestRepo(t) // First snapshot. result1, err := Snapshot(worktree, uuid.MustParse("00000000-0000-0000-0000-000000000004")) if err != nil { t.Fatalf("first Snapshot() error: %v", err) } // Modify a file and snapshot with a different preflight ID. if err := os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# v2\n"), 0o644); err != nil { t.Fatal(err) } result2, err := Snapshot(worktree, uuid.MustParse("00000000-0000-0000-0000-000000000005")) if err != nil { t.Fatalf("second Snapshot() error: %v", err) } if result1.Commit == result2.Commit { t.Error("expected different commits for different snapshots") } // Both remote branches should exist with their respective commits. remote1 := runGit(t, worktree, "ls-remote", "origin", result1.Ref) if !strings.Contains(remote1, result1.Commit) { t.Errorf("run-1 branch should point to %s, got %q", result1.Commit, remote1) } remote2 := runGit(t, worktree, "ls-remote", "origin", result2.Ref) if !strings.Contains(remote2, result2.Commit) { t.Errorf("run-2 branch should point to %s, got %q", result2.Commit, remote2) } } func TestSnapshotResult_ShortCommit(t *testing.T) { tests := []struct { name string commit string want string }{ { name: "full SHA is truncated to 10 chars", commit: "abc123def456789000aabbccddeeff0011223344", want: "abc123def4", }, { name: "exactly 10 chars", commit: "abc123def4", want: "abc123def4", }, { name: "short commit returned as-is", commit: "abc", want: "abc", }, { name: "empty commit", commit: "", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := SnapshotResult{Commit: tt.commit} if got := r.ShortCommit(); got != tt.want { t.Errorf("ShortCommit() = %q, want %q", got, tt.want) } }) } } // setupDiffEnv creates a temp git index seeded from HEAD and returns the env // slice for use with diffFiles. The caller can stage changes into this index // using git commands with the returned env. func setupDiffEnv(t *testing.T, worktree string) []string { t.Helper() tmp, err := os.CreateTemp("", "git-index-test-*") if err != nil { t.Fatal(err) } tmpIndex := tmp.Name() tmp.Close() t.Cleanup(func() { os.Remove(tmpIndex) }) env := append(os.Environ(), "GIT_INDEX_FILE="+tmpIndex) cmd := exec.Command("git", "read-tree", "HEAD") cmd.Dir = worktree cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git read-tree HEAD: %v\n%s", err, out) } return env } func TestDiffFiles(t *testing.T) { tests := []struct { name string setup func(t *testing.T, worktree string, env []string) want []FileChange }{ { name: "modified file", setup: func(t *testing.T, worktree string, env []string) { t.Helper() os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# changed\n"), 0o644) cmd := exec.Command("git", "add", "README.md") cmd.Dir = worktree cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } }, want: []FileChange{{Status: "M", Path: "README.md"}}, }, { name: "added file", setup: func(t *testing.T, worktree string, env []string) { t.Helper() os.WriteFile(filepath.Join(worktree, "new.txt"), []byte("new\n"), 0o644) cmd := exec.Command("git", "add", "new.txt") cmd.Dir = worktree cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } }, want: []FileChange{{Status: "A", Path: "new.txt"}}, }, { name: "deleted file", setup: func(t *testing.T, worktree string, env []string) { t.Helper() os.Remove(filepath.Join(worktree, "README.md")) cmd := exec.Command("git", "add", "README.md") cmd.Dir = worktree cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } }, want: []FileChange{{Status: "D", Path: "README.md"}}, }, { name: "renamed file", setup: func(t *testing.T, worktree string, env []string) { t.Helper() os.Rename(filepath.Join(worktree, "README.md"), filepath.Join(worktree, "DOCS.md")) cmd := exec.Command("git", "add", "-A") cmd.Dir = worktree cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } }, want: []FileChange{{Status: "R", Path: "DOCS.md"}}, }, { name: "file with spaces in name", setup: func(t *testing.T, worktree string, env []string) { t.Helper() os.WriteFile(filepath.Join(worktree, "my file.txt"), []byte("data\n"), 0o644) cmd := exec.Command("git", "add", "my file.txt") cmd.Dir = worktree cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } }, want: []FileChange{{Status: "A", Path: "my file.txt"}}, }, { name: "no changes", setup: func(t *testing.T, worktree string, env []string) { t.Helper() }, want: nil, }, { name: "multiple changes", setup: func(t *testing.T, worktree string, env []string) { t.Helper() os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# v2\n"), 0o644) os.WriteFile(filepath.Join(worktree, "a.txt"), []byte("a\n"), 0o644) os.WriteFile(filepath.Join(worktree, "b.txt"), []byte("b\n"), 0o644) cmd := exec.Command("git", "add", "-A") cmd.Dir = worktree cmd.Env = env if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } }, want: []FileChange{ {Status: "M", Path: "README.md"}, {Status: "A", Path: "a.txt"}, {Status: "A", Path: "b.txt"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { worktree := initTestRepo(t) env := setupDiffEnv(t, worktree) tt.setup(t, worktree, env) got, err := diffFiles(worktree, env, false) if err != nil { t.Fatalf("diffFiles() error: %v", err) } if len(got) != len(tt.want) { t.Fatalf("diffFiles() returned %d files, want %d\ngot: %+v", len(got), len(tt.want), got) } for i := range tt.want { if got[i].Status != tt.want[i].Status { t.Errorf("file[%d].Status = %q, want %q", i, got[i].Status, tt.want[i].Status) } if got[i].Path != tt.want[i].Path { t.Errorf("file[%d].Path = %q, want %q", i, got[i].Path, tt.want[i].Path) } } }) } } func TestSnapshot_CleanWorktree(t *testing.T) { worktree := initTestRepo(t) preflightID := uuid.MustParse("00000000-0000-0000-0000-000000000006") result, err := Snapshot(worktree, preflightID) if err != nil { t.Fatalf("Snapshot() error: %v", err) } head := runGit(t, worktree, "rev-parse", "HEAD") // Even with a clean worktree a new commit should always be created so // that commit statuses are attributed to the preflight run rather than // the shared HEAD commit. if result.Commit == head { t.Errorf("expected a new commit distinct from HEAD %s, but got the same SHA", head) } if len(result.Files) != 0 { t.Errorf("expected no changed files, got %d", len(result.Files)) } // The new commit should be reachable and its parent should be HEAD. parent := runGit(t, worktree, "rev-parse", result.Commit+"^") if parent != head { t.Errorf("expected parent of snapshot commit to be HEAD %s, got %s", head, parent) } // The remote branch should exist and point to the new commit. remoteRef := runGit(t, worktree, "ls-remote", "origin", result.Ref) if !strings.Contains(remoteRef, result.Commit) { t.Errorf("remote branch should point to snapshot commit %s, got %q", result.Commit, remoteRef) } } ================================================ FILE: internal/secret/view.go ================================================ package secret import ( "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v4" ) // SecretViewTable renders a table view of one or more cluster secrets func SecretViewTable(secrets ...buildkite.ClusterSecret) string { if len(secrets) == 0 { return "No secrets found." } rows := make([][]string, 0, len(secrets)) for _, s := range secrets { rows = append(rows, []string{ output.ValueOrDash(s.Key), output.ValueOrDash(s.ID), output.ValueOrDash(s.Description), }) } return output.Table( []string{"Key", "ID", "Description"}, rows, map[string]string{"key": "bold", "id": "dim", "description": "dim"}, ) } ================================================ FILE: internal/user/user.graphql ================================================ mutation InviteUser($organization: ID!, $emails: [String!]!) { organizationInvitationCreate( input: { organizationID: $organization, emails: $emails } ) { clientMutationId } } query FindUserByEmail($organization: ID!, $email: String!) { organization(slug: $organization) { members(first: 1, email: $email) { edges { node { user { id } } } } } } ================================================ FILE: internal/util/util.go ================================================ package util import ( "encoding/base64" "fmt" "os" "strings" "github.com/pkg/browser" ) func GenerateGraphQLID(prefix, uuid string) string { var graphqlID strings.Builder wr := base64.NewEncoder(base64.StdEncoding, &graphqlID) fmt.Fprintf(wr, "%s%s", prefix, uuid) wr.Close() return graphqlID.String() } func OpenInWebBrowser(openInWeb bool, webUrl string) error { if openInWeb { err := browser.OpenURL(webUrl) if err != nil { fmt.Fprintf(os.Stderr, "Error opening browser: %v\n", err) return err } } return nil } ================================================ FILE: internal/validation/errors.go ================================================ package validation import ( "fmt" "strings" ) type ValidationErrors []ValidationError type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("%s: %s", e.Field, e.Message) } func (e ValidationErrors) Error() string { if len(e) == 0 { return "" } var msgs []string for _, err := range e { msgs = append(msgs, err.Error()) } return strings.Join(msgs, "\n") } ================================================ FILE: internal/validation/rule.go ================================================ package validation type Rule interface { Validate(value interface{}) error } ================================================ FILE: internal/validation/validator.go ================================================ package validation import ( "fmt" "regexp" "strings" ) type Validator struct { rules map[string][]Rule } func New() *Validator { return &Validator{ rules: make(map[string][]Rule), } } func (v *Validator) AddRule(field string, rule Rule) { v.rules[field] = append(v.rules[field], rule) } // Validate validates a map of field/value pairs func (v *Validator) Validate(fields map[string]interface{}) error { var errors ValidationErrors for field, value := range fields { if rules, ok := v.rules[field]; ok { for _, rule := range rules { if err := rule.Validate(value); err != nil { errors = append(errors, ValidationError{ Field: field, Message: err.Error(), }) } } } } if len(errors) > 0 { return errors } return nil } type RequiredRule struct{} func (r RequiredRule) Validate(value interface{}) error { if value == nil { return fmt.Errorf("field is required") } if s, ok := value.(string); ok && strings.TrimSpace(s) == "" { return fmt.Errorf("field is required") } return nil } type SlugRule struct{} func (r SlugRule) Validate(value interface{}) error { s, ok := value.(string) if !ok { return fmt.Errorf("value must be a string") } matched, _ := regexp.MatchString(`^[a-zA-Z0-9]+[a-zA-Z0-9-]*$`, s) if !matched { return fmt.Errorf("must be a valid slug (letters, numbers, and hyphens)") } return nil } type UUIDRule struct{} func (r UUIDRule) Validate(value interface{}) error { s, ok := value.(string) if !ok { return fmt.Errorf("value must be a string") } matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(s)) if !matched { return fmt.Errorf("must be a valid UUID") } return nil } type MinValueRule struct { min int } func (r MinValueRule) Validate(value interface{}) error { num, ok := value.(int) if !ok { return fmt.Errorf("value must be an integer") } if num < r.min { return fmt.Errorf("value must be at least %d", r.min) } return nil } // Common rules that can be reused var ( Required = RequiredRule{} Slug = SlugRule{} UUID = UUIDRule{} MinValue = func(min int) MinValueRule { return MinValueRule{min: min} } ) ================================================ FILE: internal/validation/validator_test.go ================================================ package validation import ( "strings" "testing" ) func TestRequiredRule(t *testing.T) { t.Parallel() tests := map[string]struct { input interface{} wantErr bool errMsg string }{ "nil value": { input: nil, wantErr: true, errMsg: "field is required", }, "empty string": { input: "", wantErr: true, errMsg: "field is required", }, "whitespace string": { input: " ", wantErr: true, errMsg: "field is required", }, "valid string": { input: "test", wantErr: false, }, "valid number": { input: 42, wantErr: false, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() err := Required.Validate(tc.input) if tc.wantErr { if err == nil { t.Error("expected error but got none") } else if !strings.Contains(err.Error(), tc.errMsg) { t.Errorf("expected error containing %q, got %q", tc.errMsg, err.Error()) } } else if err != nil { t.Errorf("unexpected error: %v", err) } }) } t.Run("demonstrating multiple errors per field", func(t *testing.T) { v := New() v.AddRule("name", Required) v.AddRule("name", Slug) err := v.Validate(map[string]interface{}{ "name": "", }) if err == nil { t.Error("expected validation errors but got none") } if validationErrs, ok := err.(ValidationErrors); ok { if len(validationErrs) != 2 { t.Errorf("expected 2 validation errors for empty field (Required + Slug), got %d", len(validationErrs)) } } }) } func TestSlugRule(t *testing.T) { t.Parallel() tests := map[string]struct { input interface{} wantErr bool errMsg string }{ "valid slug": { input: "my-slug-123", wantErr: false, }, "valid slug with mixed case": { input: "acmE", wantErr: false, }, "valid slug with consecutive hyphens": { input: "my--slug", wantErr: false, }, "valid slug with a trailing hyphens": { input: "my-slug-", wantErr: false, }, "invalid characters": { input: "My Slug!", wantErr: true, errMsg: "must be a valid slug", }, "starts with hyphen": { input: "-my-slug", wantErr: true, errMsg: "must be a valid slug", }, "non-string input": { input: 123, wantErr: true, errMsg: "value must be a string", }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() err := Slug.Validate(tc.input) if tc.wantErr { if err == nil { t.Error("expected error but got none") } else if !strings.Contains(err.Error(), tc.errMsg) { t.Errorf("expected error containing %q, got %q", tc.errMsg, err.Error()) } } else if err != nil { t.Errorf("unexpected error: %v", err) } }) } } func TestMinValueRule(t *testing.T) { t.Parallel() tests := map[string]struct { input interface{} min int wantErr bool errMsg string }{ "valid value above minimum": { input: 5, min: 1, wantErr: false, }, "value equal to minimum": { input: 1, min: 1, wantErr: false, }, "value below minimum": { input: 0, min: 1, wantErr: true, errMsg: "value must be at least 1", }, "non-integer input": { input: "not a number", min: 1, wantErr: true, errMsg: "value must be an integer", }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() rule := MinValue(tc.min) err := rule.Validate(tc.input) if tc.wantErr { if err == nil { t.Error("expected error but got none") } else if !strings.Contains(err.Error(), tc.errMsg) { t.Errorf("expected error containing %q, got %q", tc.errMsg, err.Error()) } } else if err != nil { t.Errorf("unexpected error: %v", err) } }) } } func TestValidator(t *testing.T) { t.Parallel() tests := map[string]struct { fields map[string]interface{} rules map[string][]Rule wantErr bool errCount int }{ "single valid field": { fields: map[string]interface{}{ "name": "test-slug", }, rules: map[string][]Rule{ "name": {Required, Slug}, }, wantErr: false, }, "multiple valid fields": { fields: map[string]interface{}{ "name": "test-slug", "count": 5, "enabled": true, }, rules: map[string][]Rule{ "name": {Required, Slug}, "count": {MinValue(1)}, }, wantErr: false, }, "single invalid field": { fields: map[string]interface{}{ "count": 0, }, rules: map[string][]Rule{ "count": {MinValue(1)}, }, wantErr: true, errCount: 1, }, "multiple failures": { fields: map[string]interface{}{ "name": "", "count": 0, }, rules: map[string][]Rule{ "name": {Required, Slug}, "count": {MinValue(1)}, }, wantErr: true, errCount: 3, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() v := New() // Add rules to validator for field, rules := range tc.rules { for _, rule := range rules { v.AddRule(field, rule) } } err := v.Validate(tc.fields) if tc.wantErr { if err == nil { t.Error("expected error but got none") } if validationErrs, ok := err.(ValidationErrors); ok { if len(validationErrs) != tc.errCount { t.Errorf("expected %d validation errors, got %d", tc.errCount, len(validationErrs)) } } else { t.Error("expected ValidationErrors type") } } else if err != nil { t.Errorf("unexpected error: %v", err) } }) } } func TestUUIDRule(t *testing.T) { t.Parallel() tests := map[string]struct { input interface{} wantErr bool errMsg string }{ "valid UUID": { input: "123e4567-e89b-12d3-a456-426614174000", wantErr: false, }, "invalid format": { input: "not-a-uuid", wantErr: true, errMsg: "must be a valid UUID", }, "wrong length": { input: "123e4567-e89b-12d3-a456", wantErr: true, errMsg: "must be a valid UUID", }, "non-string input": { input: 123, wantErr: true, errMsg: "value must be a string", }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() err := UUID.Validate(tc.input) if tc.wantErr { if err == nil { t.Error("expected error but got none") } else if !strings.Contains(err.Error(), tc.errMsg) { t.Errorf("expected error containing %q, got %q", tc.errMsg, err.Error()) } } else if err != nil { t.Errorf("unexpected error: %v", err) } }) } } ================================================ FILE: lefthook.yml ================================================ pre-commit: parallel: true commands: format: glob: "*.go" run: | if command -v gofumpt >/dev/null 2>&1; then gofumpt -w {staged_files} else gofmt -w {staged_files} fi stage_fixed: true lint: glob: "*.go" run: golangci-lint run --fix {staged_files} stage_fixed: true ================================================ FILE: main.go ================================================ package main import ( "bytes" "errors" "fmt" "os" "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/cmd/agent" "github.com/buildkite/cli/v3/cmd/api" "github.com/buildkite/cli/v3/cmd/artifacts" "github.com/buildkite/cli/v3/cmd/auth" "github.com/buildkite/cli/v3/cmd/build" "github.com/buildkite/cli/v3/cmd/cluster" bkConfig "github.com/buildkite/cli/v3/cmd/config" "github.com/buildkite/cli/v3/cmd/configure" bkInit "github.com/buildkite/cli/v3/cmd/init" "github.com/buildkite/cli/v3/cmd/job" "github.com/buildkite/cli/v3/cmd/maintainer" "github.com/buildkite/cli/v3/cmd/organization" "github.com/buildkite/cli/v3/cmd/pipeline" "github.com/buildkite/cli/v3/cmd/pkg" "github.com/buildkite/cli/v3/cmd/preflight" "github.com/buildkite/cli/v3/cmd/queue" "github.com/buildkite/cli/v3/cmd/secret" "github.com/buildkite/cli/v3/cmd/skill" "github.com/buildkite/cli/v3/cmd/use" "github.com/buildkite/cli/v3/cmd/user" versionPkg "github.com/buildkite/cli/v3/cmd/version" "github.com/buildkite/cli/v3/cmd/whoami" "github.com/buildkite/cli/v3/internal/cli" "github.com/buildkite/cli/v3/internal/config" bkErrors "github.com/buildkite/cli/v3/internal/errors" "github.com/buildkite/cli/v3/pkg/analytics" ) // Kong CLI structure, with base commands defined as additional commands are defined in their respective files type CLI struct { // Global flags Yes bool `help:"Skip all confirmation prompts" short:"y"` NoInput bool `help:"Disable all interactive prompts" name:"no-input"` Quiet bool `help:"Suppress progress output" short:"q"` NoPager bool `help:"Disable pager for text output" name:"no-pager"` Debug bool `help:"Enable debug output for REST API calls"` Agent AgentCmd `cmd:"" help:"Manage agents"` Api ApiCmd `cmd:"" help:"Interact with the Buildkite API"` Artifacts ArtifactsCmd `cmd:"" help:"Manage pipeline build artifacts"` Auth AuthCmd `cmd:"" help:"Authenticate with Buildkite"` Build BuildCmd `cmd:"" help:"Manage pipeline builds"` Cluster ClusterCmd `cmd:"" help:"Manage organization clusters"` Maintainer MaintainerCmd `cmd:"" help:"Manage cluster maintainers"` Queue QueueCmd `cmd:"" help:"Manage cluster queues"` Secret SecretCmd `cmd:"" help:"Manage cluster secrets"` Skill SkillCmd `cmd:"" help:"Manage Buildkite skills for AI coding agents"` Config bkConfig.ConfigCmd `cmd:"" help:"Manage CLI configuration"` Configure ConfigureCmd `cmd:"" help:"Configure Buildkite API token" hidden:""` Init bkInit.InitCmd `cmd:"" help:"Initialize a pipeline.yaml file"` Job JobCmd `cmd:"" help:"Manage jobs within a build"` Organization OrganizationCmd `cmd:"" help:"Manage organizations" aliases:"org"` Pipeline PipelineCmd `cmd:"" help:"Manage pipelines"` Package PackageCmd `cmd:"" help:"Manage packages"` Preflight PreflightCmd `cmd:"" help:"Run a build against a snapshot of the local working tree (experimental)"` Use use.UseCmd `cmd:"" help:"Select an organization" hidden:""` User UserCmd `cmd:"" help:"Invite users to the organization"` Version VersionCmd `cmd:"" help:"Print the version of the CLI being used"` Whoami whoami.WhoAmICmd `cmd:"" help:"Print the current user and organization" hidden:""` } type ( VersionCmd struct { versionPkg.VersionCmd `cmd:"" help:"Print the version of the CLI being used"` } AuthCmd struct { Login auth.LoginCmd `cmd:"" help:"Login to Buildkite using OAuth or an API token"` Logout auth.LogoutCmd `cmd:"" help:"Logout and remove stored credentials"` Status auth.StatusCmd `cmd:"" help:"Print the current user auth status"` Switch auth.SwitchCmd `cmd:"" help:"Switch to a different organization" aliases:"use"` Token auth.TokenCmd `cmd:"" help:"Print the stored API token for the current organization"` } AgentCmd struct { Install agent.InstallCmd `cmd:"" help:"Install the buildkite-agent binary locally."` Run agent.RunCmd `cmd:"" help:"Run an ephemeral buildkite-agent locally."` Pause agent.PauseCmd `cmd:"" help:"Pause a Buildkite agent."` List agent.ListCmd `cmd:"" help:"List agents." alias:"ls"` Resume agent.ResumeCmd `cmd:"" help:"Resume a Buildkite agent."` Stop agent.StopCmd `cmd:"" help:"Stop Buildkite agents."` View agent.ViewCmd `cmd:"" help:"View details of an agent."` } ApiCmd struct { api.ApiCmd `cmd:"" help:"Interact with the Buildkite API"` } ArtifactsCmd struct { Download artifacts.DownloadCmd `cmd:"" help:"Download artifacts from a build."` List artifacts.ListCmd `cmd:"" help:"List artifacts for a build or a job in a build." aliases:"ls"` } BuildCmd struct { Create build.CreateCmd `cmd:"" aliases:"new" help:"Create a new build."` // Aliasing "new" because we've renamed this to "create", but we need to support backwards compatibility Cancel build.CancelCmd `cmd:"" help:"Cancel a build."` View build.ViewCmd `cmd:"" help:"View build information."` List build.ListCmd `cmd:"" help:"List builds." aliases:"ls"` Download build.DownloadCmd `cmd:"" help:"Download resources for a build."` Rebuild build.RebuildCmd `cmd:"" help:"Rebuild a build."` Watch build.WatchCmd `cmd:"" help:"Watch a build's progress in real-time."` } ClusterCmd struct { List cluster.ListCmd `cmd:"" help:"List clusters." aliases:"ls"` View cluster.ViewCmd `cmd:"" help:"View cluster information."` Create cluster.CreateCmd `cmd:"" help:"Create a new cluster."` Update cluster.UpdateCmd `cmd:"" help:"Update a cluster."` Delete cluster.DeleteCmd `cmd:"" help:"Delete a cluster." aliases:"rm"` } ConfigureCmd struct { configure.ConfigureCmd `cmd:"" help:"Configure Buildkite API token"` } JobCmd struct { Cancel job.CancelCmd `cmd:"" help:"Cancel a job."` List job.ListCmd `cmd:"" help:"List jobs." aliases:"ls"` Log job.LogCmd `cmd:"" help:"Get logs for a job."` Reprioritize job.ReprioritizeCmd `cmd:"" help:"Reprioritize a job." aliases:"priority"` Retry job.RetryCmd `cmd:"" help:"Retry a job."` Unblock job.UnblockCmd `cmd:"" help:"Unblock a job."` } MaintainerCmd struct { List maintainer.ListCmd `cmd:"" help:"List cluster maintainers." aliases:"ls"` Create maintainer.CreateCmd `cmd:"" help:"Create a cluster maintainer."` Delete maintainer.DeleteCmd `cmd:"" help:"Delete a cluster maintainer." aliases:"rm"` } OrganizationCmd struct { List organization.ListCmd `cmd:"" help:"List configured organizations." aliases:"ls"` } PackageCmd struct { Push pkg.PushCmd `cmd:"" help:"Push a new package to a Buildkite registry"` } PipelineCmd struct { Copy pipeline.CopyCmd `cmd:"" help:"Copy an existing pipeline." aliases:"cp"` Create pipeline.CreateCmd `cmd:"" help:"Create a new pipeline."` List pipeline.ListCmd `cmd:"" help:"List pipelines." aliases:"ls"` Convert pipeline.ConvertCmd `cmd:"" help:"Convert a CI/CD pipeline configuration to Buildkite format." aliases:"migrate"` Validate pipeline.ValidateCmd `cmd:"" help:"Validate a pipeline YAML file."` View pipeline.ViewCmd `cmd:"" help:"View a pipeline."` } PreflightCmd struct { Run preflight.RunCmd `cmd:"" default:"withargs" help:"Run a build against a snapshot of the local working tree (experimental)"` Cleanup preflight.CleanupCmd `cmd:"" help:"Clean up completed preflight branches (experimental)"` } QueueCmd struct { List queue.ListCmd `cmd:"" help:"List cluster queues." aliases:"ls"` View queue.ViewCmd `cmd:"" help:"View a cluster queue."` Create queue.CreateCmd `cmd:"" help:"Create a new cluster queue."` Update queue.UpdateCmd `cmd:"" help:"Update a cluster queue."` Delete queue.DeleteCmd `cmd:"" help:"Delete a cluster queue." aliases:"rm"` Pause queue.PauseCmd `cmd:"" help:"Pause dispatch for a cluster queue."` Resume queue.ResumeCmd `cmd:"" help:"Resume dispatch for a cluster queue."` } SecretCmd struct { List secret.ListCmd `cmd:"" help:"List secrets for a cluster." aliases:"ls"` Get secret.GetCmd `cmd:"" help:"View a cluster secret."` Create secret.CreateCmd `cmd:"" help:"Create a new cluster secret."` Update secret.UpdateCmd `cmd:"" help:"Update a cluster secret."` Delete secret.DeleteCmd `cmd:"" help:"Delete a cluster secret." aliases:"rm"` } SkillCmd struct { Add skill.AddCmd `cmd:"" help:"Install a Buildkite skill."` Update skill.UpdateCmd `cmd:"" help:"Update an installed Buildkite skill."` Delete skill.DeleteCmd `cmd:"" help:"Delete an installed Buildkite skill." aliases:"rm"` } UserCmd struct { Invite user.InviteCmd `cmd:"" help:"Invite users to your organization."` } ) func (c PreflightCmd) Help() string { return preflight.HelpText() } func handleError(err error) { bkErrors.NewHandler().Handle(err) } func newKongParser(cli *CLI, options ...kong.Option) (*kong.Kong, error) { baseOptions := []kong.Option{ kong.Name("bk"), kong.Description("Work with Buildkite from the command line."), kong.Vars{ // Empty default allows commands to fall back to config value "output_default_format": "", "skill_repo": "buildkite/skills", "skill_branch": "main", }, } baseOptions = append(baseOptions, options...) return kong.New(cli, baseOptions...) } func renderHelp(args []string) (string, error) { cli := &CLI{} var stdout, stderr bytes.Buffer parser, err := newKongParser( cli, kong.Writers(&stdout, &stderr), kong.Exit(func(int) {}), ) if err != nil { return "", err } applyExperiments(parser, config.New(nil, nil)) if _, err := parser.Parse(args); err != nil { if stdout.Len() > 0 { return stdout.String(), nil } return "", err } return stdout.String(), nil } func renderPreflightHelp() (string, error) { parentHelp, err := renderHelp([]string{"preflight", "--help"}) if err != nil { return "", err } runHelp, err := renderHelp([]string{"preflight", "run", "--help"}) if err != nil { return "", err } parentFlagsStart := strings.Index(parentHelp, "\nFlags:\n") parentCommandsStart := strings.Index(parentHelp, "\nCommands:\n") runFlagsStart := strings.Index(runHelp, "\nFlags:\n") if parentFlagsStart == -1 || parentCommandsStart == -1 || runFlagsStart == -1 { return parentHelp, nil } return parentHelp[:parentFlagsStart] + runHelp[runFlagsStart:] + parentHelp[parentCommandsStart:], nil } func isPreflightHelpRequest(args []string) bool { switch { case len(args) == 2 && args[0] == "preflight" && (args[1] == "--help" || args[1] == "-h"): return true case len(args) == 2 && args[0] == "help" && args[1] == "preflight": return true default: return false } } // applyExperiments toggles visibility of experimental commands based on config. func applyExperiments(parser *kong.Kong, conf *config.Config) { for _, node := range parser.Model.Children { switch node.Name { case config.ExperimentPreflight: node.Hidden = !conf.HasExperiment(config.ExperimentPreflight) } } } func main() { os.Exit(run()) } func run() int { // Handle no-args and "help" cases by showing help instead of error // This addresses the Kong limitation described in https://github.com/alecthomas/kong/issues/33 if len(os.Args) <= 1 || (len(os.Args) == 2 && os.Args[1] == "help") { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 } applyExperiments(parser, config.New(nil, nil)) _, _ = parser.Parse([]string{"--help"}) return 0 } // Handle --version and -V flags at the top level if len(os.Args) == 2 && (os.Args[1] == "--version" || os.Args[1] == "-V") { fmt.Print(versionPkg.Format(versionPkg.Version)) return 0 } args := os.Args[1:] if isPreflightHelpRequest(args) { help, err := renderPreflightHelp() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 } fmt.Print(help) return 0 } cliInstance := &CLI{} conf := config.New(nil, nil) tracker := analytics.Init("dev", conf.TelemetryEnabled()) defer tracker.Close() tracker.SetOrg(conf.OrganizationSlug()) parser, err := newKongParser(cliInstance) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 } applyExperiments(parser, conf) ctx, err := parser.Parse(args) if err != nil { tracker.TrackCommand("unknown command", args, nil) var parseErr *kong.ParseError if errors.As(err, &parseErr) && !strings.Contains(err.Error(), "did you mean") { _ = parseErr.Context.PrintUsage(false) fmt.Fprintln(os.Stderr) } fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 } tracker.TrackCommand(analytics.ParseSubcommand(ctx.Command()), args, nil) globals := cli.Globals{ Yes: cliInstance.Yes, NoInput: cliInstance.NoInput, Quiet: cliInstance.Quiet, NoPager: cliInstance.NoPager, Debug: cliInstance.Debug, } ctx.BindTo(cli.GlobalFlags(globals), (*cli.GlobalFlags)(nil)) if err := ctx.Run(cliInstance); err != nil { handleError(err) return 1 } return 0 } ================================================ FILE: main_test.go ================================================ package main import ( "os" "strings" "testing" "time" "github.com/buildkite/cli/v3/internal/config" "github.com/spf13/afero" ) func unsetEnv(t *testing.T, key string) { t.Helper() original, had := os.LookupEnv(key) if had { if err := os.Unsetenv(key); err != nil { t.Fatalf("failed to unset env %s: %v", key, err) } } t.Cleanup(func() { var err error if had { err = os.Setenv(key, original) } else { err = os.Unsetenv(key) } if err != nil { t.Fatalf("failed to restore env %s: %v", key, err) } }) } func TestApplyExperiments(t *testing.T) { t.Run("preflight visible by default", func(t *testing.T) { unsetEnv(t, "BUILDKITE_EXPERIMENTS") fs := afero.NewMemMapFs() conf := config.New(fs, nil) cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } applyExperiments(parser, conf) for _, node := range parser.Model.Children { if node.Name == "preflight" { if node.Hidden { t.Error("preflight should be visible by default") } return } } t.Fatal("preflight command not found in parser") }) t.Run("preflight hidden when experiment disabled", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "alpha") fs := afero.NewMemMapFs() conf := config.New(fs, nil) cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } applyExperiments(parser, conf) for _, node := range parser.Model.Children { if node.Name == "preflight" { if !node.Hidden { t.Error("preflight should be hidden when experiment is disabled") } return } } t.Fatal("preflight command not found in parser") }) t.Run("preflight hidden when experiments override is empty", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "") fs := afero.NewMemMapFs() conf := config.New(fs, nil) cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } applyExperiments(parser, conf) for _, node := range parser.Model.Children { if node.Name == "preflight" { if !node.Hidden { t.Error("preflight should be hidden when experiments override is empty") } return } } t.Fatal("preflight command not found in parser") }) t.Run("preflight visible when experiment enabled explicitly", func(t *testing.T) { t.Setenv("BUILDKITE_EXPERIMENTS", "preflight") fs := afero.NewMemMapFs() conf := config.New(fs, nil) cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } applyExperiments(parser, conf) for _, node := range parser.Model.Children { if node.Name == "preflight" { if node.Hidden { t.Error("preflight should be visible when experiment is enabled") } return } } t.Fatal("preflight command not found in parser") }) t.Run("preflight root still parses with default subcommand", func(t *testing.T) { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } if _, err := parser.Parse([]string{"preflight"}); err != nil { t.Fatalf("failed to parse preflight root command: %v", err) } }) t.Run("preflight await-test-results parses without a value", func(t *testing.T) { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } if _, err := parser.Parse([]string{"preflight", "--await-test-results"}); err != nil { t.Fatalf("failed to parse preflight await-test-results flag: %v", err) } if !cli.Preflight.Run.AwaitTestResults.Enabled { t.Fatal("expected await-test-results to be enabled") } if cli.Preflight.Run.AwaitTestResults.Duration != 30*time.Second { t.Fatalf("expected default await-test-results duration, got %s", cli.Preflight.Run.AwaitTestResults.Duration) } }) t.Run("preflight await-test-results parses with an explicit duration", func(t *testing.T) { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } if _, err := parser.Parse([]string{"preflight", "--await-test-results=45s"}); err != nil { t.Fatalf("failed to parse preflight await-test-results duration: %v", err) } if !cli.Preflight.Run.AwaitTestResults.Enabled { t.Fatal("expected await-test-results to be enabled") } if cli.Preflight.Run.AwaitTestResults.Duration != 45*time.Second { t.Fatalf("expected explicit await-test-results duration, got %s", cli.Preflight.Run.AwaitTestResults.Duration) } }) t.Run("preflight exit-on parses repeated flags", func(t *testing.T) { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } if _, err := parser.Parse([]string{"preflight", "--exit-on=build-failing", "--exit-on=build-failing"}); err != nil { t.Fatalf("failed to parse repeated preflight exit-on flags: %v", err) } if len(cli.Preflight.Run.ExitOn) != 2 { t.Fatalf("expected 2 exit-on values, got %d", len(cli.Preflight.Run.ExitOn)) } }) t.Run("preflight exit-on rejects unknown values", func(t *testing.T) { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } if _, err := parser.Parse([]string{"preflight", "--exit-on=test-failed:3"}); err == nil { t.Fatal("expected parse error for invalid exit-on value") } }) t.Run("preflight exit-on rejects incompatible combinations", func(t *testing.T) { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } if _, err := parser.Parse([]string{"preflight", "--exit-on=build-failing", "--exit-on=build-terminal"}); err == nil { t.Fatal("expected parse error for incompatible exit-on values") } }) t.Run("preflight run subcommand still parses", func(t *testing.T) { cli := &CLI{} parser, err := newKongParser(cli) if err != nil { t.Fatalf("failed to create parser: %v", err) } if _, err := parser.Parse([]string{"preflight", "run", "--await-test-results=45s"}); err != nil { t.Fatalf("failed to parse preflight run subcommand: %v", err) } if !cli.Preflight.Run.AwaitTestResults.Enabled { t.Fatal("expected run subcommand await-test-results to be enabled") } if cli.Preflight.Run.AwaitTestResults.Duration != 45*time.Second { t.Fatalf("expected explicit run subcommand await-test-results duration, got %s", cli.Preflight.Run.AwaitTestResults.Duration) } }) t.Run("preflight help includes mirrored run flags", func(t *testing.T) { help, err := renderPreflightHelp() if err != nil { t.Fatalf("failed to render preflight help: %v", err) } for _, want := range []string{ "--[no-]watch", "--exit-on=EXIT-ON,...", "--await-test-results", "--no-cleanup", "preflight cleanup [flags]", } { if !strings.Contains(help, want) { t.Fatalf("expected preflight help to contain %q, got:\n%s", want, help) } } }) t.Run("preflight help requests are detected", func(t *testing.T) { tests := []struct { args []string want bool }{ {args: []string{"preflight", "--help"}, want: true}, {args: []string{"preflight", "-h"}, want: true}, {args: []string{"help", "preflight"}, want: true}, {args: []string{"preflight", "run", "--help"}, want: false}, } for _, tt := range tests { if got := isPreflightHelpRequest(tt.args); got != tt.want { t.Fatalf("isPreflightHelpRequest(%q) = %v, want %v", tt.args, got, tt.want) } } }) } ================================================ FILE: mise.toml ================================================ # Pinned local toolchain for contributors. The Go version matches the # CI and release toolchain; the module minimum stays in go.mod. [settings] experimental = true lockfile = true [tools] go = "1.26.3" golangci-lint = "2.12.2" lefthook = "2.1.6" "aqua:mvdan/gofumpt" = "0.10.0" "go:github.com/nikolaydubina/go-cover-treemap" = "1.5.1" "github:goreleaser/goreleaser-pro" = "2.15.4" ko = "0.18.1" [tasks.format] description = "Format Go files with gofumpt" run = "gofumpt -w ." [tasks.build] description = "Build a local bk binary into dist/" run = "VERSION=\"$(git describe --tags --dirty --always 2>/dev/null || echo DEV)\"; mkdir -p dist && go build -ldflags \"-X github.com/buildkite/cli/v3/cmd/version.Version=$VERSION\" -o dist/bk ." [tasks.install] description = "Install the local bk binary into the active Go bin directory" depends = ["build"] run = "BIN_DIR=\"$(go env GOBIN)\"; if [ -z \"$BIN_DIR\" ]; then BIN_DIR=\"$(go env GOPATH)/bin\"; fi; mkdir -p \"$BIN_DIR\"; install -m 0755 dist/bk \"$BIN_DIR/bk\"" [tasks."install:global"] description = "Install the local bk binary into ~/bin" depends = ["build"] run = "mkdir -p \"$HOME/bin\"; install -m 0755 dist/bk \"$HOME/bin/bk\"" [tasks.lint] description = "Run golangci-lint with the repository configuration" run = "golangci-lint run --verbose --timeout 3m" [tasks.test] description = "Run the Go test suite" run = "go test ./..." [tasks.generate] description = "Regenerate the GraphQL client code" run = "go generate ./cmd/generate" [tasks.hooks] description = "Install the repository git hooks" run = "lefthook install" [tasks.ci] description = "Run the main local CI checks" depends = ["lint", "test"] ================================================ FILE: pkg/analytics/analytics.go ================================================ package analytics import ( "os" "runtime" "strings" "sync" "github.com/posthog/posthog-go" ) var ( // Set via -ldflags at build time: -X github.com/buildkite/cli/v3/pkg/analytics.apiKey=... apiKey = "" apiHost = "https://us.i.posthog.com" ) var ( client posthog.Client once sync.Once ) type Client struct { posthog posthog.Client disabled bool userID string org string } func Init(version string, enabled bool) *Client { if !enabled { return &Client{disabled: true} } key := apiKey if envKey := os.Getenv("BK_ANALYTICS_KEY"); envKey != "" { key = envKey } if key == "" || os.Getenv("CI") != "" { return &Client{disabled: true} } once.Do(func() { var err error client, err = posthog.NewWithConfig(key, posthog.Config{ Endpoint: apiHost, Logger: noopLogger{}, }) if err != nil { client = nil } }) if client == nil { return &Client{disabled: true} } return &Client{ posthog: client, userID: getUserID(), } } func (c *Client) SetOrg(org string) { if c.disabled { return } c.org = org } func (c *Client) TrackCommand(subcommand string, fullArgs []string, properties map[string]interface{}) { if c.disabled || c.posthog == nil { return } props := posthog.NewProperties() props.Set("command", strings.Join(fullArgs, " ")) props.Set("channel", "cli") props.Set("os", runtime.GOOS) props.Set("arch", runtime.GOARCH) if c.org != "" { props.Set("organization", c.org) } for k, v := range properties { props.Set(k, v) } _ = c.posthog.Enqueue(posthog.Capture{ DistinctId: c.userID, Event: subcommand, Properties: props, }) } func (c *Client) Close() { if c.disabled || c.posthog == nil { return } _ = c.posthog.Close() } func getUserID() string { if id := os.Getenv("BUILDKITE_BUILD_ID"); id != "" { return "build:" + id } hostname, err := os.Hostname() if err != nil { return "anonymous" } return "host:" + hostname } // ParseSubcommand extracts the subcommand path from Kong's command string, // removing angle-bracket arguments like "<pipeline>". func ParseSubcommand(kongCommand string) string { parts := strings.Fields(kongCommand) var cmdParts []string for _, p := range parts { if !strings.HasPrefix(p, "<") { cmdParts = append(cmdParts, p) } } return strings.Join(cmdParts, " ") } ================================================ FILE: pkg/analytics/logger.go ================================================ package analytics // noopLogger implements the posthog.Logger interface // It suppresses all PostHog SDK logs type noopLogger struct{} func (noopLogger) Debugf(format string, args ...interface{}) {} func (noopLogger) Logf(format string, args ...interface{}) {} func (noopLogger) Warnf(format string, args ...interface{}) {} func (noopLogger) Errorf(format string, args ...interface{}) {} ================================================ FILE: pkg/cmd/factory/factory.go ================================================ package factory import ( "bytes" "fmt" "io" "net/http" "net/http/httputil" "os" "regexp" "strings" "github.com/Khan/genqlient/graphql" "github.com/buildkite/cli/v3/cmd/version" "github.com/buildkite/cli/v3/internal/config" bkhttp "github.com/buildkite/cli/v3/internal/http" "github.com/buildkite/cli/v3/pkg/keyring" buildkite "github.com/buildkite/go-buildkite/v4" git "github.com/go-git/go-git/v5" ) var baseUserAgent string type Factory struct { Config *config.Config GitRepository *git.Repository GraphQLClient graphql.Client RestAPIClient *buildkite.Client Version string SkipConfirm bool NoInput bool Quiet bool NoPager bool Debug bool } // FactoryOpt is a functional option for configuring the Factory type FactoryOpt func(*factoryConfig) type factoryConfig struct { debug bool orgOverride string transport http.RoundTripper userAgentSuffix string } // WithDebug enables debug output for REST API calls func WithDebug(debug bool) FactoryOpt { return func(c *factoryConfig) { c.debug = debug } } // WithOrgOverride overrides the configured organization slug for API token // resolution. When set, the factory will use the token for this org instead // of the currently selected org. func WithOrgOverride(org string) FactoryOpt { return func(c *factoryConfig) { c.orgOverride = org } } // WithTransport sets a custom http.RoundTripper for the REST API client. // It is composed with the debug transport when debug mode is enabled. func WithTransport(t http.RoundTripper) FactoryOpt { return func(c *factoryConfig) { c.transport = t } } // WithUserAgentSuffix appends an extra product token to the default user agent. func WithUserAgentSuffix(suffix string) FactoryOpt { return func(c *factoryConfig) { c.userAgentSuffix = suffix } } // debugTransport wraps an http.RoundTripper and logs requests/responses with sensitive headers redacted type debugTransport struct { transport http.RoundTripper } // sensitiveHeaders contains headers that should be redacted in debug output var sensitiveHeaders = []string{"Authorization"} func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Save and restore the request body so that dumping it does not consume // the body before the real transport sends it. req.Clone() shares the // underlying Body reader, so DumpRequestOut on a clone drains the // original — leading to an empty/malformed request reaching the server. var bodyBytes []byte if req.Body != nil { var err error bodyBytes, err = io.ReadAll(req.Body) req.Body.Close() if err != nil { return nil, fmt.Errorf("debug transport: reading request body: %w", err) } // Restore the body for the actual request req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) } // Build a clone with its own copy of the body for dumping reqCopy := req.Clone(req.Context()) redactHeaders(reqCopy.Header) if bodyBytes != nil { reqCopy.Body = io.NopCloser(bytes.NewReader(bodyBytes)) } if dump, err := httputil.DumpRequestOut(reqCopy, true); err == nil { fmt.Fprintf(os.Stderr, "DEBUG request uri=%s\n%s\n", req.URL, redactBody(string(dump))) } resp, err := d.transport.RoundTrip(req) if err != nil { return resp, err } if dump, err := httputil.DumpResponse(resp, true); err == nil { fmt.Fprintf(os.Stderr, "DEBUG response uri=%s\n%s\n", req.URL, redactBody(string(dump))) } return resp, nil } // sensitiveBodyPatterns matches token values in form-encoded request bodies // and JSON response bodies that should be redacted in debug output. var sensitiveBodyPatterns = regexp.MustCompile( `((?:refresh_token|access_token|code|code_verifier)=)[^&\s]+` + `|("(?:access_token|refresh_token|code)":\s*")[^"]+("?)`, ) // redactBody replaces sensitive token values in HTTP dumps. func redactBody(dump string) string { return sensitiveBodyPatterns.ReplaceAllStringFunc(dump, func(match string) string { // Form-encoded: key=value if idx := strings.IndexByte(match, '='); idx > 0 && !strings.HasPrefix(match, `"`) { return match[:idx+1] + "[REDACTED]" } // JSON: "key": "value" return sensitiveBodyPatterns.ReplaceAllString(match, `${1}[REDACTED]${2}`) }) } // redactHeaders replaces sensitive header values with [REDACTED] func redactHeaders(headers http.Header) { for _, header := range sensitiveHeaders { if values := headers.Values(header); len(values) > 0 { for i, v := range values { // Keep the auth type (Bearer, Basic, etc.) but redact the token if parts := strings.SplitN(v, " ", 2); len(parts) == 2 { headers[header][i] = parts[0] + " [REDACTED]" } else { headers[header][i] = "[REDACTED]" } } } } } type gqlHTTPClient struct { client *http.Client } func init() { baseUserAgent = fmt.Sprintf("%s buildkite-cli/%s", buildkite.DefaultUserAgent, version.Version) } func buildUserAgent(suffix string) string { if suffix == "" { return baseUserAgent } return fmt.Sprintf("%s %s", baseUserAgent, suffix) } func (a *gqlHTTPClient) Do(req *http.Request) (*http.Response, error) { // Auth and User-Agent are injected by AuthTransport in the // shared HTTP transport chain, so we don't set them here. return a.client.Do(req) } func New(opts ...FactoryOpt) (*Factory, error) { cfg := &factoryConfig{} for _, opt := range opts { opt(cfg) } repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true, EnableDotGitCommonDir: true}) if err != nil { if err == git.ErrRepositoryNotExists { repo = nil } } conf := config.New(nil, repo) token := conf.APIToken() if cfg.orgOverride != "" { if t := conf.APITokenForOrg(cfg.orgOverride); t != "" { token = t } } userAgent := buildUserAgent(cfg.userAgentSuffix) // Build the HTTP transport chain. // // The chain is (outermost first): // RefreshTransport → AuthTransport → debugTransport → base transport // // AuthTransport reads the current token from a shared TokenSource on // every request, so after a refresh all subsequent requests (REST and // GraphQL) immediately use the new token — no stale cached values. transport := http.RoundTripper(http.DefaultTransport) if cfg.transport != nil { transport = cfg.transport } if cfg.debug { transport = &debugTransport{transport: transport} } tokenSource := bkhttp.NewTokenSource(token) transport = &bkhttp.AuthTransport{ Base: transport, TokenSource: tokenSource, UserAgent: userAgent, } // Add refresh transport if a refresh token is available for this org. org := conf.OrganizationSlug() if cfg.orgOverride != "" { org = cfg.orgOverride } kr := keyring.New() if refreshToken, err := kr.GetRefreshToken(org); err == nil && refreshToken != "" { transport = &bkhttp.RefreshTransport{ Base: transport, Org: org, Keyring: kr, TokenSource: tokenSource, } } httpClient := &http.Client{Transport: transport} // go-buildkite still needs WithTokenAuth to satisfy its constructor // requirement, but our AuthTransport is the canonical source of the // Authorization header. clientOpts := []buildkite.ClientOpt{ buildkite.WithBaseURL(conf.RESTAPIEndpoint()), buildkite.WithTokenAuth(token), buildkite.WithUserAgent(userAgent), buildkite.WithHTTPClient(httpClient), } buildkiteClient, err := buildkite.NewOpts(clientOpts...) if err != nil { return nil, fmt.Errorf("creating buildkite client: %w", err) } graphqlHTTPClient := &gqlHTTPClient{client: httpClient} return &Factory{ Config: conf, GitRepository: repo, GraphQLClient: graphql.NewClient(conf.GetGraphQLEndpoint(), graphqlHTTPClient), RestAPIClient: buildkiteClient, Version: version.Version, NoPager: conf.PagerDisabled(), Quiet: conf.Quiet(), NoInput: conf.NoInput(), Debug: cfg.debug, }, nil } ================================================ FILE: pkg/cmd/factory/factory_test.go ================================================ package factory import ( "io" "net/http" "net/http/httptest" "strings" "testing" buildkite "github.com/buildkite/go-buildkite/v4" ) func TestRedactHeaders(t *testing.T) { tests := []struct { name string header string value string expected string }{ { name: "Bearer token", header: "Authorization", value: "Bearer bkua_1234567890abcdef", expected: "Bearer [REDACTED]", }, { name: "Basic auth", header: "Authorization", value: "Basic dXNlcjpwYXNz", expected: "Basic [REDACTED]", }, { name: "Token without type", header: "Authorization", value: "sometoken123", expected: "[REDACTED]", }, { name: "Non-sensitive header unchanged", header: "Content-Type", value: "application/json", expected: "application/json", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { headers := http.Header{} headers.Set(tt.header, tt.value) redactHeaders(headers) got := headers.Get(tt.header) if got != tt.expected { t.Errorf("redactHeaders() = %q, want %q", got, tt.expected) } }) } } func TestRedactHeadersMultipleValues(t *testing.T) { headers := http.Header{} headers.Add("Authorization", "Bearer token1") headers.Add("Authorization", "Bearer token2") redactHeaders(headers) values := headers.Values("Authorization") if len(values) != 2 { t.Fatalf("expected 2 values, got %d", len(values)) } for _, v := range values { if v != "Bearer [REDACTED]" { t.Errorf("expected 'Bearer [REDACTED]', got %q", v) } } } func TestDebugTransportPreservesRequestBody(t *testing.T) { expectedBody := `{"name":"test-pipeline","cluster_id":"","repository":"git@github.com:test/repo.git"}` // Create a test server that checks the request body. // Note: the handler runs in a separate goroutine, so we capture errors // in a variable rather than calling t.Fatalf (which would hang the test). var receivedBody string var handlerErr error server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handlerErr = err w.WriteHeader(http.StatusInternalServerError) return } receivedBody = string(body) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok": true}`)) })) defer server.Close() // Use the debug transport dt := &debugTransport{ transport: http.DefaultTransport, } req, err := http.NewRequest("POST", server.URL, strings.NewReader(expectedBody)) if err != nil { t.Fatalf("failed to create request: %v", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer test-token") resp, err := dt.RoundTrip(req) if err != nil { t.Fatalf("RoundTrip failed: %v", err) } defer resp.Body.Close() if handlerErr != nil { t.Fatalf("handler failed to read request body: %v", handlerErr) } if resp.StatusCode != http.StatusOK { t.Errorf("expected status 200, got %d", resp.StatusCode) } if receivedBody != expectedBody { t.Errorf("request body was not preserved through debug transport\ngot: %q\nwant: %q", receivedBody, expectedBody) } } func TestDebugTransportHandlesNilBody(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() dt := &debugTransport{ transport: http.DefaultTransport, } req, err := http.NewRequest("GET", server.URL, nil) if err != nil { t.Fatalf("failed to create request: %v", err) } resp, err := dt.RoundTrip(req) if err != nil { t.Fatalf("RoundTrip failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected status 200, got %d", resp.StatusCode) } } func TestBuildUserAgent(t *testing.T) { t.Run("default user agent has no preflight suffix", func(t *testing.T) { got := buildUserAgent("") if !strings.Contains(got, buildkite.DefaultUserAgent) { t.Fatalf("expected default user agent %q in %q", buildkite.DefaultUserAgent, got) } if strings.Contains(got, "buildkite-cli-preflight/") { t.Fatalf("expected no preflight suffix in %q", got) } }) t.Run("preflight suffix is appended when requested", func(t *testing.T) { got := buildUserAgent("buildkite-cli-preflight/3.x") if !strings.Contains(got, buildkite.DefaultUserAgent) { t.Fatalf("expected default user agent %q in %q", buildkite.DefaultUserAgent, got) } if !strings.Contains(got, "buildkite-cli-preflight/3.x") { t.Fatalf("expected preflight suffix in %q", got) } }) } func TestNewUserAgent(t *testing.T) { t.Chdir(t.TempDir()) t.Run("non-preflight factory does not set preflight suffix", func(t *testing.T) { f, err := New() if err != nil { t.Fatalf("New() error = %v", err) } if strings.Contains(f.RestAPIClient.UserAgent, "buildkite-cli-preflight/") { t.Fatalf("expected no preflight suffix in %q", f.RestAPIClient.UserAgent) } }) t.Run("factory can opt in to preflight suffix", func(t *testing.T) { f, err := New(WithUserAgentSuffix("buildkite-cli-preflight/3.x")) if err != nil { t.Fatalf("New() error = %v", err) } if !strings.Contains(f.RestAPIClient.UserAgent, "buildkite-cli-preflight/3.x") { t.Fatalf("expected preflight suffix in %q", f.RestAPIClient.UserAgent) } }) } ================================================ FILE: pkg/cmd/validation/config.go ================================================ package validation import ( "errors" "fmt" "os" "strings" "github.com/buildkite/cli/v3/internal/config" ) // CommandsNotRequiringToken is a list of command paths that don't require an API token var CommandsNotRequiringToken = []string{ "pipeline validate", // The pipeline validate command doesn't require an API token "pipeline migrate", // The pipeline migrate command uses a public migration API } // ValidateConfiguration checks that the configuration is valid to execute the command (Kong version) func ValidateConfiguration(conf *config.Config, commandPath string) error { return validateConfiguration(conf, commandPath, "") } // ValidateConfigurationForOrg checks configuration for a specific organization // context when a command supports --org. func ValidateConfigurationForOrg(conf *config.Config, commandPath, org string) error { return validateConfiguration(conf, commandPath, org) } func validateConfiguration(conf *config.Config, commandPath, orgOverride string) error { org := conf.OrganizationSlug() token := conf.APIToken() if orgOverride != "" { org = orgOverride if t := conf.APITokenForOrg(org); t != "" { token = t } } missingToken := token == "" missingOrg := org == "" // Skip token check for all configure commands if strings.HasPrefix(commandPath, "configure") { return nil } // Skip token check for commands that don't need it for _, exemptCmd := range CommandsNotRequiringToken { // Check if the command path ends with the exempt command pattern if strings.HasSuffix(commandPath, exemptCmd) { return nil // Skip validation for exempt commands } } switch { case missingToken && missingOrg: return errors.New("you are not authenticated. Run bk auth login to authenticate, or run bk use to select a configured organization") case missingToken: return errors.New("you are not authenticated. Run bk auth login to authenticate") // an organization may not be present if the user is only viewing public resources case missingOrg: fmt.Fprintln(os.Stderr, "Warning: no organization set, only public pipelines will be visible. Run bk auth login, or bk use, to set an organization") return nil } return nil } ================================================ FILE: pkg/cmd/validation/config_test.go ================================================ package validation import ( "io" "os" "strings" "testing" "github.com/buildkite/cli/v3/internal/config" bkKeyring "github.com/buildkite/cli/v3/pkg/keyring" ) func TestValidateConfiguration_ExemptCommands(t *testing.T) { t.Setenv("BUILDKITE_API_TOKEN", "") t.Setenv("BUILDKITE_ORGANIZATION_SLUG", "") conf := newTestConfig(t) for _, path := range []string{ "pipeline validate", "pipeline migrate", "configure", "configure default", "configure add", } { if err := ValidateConfiguration(conf, path); err != nil { t.Fatalf("expected no error for exempt command %q, got %v", path, err) } } } func TestValidateConfiguration_MissingValues(t *testing.T) { t.Run("missing token and org", func(t *testing.T) { t.Setenv("BUILDKITE_API_TOKEN", "") t.Setenv("BUILDKITE_ORGANIZATION_SLUG", "") conf := newTestConfig(t) if err := ValidateConfiguration(conf, "pipeline view"); err == nil { t.Fatalf("expected error when token and org are missing") } }) t.Run("missing token", func(t *testing.T) { t.Setenv("BUILDKITE_API_TOKEN", "") t.Setenv("BUILDKITE_ORGANIZATION_SLUG", "org") conf := newTestConfig(t) if err := ValidateConfiguration(conf, "pipeline view"); err == nil { t.Fatalf("expected error when token is missing") } }) t.Run("token and org present", func(t *testing.T) { t.Setenv("BUILDKITE_API_TOKEN", "token2") t.Setenv("BUILDKITE_ORGANIZATION_SLUG", "org2") conf := newTestConfig(t) if err := ValidateConfiguration(conf, "pipeline view"); err != nil { t.Fatalf("expected no error when token and org are set, got %v", err) } }) t.Run("missing org warning is written to stderr", func(t *testing.T) { t.Setenv("BUILDKITE_API_TOKEN", "token") t.Setenv("BUILDKITE_ORGANIZATION_SLUG", "") conf := newTestConfig(t) var validationErr error stdout, stderr := captureStandardStreams(t, func() { validationErr = ValidateConfiguration(conf, "pipeline view") }) if validationErr != nil { t.Fatalf("expected no error when only org is missing, got %v", validationErr) } if stdout != "" { t.Fatalf("expected stdout to remain empty, got %q", stdout) } if !strings.Contains(stderr, "Warning: no organization set") { t.Fatalf("expected stderr warning, got %q", stderr) } }) } func newTestConfig(t *testing.T) *config.Config { t.Helper() t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", "") bkKeyring.MockForTesting() return config.New(nil, nil) } func captureStandardStreams(t *testing.T, fn func()) (stdout, stderr string) { t.Helper() oldStdout := os.Stdout oldStderr := os.Stderr stdoutR, stdoutW, err := os.Pipe() if err != nil { t.Fatalf("os.Pipe() stdout error = %v", err) } stderrR, stderrW, err := os.Pipe() if err != nil { t.Fatalf("os.Pipe() stderr error = %v", err) } os.Stdout = stdoutW os.Stderr = stderrW defer func() { os.Stdout = oldStdout os.Stderr = oldStderr }() fn() if err := stdoutW.Close(); err != nil { t.Fatalf("stdout close error = %v", err) } if err := stderrW.Close(); err != nil { t.Fatalf("stderr close error = %v", err) } stdoutBytes, err := io.ReadAll(stdoutR) if err != nil { t.Fatalf("stdout read error = %v", err) } stderrBytes, err := io.ReadAll(stderrR) if err != nil { t.Fatalf("stderr read error = %v", err) } if err := stdoutR.Close(); err != nil { t.Fatalf("stdout reader close error = %v", err) } if err := stderrR.Close(); err != nil { t.Fatalf("stderr reader close error = %v", err) } return string(stdoutBytes), string(stderrBytes) } ================================================ FILE: pkg/keyring/keyring.go ================================================ // Package keyring provides secure credential storage using the OS keychain. // It falls back to file-based storage when the keychain is unavailable (e.g., in CI environments). package keyring import ( "os" "sync" "github.com/zalando/go-keyring" ) const ( serviceName = "buildkite-cli" refreshServiceName = "buildkite-cli-refresh" ) var ( keyringAvailableOnce sync.Once keyringAvailable bool ) // Keyring provides secure credential storage with fallback support type Keyring struct { useKeyring bool } // New creates a new Keyring instance. // It automatically detects if the system keyring is available. func New() *Keyring { return &Keyring{ useKeyring: isKeyringAvailable(), } } // Set stores a token for the given organization func (k *Keyring) Set(org, token string) error { if !k.useKeyring { return nil // Fallback handled by config file } return keyring.Set(serviceName, org, token) } // Get retrieves a token for the given organization func (k *Keyring) Get(org string) (string, error) { if !k.useKeyring { return "", keyring.ErrNotFound } return keyring.Get(serviceName, org) } // Delete removes a token for the given organization func (k *Keyring) Delete(org string) error { if !k.useKeyring { return nil } return keyring.Delete(serviceName, org) } // SetRefreshToken stores a refresh token for the given organization func (k *Keyring) SetRefreshToken(org, token string) error { if !k.useKeyring { return nil } return keyring.Set(refreshServiceName, org, token) } // GetRefreshToken retrieves a refresh token for the given organization func (k *Keyring) GetRefreshToken(org string) (string, error) { if !k.useKeyring { return "", keyring.ErrNotFound } return keyring.Get(refreshServiceName, org) } // DeleteRefreshToken removes a refresh token for the given organization func (k *Keyring) DeleteRefreshToken(org string) error { if !k.useKeyring { return nil } return keyring.Delete(refreshServiceName, org) } // IsAvailable returns true if the system keyring is available func (k *Keyring) IsAvailable() bool { return k.useKeyring } // MockForTesting replaces the keyring backend with an in-memory store // and marks it as available so subsequent New() calls use the mock. func MockForTesting() { keyring.MockInit() keyringAvailableOnce = sync.Once{} keyringAvailableOnce.Do(func() { keyringAvailable = true }) } // ResetForTesting resets the availability cache so that the next call to // New() re-evaluates the environment. Intended for use in tests only. func ResetForTesting() { keyringAvailableOnce = sync.Once{} keyringAvailable = false } // isKeyringAvailable checks if the system keyring can be used func isKeyringAvailable() bool { keyringAvailableOnce.Do(func() { // Disable keyring if explicitly opted out if os.Getenv("BUILDKITE_NO_KEYRING") != "" { keyringAvailable = false return } // Disable keyring in CI environments if os.Getenv("CI") != "" || os.Getenv("BUILDKITE") != "" { keyringAvailable = false return } // Assume keyring is available; callers can handle errors keyringAvailable = true }) return keyringAvailable } ================================================ FILE: pkg/keyring/keyring_test.go ================================================ package keyring import ( "os" "testing" ) // setEnv sets an environment variable for the duration of the test and // restores the original value (or unsets it) via t.Cleanup. func setEnv(t *testing.T, key, value string) { t.Helper() original, had := os.LookupEnv(key) if err := os.Setenv(key, value); err != nil { t.Fatalf("failed to set env %s: %v", key, err) } t.Cleanup(func() { if had { os.Setenv(key, original) } else { os.Unsetenv(key) } // Reset the once so the next test starts fresh. ResetForTesting() }) // Reset now so this test sees the new env value. ResetForTesting() } func TestIsKeyringAvailable(t *testing.T) { // These tests manipulate package-level state (sync.Once) so must not run // in parallel with each other. t.Run("disabled by BUILDKITE_NO_KEYRING", func(t *testing.T) { setEnv(t, "BUILDKITE_NO_KEYRING", "1") setEnv(t, "CI", "") setEnv(t, "BUILDKITE", "") kr := New() if kr.IsAvailable() { t.Error("expected keyring to be unavailable when BUILDKITE_NO_KEYRING is set") } }) t.Run("disabled by CI", func(t *testing.T) { setEnv(t, "CI", "true") setEnv(t, "BUILDKITE_NO_KEYRING", "") setEnv(t, "BUILDKITE", "") kr := New() if kr.IsAvailable() { t.Error("expected keyring to be unavailable when CI is set") } }) t.Run("disabled by BUILDKITE", func(t *testing.T) { setEnv(t, "BUILDKITE", "true") setEnv(t, "BUILDKITE_NO_KEYRING", "") setEnv(t, "CI", "") kr := New() if kr.IsAvailable() { t.Error("expected keyring to be unavailable when BUILDKITE is set") } }) } func TestNoKeyringGet(t *testing.T) { setEnv(t, "BUILDKITE_NO_KEYRING", "1") setEnv(t, "CI", "") setEnv(t, "BUILDKITE", "") kr := New() token, err := kr.Get("my-org") if token != "" { t.Errorf("Get() returned non-empty token with keyring disabled, got %q", token) } if err == nil { t.Error("Get() expected ErrNotFound when keyring is disabled, got nil") } } func TestNoKeyringSet(t *testing.T) { setEnv(t, "BUILDKITE_NO_KEYRING", "1") setEnv(t, "CI", "") setEnv(t, "BUILDKITE", "") kr := New() if err := kr.Set("my-org", "token-123"); err != nil { t.Errorf("Set() returned unexpected error with keyring disabled: %v", err) } } func TestNoKeyringDelete(t *testing.T) { setEnv(t, "BUILDKITE_NO_KEYRING", "1") setEnv(t, "CI", "") setEnv(t, "BUILDKITE", "") kr := New() if err := kr.Delete("my-org"); err != nil { t.Errorf("Delete() returned unexpected error with keyring disabled: %v", err) } } ================================================ FILE: pkg/oauth/oauth.go ================================================ // Package oauth provides OAuth 2.0 PKCE authentication flow for CLI applications package oauth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "os" "strings" "time" ) const ( DefaultHost = "buildkite.com" ) // AllScopes is the complete set of Buildkite API token scopes. When no --scopes // flag is provided, the CLI requests all of these and Buildkite grants only the // ones the user actually has permission for. // // Reference: https://buildkite.com/docs/apis/managing-api-tokens var AllScopes = []string{ // CI/CD "read_agents", "read_artifacts", "read_build_logs", "read_builds", "read_clusters", "read_job_env", "read_pipeline_templates", "read_pipelines", "read_rules", "write_agents", "write_artifacts", "write_build_logs", "write_builds", "write_clusters", "write_pipeline_templates", "write_pipelines", "write_rules", // Organization and Users "read_organizations", "read_teams", "read_user", "write_teams", // Security "read_secrets_details", "write_secrets", // Test Engine "read_suites", "read_test_plan", "write_suites", "write_test_plan", // Packages "delete_packages", "delete_registries", "read_packages", "read_registries", "write_packages", "write_registries", // Portals "read_portals", "write_portals", } // ScopeGroups defines named groups of scopes that can be used with --scopes. // For example, --scopes "read_only" expands to all read_* scopes. var ScopeGroups = map[string][]string{ "read_only": { "read_agents", "read_artifacts", "read_build_logs", "read_builds", "read_clusters", "read_job_env", "read_organizations", "read_packages", "read_pipeline_templates", "read_pipelines", "read_portals", "read_registries", "read_rules", "read_secrets_details", "read_suites", "read_teams", "read_test_plan", "read_user", }, } // ResolveScopes expands scope group names (e.g., "read_only") into their // individual scopes. Unknown tokens are passed through as literal scopes. // Multiple groups and individual scopes can be mixed: // // "read_only write_builds" → "read_agents read_artifacts ... write_builds" func ResolveScopes(input string) string { if input == "" { return "" } seen := make(map[string]bool) var resolved []string for _, token := range strings.Fields(input) { if group, ok := ScopeGroups[token]; ok { for _, s := range group { if !seen[s] { seen[s] = true resolved = append(resolved, s) } } } else { if !seen[token] { seen[token] = true resolved = append(resolved, token) } } } return strings.Join(resolved, " ") } // DefaultClientID is the OAuth client ID for the Buildkite CLI // This can be overridden with ldflags var DefaultClientID = "5214b230f06b48938ab5" // Config holds OAuth configuration type Config struct { Host string // e.g., "buildkite.com" ClientID string // OAuth client ID OrgSlug string // Organization slug to request access for OrgUUID string // Organization UUID to request access for CallbackURL string // e.g., "http://127.0.0.1:8080/callback" Scopes string // Space-separated OAuth scopes } // CallbackResult holds the result from the OAuth callback type CallbackResult struct { Code string State string Error string } // TokenResponse holds the token exchange response type TokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` RefreshToken string `json:"refresh_token,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` Error string `json:"error,omitempty"` ErrorDesc string `json:"error_description,omitempty"` } // Flow manages an OAuth authentication flow type Flow struct { config *Config codeVerifier string state string listener net.Listener } // NewFlow creates a new OAuth flow func NewFlow(cfg *Config) (*Flow, error) { if cfg.Host == "" { // Allow override via environment variable for local development if envHost := os.Getenv("BUILDKITE_HOST"); envHost != "" { cfg.Host = envHost } else { cfg.Host = DefaultHost } } if cfg.ClientID == "" { cfg.ClientID = DefaultClientID } if cfg.Scopes == "" { cfg.Scopes = strings.Join(AllScopes, " ") } // Generate PKCE verifier and state codeVerifier, err := generateCodeVerifier() if err != nil { return nil, fmt.Errorf("failed to generate code verifier: %w", err) } state, err := generateState() if err != nil { return nil, fmt.Errorf("failed to generate state: %w", err) } var listener net.Listener // Only start local callback server if no custom redirect URI provided if cfg.CallbackURL == "" { var err error listener, err = net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("failed to start callback server: %w", err) } cfg.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d/callback", listener.Addr().(*net.TCPAddr).Port) } return &Flow{ config: cfg, codeVerifier: codeVerifier, state: state, listener: listener, }, nil } // AuthorizationURL returns the URL to open in the browser func (f *Flow) AuthorizationURL() string { codeChallenge := generateCodeChallenge(f.codeVerifier) params := url.Values{ "client_id": {f.config.ClientID}, "response_type": {"code"}, "scope": {f.config.Scopes}, "redirect_uri": {f.config.CallbackURL}, "state": {f.state}, "code_challenge": {codeChallenge}, "code_challenge_method": {"S256"}, } if f.config.OrgUUID != "" { params.Set("organization_uuid", f.config.OrgUUID) } else if f.config.OrgSlug != "" { params.Set("organization", f.config.OrgSlug) } return fmt.Sprintf("https://%s/oauth/authorize?%s", f.config.Host, params.Encode()) } // WaitForCallback waits for the OAuth callback and returns the authorization code func (f *Flow) WaitForCallback(ctx context.Context) (*CallbackResult, error) { if f.listener == nil { return nil, fmt.Errorf("callback listener not available as a custom CallbackURL was provided") } resultCh := make(chan *CallbackResult, 1) errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") errMsg := r.URL.Query().Get("error") result := &CallbackResult{ Code: code, State: state, Error: errMsg, } // Validate state if state != f.state { result.Error = "state mismatch - possible CSRF attack" } // Send response to browser w.Header().Set("Content-Type", "text/html") if result.Error == "" && result.Code != "" { // This is the page which lets folks know if they have been auth'd etc // then that they can close the window, I tried adding an emoji in here // but it renders weird fmt.Fprint(w, `<!DOCTYPE html> <html> <head><title>Authentication Successful

✓ Authentication Successful

You can close this window and return to your terminal.

`) } else { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, ` Authentication Failed

✕ Authentication Failed

Error: %s

`, result.Error) } resultCh <- result }) server := &http.Server{Handler: mux} go func() { if err := server.Serve(f.listener); err != http.ErrServerClosed { errCh <- err } }() defer func() { _ = server.Shutdown(context.Background()) }() select { case result := <-resultCh: if result.Error != "" { return nil, fmt.Errorf("authorization failed: %s", result.Error) } return result, nil case err := <-errCh: return nil, fmt.Errorf("callback server error: %w", err) case <-ctx.Done(): return nil, ctx.Err() } } // ExchangeCode exchanges the authorization code for an access token func (f *Flow) ExchangeCode(ctx context.Context, code string) (*TokenResponse, error) { tokenURL := fmt.Sprintf("https://%s/oauth/token", f.config.Host) data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "client_id": {f.config.ClientID}, "redirect_uri": {f.config.CallbackURL}, "code_verifier": {f.codeVerifier}, } req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("token request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var tokenResp TokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } if tokenResp.Error != "" { return nil, fmt.Errorf("token error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc) } if tokenResp.AccessToken == "" { return nil, fmt.Errorf("no access token in response") } return &tokenResp, nil } // RefreshAccessToken exchanges a refresh token for a new access token and refresh token. func RefreshAccessToken(ctx context.Context, host, clientID, refreshToken string) (*TokenResponse, error) { if host == "" { if envHost := os.Getenv("BUILDKITE_HOST"); envHost != "" { host = envHost } else { host = DefaultHost } } if clientID == "" { clientID = DefaultClientID } tokenURL := fmt.Sprintf("https://%s/oauth/token", host) data := url.Values{ "grant_type": {"refresh_token"}, "refresh_token": {refreshToken}, "client_id": {clientID}, } req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("refresh token request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var tokenResp TokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } if tokenResp.Error != "" { return nil, fmt.Errorf("token refresh error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc) } if tokenResp.AccessToken == "" { return nil, fmt.Errorf("no access token in refresh response") } return &tokenResp, nil } // Close cleans up the OAuth flow resources func (f *Flow) Close() error { if f.listener != nil { return f.listener.Close() } return nil } // generateCodeVerifier generates a PKCE code verifier func generateCodeVerifier() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // generateCodeChallenge generates a PKCE code challenge from the verifier func generateCodeChallenge(verifier string) string { hash := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(hash[:]) } // generateState generates a random state parameter func generateState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } ================================================ FILE: pkg/oauth/oauth_test.go ================================================ package oauth import ( "net/url" "strings" "testing" ) func TestResolveScopes(t *testing.T) { t.Parallel() readOnlyExpanded := strings.Join(ScopeGroups["read_only"], " ") tests := []struct { name string input string want string }{ { name: "empty returns empty", input: "", want: "", }, { name: "individual scopes pass through", input: "read_user write_builds", want: "read_user write_builds", }, { name: "read_only group expands", input: "read_only", want: readOnlyExpanded, }, { name: "group mixed with individual scopes", input: "read_only write_builds", want: readOnlyExpanded + " write_builds", }, { name: "duplicate scopes are deduplicated", input: "read_only read_user read_builds", want: readOnlyExpanded, }, { name: "unknown group names pass through as literal scopes", input: "custom_scope", want: "custom_scope", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := ResolveScopes(tt.input) if got != tt.want { t.Errorf("ResolveScopes(%q)\n got: %q\n want: %q", tt.input, got, tt.want) } }) } } func TestNewFlow_DefaultsToAllScopes(t *testing.T) { t.Parallel() flow, err := NewFlow(&Config{ Host: "buildkite.com", ClientID: "test-client", CallbackURL: "http://localhost:9999/callback", Scopes: "", }) if err != nil { t.Fatalf("NewFlow: %v", err) } authURL := flow.AuthorizationURL() if !strings.Contains(authURL, "scope=") { t.Fatal("expected scope parameter in URL") } // Verify all scopes are present for _, s := range AllScopes { if !strings.Contains(authURL, s) { t.Errorf("expected scope %q in URL, got: %s", s, authURL) } } } func TestAuthorizationURL_IncludesOrganizationSlug(t *testing.T) { t.Parallel() flow, err := NewFlow(&Config{ Host: "buildkite.com", ClientID: "test-client", CallbackURL: "http://localhost:9999/callback", OrgSlug: "buildkite", Scopes: "read_user", }) if err != nil { t.Fatalf("NewFlow: %v", err) } authURL, err := url.Parse(flow.AuthorizationURL()) if err != nil { t.Fatalf("Parse AuthorizationURL: %v", err) } query := authURL.Query() if got := query.Get("organization"); got != "buildkite" { t.Fatalf("organization = %q, want %q", got, "buildkite") } if got := query.Get("organization_uuid"); got != "" { t.Fatalf("organization_uuid = %q, want empty", got) } } func TestAuthorizationURL_IncludesOrganizationUUID(t *testing.T) { t.Parallel() const orgUUID = "018f2f7e-7e99-7d77-b4d3-a95cb01805f4" flow, err := NewFlow(&Config{ Host: "buildkite.com", ClientID: "test-client", CallbackURL: "http://localhost:9999/callback", OrgUUID: orgUUID, Scopes: "read_user", }) if err != nil { t.Fatalf("NewFlow: %v", err) } authURL, err := url.Parse(flow.AuthorizationURL()) if err != nil { t.Fatalf("Parse AuthorizationURL: %v", err) } query := authURL.Query() if got := query.Get("organization_uuid"); got != orgUUID { t.Fatalf("organization_uuid = %q, want %q", got, orgUUID) } if got := query.Get("organization"); got != "" { t.Fatalf("organization = %q, want empty", got) } } func TestAuthorizationURL_UsesProvidedScopes(t *testing.T) { t.Parallel() flow, err := NewFlow(&Config{ Host: "buildkite.com", ClientID: "test-client", CallbackURL: "http://localhost:9999/callback", Scopes: "read_user read_builds", }) if err != nil { t.Fatalf("NewFlow: %v", err) } authURL := flow.AuthorizationURL() if !strings.Contains(authURL, "scope=") { t.Fatal("expected scope parameter in URL") } // Should use the provided scopes, not all scopes if strings.Contains(authURL, "write_builds") { t.Errorf("expected only provided scopes, but found write_builds in URL: %s", authURL) } } ================================================ FILE: pkg/oauth/refresh_test.go ================================================ package oauth import ( "context" "net/http" "net/http/httptest" "testing" ) func TestRefreshAccessToken_Success(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/oauth/token" { t.Errorf("expected /oauth/token, got %s", r.URL.Path) } if err := r.ParseForm(); err != nil { t.Fatalf("failed to parse form: %v", err) } if got := r.FormValue("grant_type"); got != "refresh_token" { t.Errorf("expected grant_type=refresh_token, got %s", got) } if got := r.FormValue("refresh_token"); got != "bkur_old_refresh_token" { t.Errorf("expected refresh_token=bkur_old_refresh_token, got %s", got) } if got := r.FormValue("client_id"); got != "test-client" { t.Errorf("expected client_id=test-client, got %s", got) } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "access_token": "new_access_token", "token_type": "Bearer", "scope": "read_user read_organizations", "refresh_token": "bkur_new_refresh_token", "expires_in": 3600 }`)) })) defer server.Close() // Override the default HTTP client to trust the test server's TLS cert origTransport := http.DefaultTransport http.DefaultTransport = server.Client().Transport defer func() { http.DefaultTransport = origTransport }() // Extract host from the test server URL (strip https://) host := server.URL[len("https://"):] resp, err := RefreshAccessToken(context.Background(), host, "test-client", "bkur_old_refresh_token") if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.AccessToken != "new_access_token" { t.Errorf("expected access_token=new_access_token, got %s", resp.AccessToken) } if resp.RefreshToken != "bkur_new_refresh_token" { t.Errorf("expected refresh_token=bkur_new_refresh_token, got %s", resp.RefreshToken) } if resp.ExpiresIn != 3600 { t.Errorf("expected expires_in=3600, got %d", resp.ExpiresIn) } if resp.Scope != "read_user read_organizations" { t.Errorf("expected scope=read_user read_organizations, got %s", resp.Scope) } } func TestRefreshAccessToken_ErrorResponse(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{ "error": "invalid_grant", "error_description": "Invalid refresh token" }`)) })) defer server.Close() origTransport := http.DefaultTransport http.DefaultTransport = server.Client().Transport defer func() { http.DefaultTransport = origTransport }() host := server.URL[len("https://"):] _, err := RefreshAccessToken(context.Background(), host, "test-client", "bad-token") if err == nil { t.Fatal("expected error, got nil") } expected := "token refresh error: invalid_grant - Invalid refresh token" if err.Error() != expected { t.Errorf("expected error %q, got %q", expected, err.Error()) } } ================================================ FILE: pkg/output/color.go ================================================ package output import ( "os" "sync" "github.com/mattn/go-isatty" ) var ( colorOnce sync.Once colorEnabled = true ) // ColorEnabled returns false when the NO_COLOR environment variable is present // See https://no-color.org for the convention func ColorEnabled() bool { colorOnce.Do(func() { if _, disabled := os.LookupEnv("NO_COLOR"); disabled { colorEnabled = false return } if term := os.Getenv("TERM"); term == "dumb" { colorEnabled = false return } if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { colorEnabled = false return } }) return colorEnabled } ================================================ FILE: pkg/output/flags.go ================================================ package output // OutputFlags provides shorthand flags for output format selection. // Embed this struct in command structs to get --json, --yaml, --text flags // in addition to the existing --output/-o flag. type OutputFlags struct { JSON bool `help:"Output as JSON" xor:"format"` YAML bool `help:"Output as YAML" xor:"format"` Text bool `help:"Output as text" xor:"format"` Output string `help:"Output format. One of: json, yaml, text" short:"o" default:"${output_default_format}" enum:",json,yaml,text"` } // AfterApply is called by Kong after parsing to map boolean flags to the Output string. func (o *OutputFlags) AfterApply() error { switch { case o.JSON: o.Output = "json" case o.YAML: o.Output = "yaml" case o.Text: o.Output = "text" } return nil } ================================================ FILE: pkg/output/flags_test.go ================================================ package output import "testing" func TestOutputFlags_AfterApply(t *testing.T) { tests := []struct { name string flags OutputFlags expected string }{ { name: "json flag sets output to json", flags: OutputFlags{JSON: true}, expected: "json", }, { name: "yaml flag sets output to yaml", flags: OutputFlags{YAML: true}, expected: "yaml", }, { name: "text flag sets output to text", flags: OutputFlags{Text: true}, expected: "text", }, { name: "no flags leaves output empty", flags: OutputFlags{}, expected: "", }, { name: "explicit output value is preserved when no bool flags set", flags: OutputFlags{Output: "yaml"}, expected: "yaml", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.flags.AfterApply() if err != nil { t.Fatalf("unexpected error: %v", err) } if tt.flags.Output != tt.expected { t.Errorf("expected Output=%q, got %q", tt.expected, tt.flags.Output) } }) } } ================================================ FILE: pkg/output/output.go ================================================ package output import ( "encoding/json" "fmt" "io" "gopkg.in/yaml.v3" ) // Format represents the output format type type Format string const ( // FormatJSON outputs in JSON format FormatJSON Format = "json" // FormatYAML outputs in YAML format FormatYAML Format = "yaml" // FormatText outputs in plain text/default format FormatText Format = "text" DefaultFormat Format = FormatJSON ) // ResolveFormat determines the output format to use. // Priority: flagValue (if set) > configValue > DefaultFormat func ResolveFormat(flagValue, configValue string) Format { if flagValue != "" { return Format(flagValue) } if configValue != "" { return Format(configValue) } return DefaultFormat } // Formatter is an interface that types must implement to support formatted output type Formatter interface { // TextOutput returns the plain text representation TextOutput() string } // Write outputs the given value in the specified format to the writer func Write(w io.Writer, v interface{}, format Format) error { switch format { case FormatJSON: return writeJSON(w, v) case FormatYAML: return writeYAML(w, v) case FormatText: return writeText(w, v) default: return fmt.Errorf("unsupported output format: %s", format) } } // WriteTextOrStructured writes a human-readable text line for text output and // structured output for JSON or YAML formats. func WriteTextOrStructured(w io.Writer, format Format, structuredValue interface{}, text string) error { if format == FormatText { _, err := fmt.Fprintln(w, text) return err } return Write(w, structuredValue, format) } func writeJSON(w io.Writer, v interface{}) error { encoder := json.NewEncoder(w) encoder.SetIndent("", " ") return encoder.Encode(v) } func writeYAML(w io.Writer, v interface{}) error { encoder := yaml.NewEncoder(w) encoder.SetIndent(2) return encoder.Encode(v) } func writeText(w io.Writer, v interface{}) error { if f, ok := v.(Formatter); ok { _, err := fmt.Fprintln(w, f.TextOutput()) return err } // Fallback to default string representation _, err := fmt.Fprintln(w, v) return err } ================================================ FILE: pkg/output/output_test.go ================================================ package output import ( "bytes" "strings" "testing" ) func TestWriteTextOrStructured(t *testing.T) { t.Parallel() t.Run("writes text for text output", func(t *testing.T) { t.Parallel() var buf bytes.Buffer if err := WriteTextOrStructured(&buf, FormatText, []string{}, "No pipelines found."); err != nil { t.Fatalf("WriteTextOrStructured() error = %v", err) } if got := strings.TrimSpace(buf.String()); got != "No pipelines found." { t.Fatalf("WriteTextOrStructured() = %q, want %q", got, "No pipelines found.") } }) t.Run("writes structured empty collections for json output", func(t *testing.T) { t.Parallel() var buf bytes.Buffer if err := WriteTextOrStructured(&buf, FormatJSON, []string{}, "ignored"); err != nil { t.Fatalf("WriteTextOrStructured() error = %v", err) } if got := strings.TrimSpace(buf.String()); got != "[]" { t.Fatalf("WriteTextOrStructured() = %q, want %q", got, "[]") } }) t.Run("writes structured null values for json output", func(t *testing.T) { t.Parallel() var buf bytes.Buffer if err := WriteTextOrStructured(&buf, FormatJSON, nil, "ignored"); err != nil { t.Fatalf("WriteTextOrStructured() error = %v", err) } if got := strings.TrimSpace(buf.String()); got != "null" { t.Fatalf("WriteTextOrStructured() = %q, want %q", got, "null") } }) } ================================================ FILE: pkg/output/table.go ================================================ package output import ( "math" "os" "regexp" "sort" "strconv" "strings" "unicode/utf8" "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" "golang.org/x/term" ) const ( ansiReset = "\033[0m" ansiBold = "\033[1m" ansiDim = "\033[2m" ansiItalic = "\033[3m" ansiUnderline = "\033[4m" ansiDimUnder = "\033[2;4m" ansiStrikeThrough = "\033[9m" colSeparator = " " minColumnWidth = 3 ellipsisWidth = 3 defaultTableWidth = 120 ) // ansiPattern strips ANSI/OSC escape sequences var ansiPattern = regexp.MustCompile(`\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\a]*(?:\a|\x1b\\)|[P_\]^][^\x1b]*\x1b\\)`) func Table(headers []string, rows [][]string, columnStyles map[string]string) string { if len(headers) == 0 { return "" } useColor := ColorEnabled() maxWidth := detectedTableWidth() upperHeaders := make([]string, len(headers)) colStyles := make([]string, len(headers)) for i, header := range headers { upperHeaders[i] = strings.ToUpper(header) style := columnStyles[strings.ToLower(header)] if style != "" && useColor { switch style { case "bold": colStyles[i] = ansiBold case "dim": colStyles[i] = ansiDim case "italic": colStyles[i] = ansiItalic case "underline": colStyles[i] = ansiUnderline case "strikethrough": colStyles[i] = ansiStrikeThrough default: colStyles[i] = "" } } } colWidths := make([]int, len(headers)) for i, header := range upperHeaders { colWidths[i] = displayWidth(header) } for _, row := range rows { for i := 0; i < len(row) && i < len(colWidths); i++ { if width := displayWidth(row[i]); width > colWidths[i] { colWidths[i] = width } } } colWidths = clampColumnWidths(colWidths, len(headers), len(colSeparator), maxWidth) totalWidth := 0 for _, width := range colWidths { totalWidth += width + len(colSeparator) } const maxEstimatedSize = 1 << 20 estimatedSize := totalWidth * (len(rows) + 1) if estimatedSize < 0 || estimatedSize > maxEstimatedSize { estimatedSize = maxEstimatedSize } var builder strings.Builder builder.Grow(estimatedSize) for i, upperHeader := range upperHeaders { if useColor { builder.WriteString(ansiDimUnder) } writePadded(&builder, truncateToWidth(upperHeader, colWidths[i]), colWidths[i]) if useColor { builder.WriteString(ansiReset) } if i < len(headers)-1 && colWidths[i] > 0 { builder.WriteString(colSeparator) } } builder.WriteString("\n") for _, row := range rows { for i := range headers { value := "" if i < len(row) { value = row[i] } value = truncateToWidth(value, colWidths[i]) if colStyles[i] != "" { builder.WriteString(colStyles[i]) } writePadded(&builder, value, colWidths[i]) if colStyles[i] != "" { builder.WriteString(ansiReset) } if i < len(headers)-1 && colWidths[i] > 0 { builder.WriteString(colSeparator) } } builder.WriteString("\n") } return builder.String() } func displayWidth(s string) int { stripped := ansiPattern.ReplaceAllString(s, "") return runewidth.StringWidth(stripped) } func writePadded(builder *strings.Builder, s string, width int) { visible := displayWidth(s) builder.WriteString(s) for i := visible; i < width; i++ { builder.WriteByte(' ') } } func truncateToWidth(s string, width int) string { if width <= 0 { return "" } if displayWidth(s) <= width { return s } if width <= ellipsisWidth { return trimToWidth(s, width) } trimmed := trimToWidth(s, width-ellipsisWidth) return trimmed + "..." } func trimToWidth(s string, width int) string { if width <= 0 { return "" } stripped := ansiPattern.ReplaceAllString(s, "") if runewidth.StringWidth(stripped) <= width { return s } var b strings.Builder b.Grow(len(s)) currentWidth := 0 i := 0 for i < len(s) { if s[i] == '\x1b' { if loc := ansiPattern.FindStringIndex(s[i:]); loc != nil && loc[0] == 0 { b.WriteString(s[i : i+loc[1]]) i += loc[1] continue } } r, size := utf8.DecodeRuneInString(s[i:]) if r == utf8.RuneError { break } rw := runewidth.RuneWidth(r) if rw == 0 { b.WriteString(s[i : i+size]) i += size continue } if currentWidth+rw > width { break } b.WriteString(s[i : i+size]) currentWidth += rw i += size } return b.String() } func clampColumnWidths(colWidths []int, colCount, separatorWidth, maxWidth int) []int { if maxWidth <= 0 || colCount == 0 { return colWidths } sepTotal := (colCount - 1) * separatorWidth if sepTotal >= maxWidth { clamped := make([]int, len(colWidths)) return clamped } available := maxWidth - sepTotal sum := 0 for _, width := range colWidths { sum += width } if sum <= available { return colWidths } clamped := make([]int, len(colWidths)) if sum == 0 { for i := range clamped { clamped[i] = minColumnWidth } return clamped } effectiveMin := minColumnWidth if available < minColumnWidth*colCount { effectiveMin = available / colCount } // First pass: give narrow columns their full width, mark wide columns for proportional allocation // A column is "narrow" if it fits within its fair share of space fairShare := available / colCount narrowThreshold := fairShare * 2 fixed := make([]bool, len(colWidths)) remainingSpace := available flexSum := 0 for i, width := range colWidths { if width <= narrowThreshold { // This column is narrow enough to get its full width clamped[i] = width fixed[i] = true remainingSpace -= width } else { // This column needs proportional allocation flexSum += width } } // Second pass: proportionally allocate remaining space to flexible columns for i, width := range colWidths { if fixed[i] { continue } if flexSum == 0 { clamped[i] = effectiveMin continue } ratio := float64(width) / float64(flexSum) alloc := int(math.Floor(ratio * float64(remainingSpace))) clamped[i] = max(alloc, effectiveMin) } currentTotal := 0 for _, width := range clamped { currentTotal += width } remaining := available - currentTotal type columnIndex struct { originalWidth int index int } indices := make([]columnIndex, len(colWidths)) for i, width := range colWidths { indices[i] = columnIndex{originalWidth: width, index: i} } sort.Slice(indices, func(i, j int) bool { return indices[i].originalWidth > indices[j].originalWidth }) if remaining > 0 { for remaining > 0 { for _, col := range indices { if remaining == 0 { break } clamped[col.index]++ remaining-- } } } else if remaining < 0 { for _, col := range indices { if remaining == 0 { break } reduction := -remaining if reduction > clamped[col.index] { reduction = clamped[col.index] } clamped[col.index] -= reduction remaining += reduction } } return clamped } func detectedTableWidth() int { if override := os.Getenv("BUILDKITE_TABLE_MAX_WIDTH"); override != "" { if parsed, err := strconv.Atoi(strings.TrimSpace(override)); err == nil && parsed > 0 { return parsed } } fd := os.Stdout.Fd() if !isatty.IsTerminal(fd) && !isatty.IsCygwinTerminal(fd) { return defaultTableWidth } width, _, err := term.GetSize(int(fd)) if err != nil || width <= 0 { return defaultTableWidth } return width } ================================================ FILE: pkg/output/table_test.go ================================================ package output import ( "strings" "testing" "unicode/utf8" ) func TestTableTruncatesWhenWidthExceeded(t *testing.T) { t.Setenv("BUILDKITE_TABLE_MAX_WIDTH", "20") headers := []string{"Col1", "Col2"} rows := [][]string{{"this-is-a-very-long-value", "short"}} table := Table(headers, rows, nil) lines := strings.Split(strings.TrimSuffix(table, "\n"), "\n") for i, line := range lines { if displayWidth(line) > 20 { t.Fatalf("line %d exceeds max width: %d > 20", i, displayWidth(line)) } } if !strings.Contains(table, "...") { t.Fatalf("expected truncated output to contain ellipsis") } } func TestTableProportionalClampPreservesShortColumn(t *testing.T) { t.Setenv("BUILDKITE_TABLE_MAX_WIDTH", "30") headers := []string{"Short", "Longer"} rows := [][]string{{"ok", "this-is-a-very-long-value-that-should-truncate"}} table := Table(headers, rows, nil) lines := strings.Split(strings.TrimSuffix(table, "\n"), "\n") for i, line := range lines { if displayWidth(line) > 30 { t.Fatalf("line %d exceeds max width: %d > 30", i, displayWidth(line)) } } if strings.Contains(lines[1], "ok...") { t.Fatalf("short column should not be truncated: %q", lines[1]) } if !strings.Contains(lines[1], "...") { t.Fatalf("long column should be truncated with ellipsis") } } func TestTableRespectsNoTruncationWhenWidthIsLarge(t *testing.T) { t.Setenv("BUILDKITE_TABLE_MAX_WIDTH", "200") headers := []string{"Col1", "Col2"} rows := [][]string{{"alpha", "beta"}} table := Table(headers, rows, nil) if !strings.Contains(table, "alpha") || !strings.Contains(table, "beta") { t.Fatalf("expected table to contain original values") } for _, line := range strings.Split(strings.TrimSuffix(table, "\n"), "\n") { if displayWidth(line) > 200 { t.Fatalf("line exceeds large width guard") } } } func TestTableFitsWhenMaxWidthSmallerThanColumnCount(t *testing.T) { t.Setenv("BUILDKITE_TABLE_MAX_WIDTH", "20") headers := []string{"A", "B", "C", "D"} rows := [][]string{{"val1", "val2", "val3", "val4"}} table := Table(headers, rows, nil) lines := strings.Split(strings.TrimSuffix(table, "\n"), "\n") for i, line := range lines { width := displayWidth(line) if width > 20 { t.Fatalf("line %d exceeds max width: %d > 20 (content: %q)", i, width, line) } } if len(table) == 0 { t.Fatalf("table should not be empty even with severe constraints") } } func TestTableFitsWhenSeparatorsExceedMaxWidth(t *testing.T) { t.Setenv("BUILDKITE_TABLE_MAX_WIDTH", "5") headers := []string{"A", "B", "C"} rows := [][]string{{"x", "y", "z"}} table := Table(headers, rows, nil) lines := strings.Split(strings.TrimSuffix(table, "\n"), "\n") for i, line := range lines { width := displayWidth(line) if width > 5 { t.Fatalf("line %d exceeds max width: %d > 5 (content: %q)", i, width, line) } } if len(table) == 0 { t.Fatalf("table should render even when separators exceed width") } } func TestTrimToWidthPreservesANSICodes(t *testing.T) { tests := []struct { name string input string width int shouldMatch func(string) bool }{ { name: "ANSI code at start", input: "\x1b[31mHello World\x1b[0m", width: 5, shouldMatch: func(result string) bool { return strings.HasPrefix(result, "\x1b[31m") && strings.Contains(result, "Hello") }, }, { name: "ANSI code in middle - preserves codes before truncation", input: "Hello \x1b[31mWorld\x1b[0m", width: 8, shouldMatch: func(result string) bool { // Should contain "Hello " and the color code, with "Wo" return strings.Contains(result, "Hello") && strings.Contains(result, "\x1b[31m") && strings.Contains(result, "Wo") }, }, { name: "Multiple ANSI codes", input: "\x1b[1m\x1b[31mBold Red\x1b[0m", width: 4, shouldMatch: func(result string) bool { return strings.Contains(result, "\x1b[1m") && strings.Contains(result, "\x1b[31m") && strings.Contains(result, "Bold") }, }, { name: "ANSI code after truncation point - not included", input: "Hello\x1b[31m World\x1b[0m", width: 5, shouldMatch: func(result string) bool { return result == "Hello" || result == "Hello\x1b[31m" }, }, { name: "Wide characters with ANSI", input: "\x1b[32m你好世界\x1b[0m", width: 4, shouldMatch: func(result string) bool { return strings.HasPrefix(result, "\x1b[32m") && strings.Contains(result, "你好") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := trimToWidth(tt.input, tt.width) if !tt.shouldMatch(result) { t.Errorf("trimToWidth(%q, %d) = %q failed validation", tt.input, tt.width, result) } if strings.Contains(result, "\x1b") { matches := ansiPattern.FindAllString(result, -1) if strings.Count(result, "\x1b") != len(matches) { t.Errorf("Result contains broken ANSI sequences: %q (found %d escape chars but %d complete sequences)", result, strings.Count(result, "\x1b"), len(matches)) } } actualWidth := displayWidth(result) if actualWidth > tt.width { t.Errorf("Result width %d exceeds requested width %d", actualWidth, tt.width) } }) } } func TestTableHandlesComplexUnicode(t *testing.T) { tests := []struct { name string headers []string rows [][]string verify func(*testing.T, string) }{ { name: "Emoji with zero-width joiners", headers: []string{"Family", "Description"}, rows: [][]string{ {"👨‍👩‍👧‍👦", "Family with kids"}, {"👩‍💻", "Woman technologist"}, }, verify: func(t *testing.T, result string) { if !strings.Contains(result, "👨‍👩‍👧‍👦") { t.Error("Family emoji with ZWJ missing from output") } if !strings.Contains(result, "👩‍💻") { t.Error("Woman technologist emoji missing from output") } }, }, { name: "Right-to-left text (Hebrew)", headers: []string{"Hebrew", "English"}, rows: [][]string{ {"שלום", "Hello"}, {"עברית", "Hebrew"}, }, verify: func(t *testing.T, result string) { if !strings.Contains(result, "שלום") { t.Error("Hebrew text missing from output") } if !strings.Contains(result, "עברית") { t.Error("Hebrew word for 'Hebrew' missing from output") } }, }, { name: "Right-to-left text (Arabic)", headers: []string{"Arabic", "English"}, rows: [][]string{ {"مرحبا", "Hello"}, {"العربية", "Arabic"}, }, verify: func(t *testing.T, result string) { if !strings.Contains(result, "مرحبا") { t.Error("Arabic text missing from output") } if !strings.Contains(result, "العربية") { t.Error("Arabic word for 'Arabic' missing from output") } }, }, { name: "Combining diacritical marks", headers: []string{"Text", "Type"}, rows: [][]string{ {"café", "Precomposed"}, {"café", "Combining marks"}, {"ñ vs ñ", "Different forms"}, }, verify: func(t *testing.T, result string) { // Both forms of café should be present cafeCount := strings.Count(result, "café") if cafeCount < 1 { t.Errorf("Expected at least one 'café', got %d occurrences", cafeCount) } }, }, { name: "Mixed emoji and text", headers: []string{"Status", "Message"}, rows: [][]string{ {"✅", "Success"}, {"❌", "Failed"}, {"⚠️", "Warning"}, }, verify: func(t *testing.T, result string) { if !strings.Contains(result, "✅") { t.Error("Check mark emoji missing") } if !strings.Contains(result, "❌") { t.Error("Cross mark emoji missing") } if !strings.Contains(result, "⚠") { t.Error("Warning emoji missing") } }, }, { name: "Skin tone modifiers", headers: []string{"Emoji", "Description"}, rows: [][]string{ {"👋", "Wave (default)"}, {"👋🏻", "Wave (light skin)"}, {"👋🏿", "Wave (dark skin)"}, }, verify: func(t *testing.T, result string) { if !strings.Contains(result, "👋") { t.Error("Wave emoji missing") } }, }, { name: "Regional indicator symbols (flags)", headers: []string{"Flag", "Country"}, rows: [][]string{ {"🇺🇸", "United States"}, {"🇬🇧", "United Kingdom"}, {"🇯🇵", "Japan"}, }, verify: func(t *testing.T, result string) { // Flags are made of regional indicator pairs if !strings.Contains(result, "🇺🇸") { t.Error("US flag emoji missing") } }, }, { name: "Variation selectors", headers: []string{"Char", "Type"}, rows: [][]string{ {"♠", "Text style"}, {"♠️", "Emoji style"}, }, verify: func(t *testing.T, result string) { if !strings.Contains(result, "♠") { t.Error("Spade symbol missing") } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := Table(tt.headers, tt.rows, nil) // Verify the table was generated if len(result) == 0 { t.Fatal("Table output is empty") } // Verify headers are present for _, header := range tt.headers { upperHeader := strings.ToUpper(header) if !strings.Contains(result, upperHeader) { t.Errorf("Header %q not found in output", upperHeader) } } // Run custom verification tt.verify(t, result) // Verify the output doesn't have broken formatting lines := strings.Split(strings.TrimSuffix(result, "\n"), "\n") if len(lines) != len(tt.rows)+1 { t.Errorf("Expected %d lines (1 header + %d rows), got %d", len(tt.rows)+1, len(tt.rows), len(lines)) } }) } } func TestTrimToWidthWithComplexUnicode(t *testing.T) { tests := []struct { name string input string width int minWidth int // minimum acceptable width of result }{ { name: "Emoji with ZWJ truncation", input: "Hello 👨‍👩‍👧‍👦 World", width: 10, minWidth: 0, }, { name: "Arabic text truncation", input: "مرحبا بك في العالم", width: 8, minWidth: 0, }, { name: "Hebrew text truncation", input: "שלום עולם מקסים", width: 6, minWidth: 0, }, { name: "Combined diacritics truncation", input: "Café résumé naïve", width: 8, minWidth: 0, }, { name: "Flag emoji truncation", input: "USA: 🇺🇸 UK: 🇬🇧 JP: 🇯🇵", width: 12, minWidth: 0, }, { name: "Skin tone modifiers truncation", input: "👋 👋🏻 👋🏿", width: 6, minWidth: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := trimToWidth(tt.input, tt.width) actualWidth := displayWidth(result) if actualWidth > tt.width { t.Errorf("Result width %d exceeds max width %d (input: %q, result: %q)", actualWidth, tt.width, tt.input, result) } if actualWidth < tt.minWidth { t.Errorf("Result width %d is less than min width %d (result: %q)", actualWidth, tt.minWidth, result) } // Verify we didn't create invalid UTF-8 if !utf8.ValidString(result) { t.Errorf("Result contains invalid UTF-8: %q", result) } }) } } ================================================ FILE: pkg/output/value.go ================================================ package output import "strings" func ValueOrDash(s string) string { if strings.TrimSpace(s) == "" { return "-" } return s } ================================================ FILE: pkg/output/viewable.go ================================================ package output import ( "encoding/json" ) // Viewable wraps any type to provide formatted output support. // It delegates JSON/YAML marshaling directly to the underlying data, // while using a custom render function for text output. type Viewable[T any] struct { Data T Render func(T) string } // TextOutput implements the Formatter interface for text output. func (v Viewable[T]) TextOutput() string { return v.Render(v.Data) } // MarshalJSON delegates JSON marshaling to the underlying data. func (v Viewable[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.Data) } // MarshalYAML delegates YAML marshaling to the underlying data. func (v Viewable[T]) MarshalYAML() (interface{}, error) { return v.Data, nil } ================================================ FILE: pkg/output/viewable_test.go ================================================ package output import ( "encoding/json" "testing" "gopkg.in/yaml.v3" ) func TestViewable_TextOutput(t *testing.T) { t.Parallel() type Data struct { Name string `json:"name"` Value int `json:"value"` } v := Viewable[Data]{ Data: Data{Name: "test", Value: 42}, Render: func(d Data) string { return "Name: " + d.Name }, } got := v.TextOutput() want := "Name: test" if got != want { t.Errorf("TextOutput() = %q, want %q", got, want) } } func TestViewable_MarshalJSON(t *testing.T) { t.Parallel() type Data struct { Name string `json:"name"` Value int `json:"value"` } v := Viewable[Data]{ Data: Data{Name: "test", Value: 42}, Render: func(d Data) string { return "" }, } got, err := json.Marshal(v) if err != nil { t.Fatalf("MarshalJSON() error = %v", err) } var unmarshaled Data if err := json.Unmarshal(got, &unmarshaled); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if unmarshaled.Name != "test" || unmarshaled.Value != 42 { t.Errorf("MarshalJSON() produced incorrect data: %+v", unmarshaled) } } func TestViewable_MarshalYAML(t *testing.T) { t.Parallel() type Data struct { Name string `yaml:"name"` Value int `yaml:"value"` } v := Viewable[Data]{ Data: Data{Name: "test", Value: 42}, Render: func(d Data) string { return "" }, } got, err := yaml.Marshal(v) if err != nil { t.Fatalf("MarshalYAML() error = %v", err) } var unmarshaled Data if err := yaml.Unmarshal(got, &unmarshaled); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if unmarshaled.Name != "test" || unmarshaled.Value != 42 { t.Errorf("MarshalYAML() produced incorrect data: %+v", unmarshaled) } } ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", ":disableDependencyDashboard", ":semanticCommits", ":automergeMinor", "schedule:nonOfficeHours" ], "labels": ["dependencies"], "postUpdateOptions": ["gomodTidy"], "packageRules": [ { "description": "Group Buildkite dependencies", "matchSourceUrls": ["https://github.com/buildkite/**"], "groupName": "buildkite", "automerge": true }, { "description": "Group Buildkite pipeline config", "matchFileNames": [".buildkite/**"], "groupName": "buildkite" }, { "description": "Group Go minor and patch updates", "matchManagers": ["gomod"], "matchUpdateTypes": ["minor", "patch"], "groupName": "go-minor-patch", "automerge": true }, { "description": "Group golang.org/x/ packages", "matchSourceUrls": ["https://github.com/golang/**"], "groupName": "golang-x", "automerge": true }, { "description": "Group test and dev tooling", "matchPackageNames": [ "github.com/stretchr/testify", "github.com/alecthomas/assert" ], "groupName": "testing", "automerge": true }, { "description": "Do not automerge major updates", "matchUpdateTypes": ["major"], "automerge": false } ], "automerge": true, "automergeType": "pr", "platformAutomerge": true } ================================================ FILE: schema.graphql ================================================ """Directs the executor to include this field or fragment only when the `if` argument is true.""" directive @include( """Included when true.""" if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """Directs the executor to skip this field or fragment when the `if` argument is true.""" directive @skip( """Skipped when true.""" if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """Marks an element of a GraphQL schema as no longer supported.""" directive @deprecated( """Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).""" reason: String ) on FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION """Requires that exactly one field must be supplied and that field must not be `null`.""" directive @oneOf on INPUT_OBJECT """API access tokens for authentication with the Buildkite API""" type APIAccessToken implements Node{ id: ID! """The public UUID for the API Access Token""" uuid: ID! } """A code that is used by an API Application to request an API Access Token""" type APIAccessTokenCode implements Node{ application: APIApplication """The time when this code was authorized by a user""" authorizedAt: DateTime """The IP address of the client that authorized this code""" authorizedIPAddress: String """The actual code used to find this API Access Token Code record""" code: String! """The description of the code provided by the API Application""" description: String! """The time when this code will expire""" expiresAt: DateTime! id: ID! } """Autogenerated input type of APIAccessTokenCodeAuthorizeMutation""" input APIAccessTokenCodeAuthorizeMutationInput { """Autogenerated input type of APIAccessTokenCodeAuthorizeMutation""" clientMutationId: String """Autogenerated input type of APIAccessTokenCodeAuthorizeMutation""" id: ID! } """Autogenerated return type of APIAccessTokenCodeAuthorizeMutation.""" type APIAccessTokenCodeAuthorizeMutationPayload { apiAccessTokenCode: APIAccessTokenCode! """A unique identifier for the client performing the mutation.""" clientMutationId: String } """All possible scopes on a user's API Access Token""" enum APIAccessTokenScopes { GRAPHQL READ_AGENTS READ_ARTIFACTS READ_BUILD_LOGS READ_BUILDS READ_CLUSTERS READ_JOB_ENV READ_NOTIFICATION_SERVICES READ_ORGANIZATIONS READ_PIPELINE_TEMPLATES READ_PIPELINES READ_SUITES READ_TEAMS READ_USER WRITE_AGENTS WRITE_ARTIFACTS WRITE_BUILD_LOGS WRITE_BUILDS WRITE_CLUSTERS WRITE_NOTIFICATION_SERVICES WRITE_PIPELINE_TEMPLATES WRITE_PIPELINES WRITE_SUITES WRITE_TEAMS } """An API Application""" type APIApplication implements Node{ """A description of the application""" description: String! id: ID! """The name of this application""" name: String! } """An agent""" type Agent implements Node{ clusterQueue: ClusterQueue """The time when the agent connected to Buildkite""" connectedAt: DateTime """The connection state of the agent""" connectionState: String! """The date the agent was created""" createdAt: DateTime """The time when the agent disconnected from Buildkite""" disconnectedAt: DateTime """The last time the agent performed a `heartbeat` operation to the Agent API""" heartbeatAt: DateTime """The hostname of the machine running the agent""" hostname: String id: ID! """The IP address that the agent has connected from""" ipAddress: String """If this version of agent has been deprecated by Buildkite""" isDeprecated: Boolean! """Returns whether or not this agent is running a job. If isRunningJob true, but the `job` field is empty, the current user doesn't have access to view the job""" isRunningJob: Boolean! """The currently running job""" job: Job """Jobs that have been assigned to this agent""" jobs( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String type: [JobTypes!] state: [JobStates!] priority: Int agentQueryRules: [String!] concurrency: JobConcurrencySearch """Whether or not the command job passed. Passing `false` will return all failed jobs (including "soft failed" jobs)""" passed: Boolean """Filtering jobs based on related step information""" step: JobStepSearch """Order the jobs""" order: JobOrder ): JobConnection """The date the agent was lost from Buildkite if it didn't cleanly disconnect""" lostAt: DateTime """The meta data this agent was stared with""" metaData: [String!] """The name of the agent""" name: String! """The operating system the agent is running on""" operatingSystem: OperatingSystem organization: Organization permissions: AgentPermissions! """The process identifier (PID) of the agent process on the machine""" pid: String pingedAt: DateTime """The priority setting for the agent""" priority: Int """Whether this agent is visible to everyone, including people outside this organization""" public: Boolean! """The time this agent was forced to stop""" stopForcedAt: DateTime """The user that forced this agent to stop""" stopForcedBy: User """The time the agent was first asked to stop""" stoppedAt: DateTime """The user that initially stopped this agent""" stoppedBy: User """The time the agent was gracefully stopped by a user""" stoppedGracefullyAt: DateTime """The user that gracefully stopped this agent""" stoppedGracefullyBy: User """The User-Agent of the program that is making Agent API requests to Buildkite""" userAgent: String """The public UUID for the agent""" uuid: String! """The version of the agent""" version: String """Whether this agent's version has known issues and should be upgraded""" versionHasKnownIssues: Boolean! } type AgentConnection implements Connection{ count: Int! edges: [AgentEdge] pageInfo: PageInfo } type AgentEdge { cursor: String! node: Agent } """Permissions information about what actions the current user can do against this agent""" type AgentPermissions { """Whether the user can stop the agent remotely""" agentStop: Permission } """Autogenerated input type of AgentStop""" input AgentStopInput { """Autogenerated input type of AgentStop""" clientMutationId: String """Autogenerated input type of AgentStop""" id: ID! """Autogenerated input type of AgentStop""" graceful: Boolean } """Autogenerated return type of AgentStop.""" type AgentStopPayload { agent: Agent! """A unique identifier for the client performing the mutation.""" clientMutationId: String } """A token used to connect an agent to Buildkite""" type AgentToken implements Node{ """The time this agent token was created""" createdAt: DateTime """The user that created this agent token""" createdBy: User """A description about what this agent token is used for""" description: String id: ID! organization: Organization permissions: AgentTokenPermissions! """Whether agents registered with this token will be visible to everyone, including people outside this organization""" public: Boolean! """The time this agent token was revoked""" revokedAt: DateTime """The user that revoked this agent token""" revokedBy: User """The reason as defined by the user why this token was revoked""" revokedReason: String """The token value used to register a new agent""" token: String! """The public UUID for the agent""" uuid: ID! } type AgentTokenConnection implements Connection{ count: Int! edges: [AgentTokenEdge] pageInfo: PageInfo } """Autogenerated input type of AgentTokenCreate""" input AgentTokenCreateInput { """Autogenerated input type of AgentTokenCreate""" clientMutationId: String """Autogenerated input type of AgentTokenCreate""" organizationID: ID! """Autogenerated input type of AgentTokenCreate""" description: String """Autogenerated input type of AgentTokenCreate""" public: Boolean } """Autogenerated return type of AgentTokenCreate.""" type AgentTokenCreatePayload { agentTokenEdge: AgentTokenEdge! """A unique identifier for the client performing the mutation.""" clientMutationId: String organization: Organization! """The token value used to register a new unclustered agent. Please ensure to securely copy this value immediately upon generation as it will not be displayed again.""" tokenValue: String! } type AgentTokenEdge { cursor: String! node: AgentToken } """Permissions information about what actions the current user can do against the agent token""" type AgentTokenPermissions { """Whether the user can revoke this agent token""" agentTokenRevoke: Permission } """Autogenerated input type of AgentTokenRevoke""" input AgentTokenRevokeInput { """Autogenerated input type of AgentTokenRevoke""" clientMutationId: String """Autogenerated input type of AgentTokenRevoke""" id: ID! """Autogenerated input type of AgentTokenRevoke""" reason: String! } """Autogenerated return type of AgentTokenRevoke.""" type AgentTokenRevokePayload { agentToken: AgentToken! """A unique identifier for the client performing the mutation.""" clientMutationId: String } """An annotation allows you to add arbitrary content to the top of a build page in the Buildkite UI""" type Annotation implements Node{ """The body of the annotation""" body: AnnotationBody """The context of the annotation that helps you differentiate this one from others""" context: String! """The date the annotation was created""" createdAt: DateTime! id: ID! """The priority of the annotation""" priority: Int! """The visual style of the annotation""" style: AnnotationStyle """The last time the annotation was changed""" updatedAt: DateTime """The public UUID for this annotation""" uuid: ID! } """A body of an annotation""" type AnnotationBody { """The body of the annotation rendered as HTML. The renderer result could be an empty string if the textual version has unsupported HTML tags""" html: String """The body of the annotation as text""" text: String! } type AnnotationConnection implements Connection{ count: Int! edges: [AnnotationEdge] pageInfo: PageInfo } type AnnotationEdge { cursor: String! node: Annotation } """The different orders you can sort annotations by""" enum AnnotationOrder { """Order by priority, then by the most recently created annotations first""" PRIORITY_RECENTLY_CREATED """Order by the most recently created annotations first""" RECENTLY_CREATED } """The visual style of the annotation""" enum AnnotationStyle { """The default styling of an annotation""" DEFAULT """The annotation has a green border with a tick next to it""" SUCCESS """The annotation has a blue border with an information icon next to it""" INFO """The annotation has an orange border with a warning icon next to it""" WARNING """ The annotation has a red border with a cross next to it""" ERROR } """A file uploaded from the agent whilst running a job""" type Artifact implements Node{ """The download URL for the artifact. Unless you've used your own artifact storage, the URL will be valid for only 10 minutes.""" downloadURL: String! """The time when the artifact will, or did, expire""" expiresAt: DateTime id: ID! """The job that uploaded this artifact""" job: JobTypeCommand """The mime type of the file provided by the agent""" mimeType: String! """The path of the uploaded artifact""" path: String! """A SHA1SUM of the file""" sha1sum: String! """A SHA256SUM of the file""" sha256sum: String """The size of the file in bytes that was uploaded""" size: Int! """The upload state of the artifact""" state: String! """The public UUID for this artifact""" uuid: ID! } type ArtifactConnection implements Connection{ count: Int! edges: [ArtifactEdge] pageInfo: PageInfo } type ArtifactEdge { cursor: String! node: Artifact } """Context for an audit event created during an REST/GraphQL API request""" type AuditAPIContext { """The API access token UUID used to authenticate the request""" requestApiAccessTokenUuid: String """The remote IP which made the request""" requestIpAddress: String """The client supplied user agent which made the request""" requestUserAgent: String } """The actor who caused an AuditEvent""" type AuditActor { """The GraphQL ID for this actor""" id: ID! """The name or short description of this actor""" name: String """The node corresponding to this actor, if available""" node: AuditActorNode """The type of this actor""" type: AuditActorType """The public UUID of this actor""" uuid: ID! } """Kinds of actors which can perform audit events""" union AuditActorNode =Agent | User """All the possible types of actors in an Audit Event""" enum AuditActorType { AGENT USER } """Context for an audit event created during an agent API request""" type AuditAgentAPIContext { """The agent UUID""" agentUuid: String """The type of token that authenticated the agent""" authenticationType: String """The connection state of the agent""" connectionState: String """The organization UUID that the agent belongs to""" organizationUuid: String """The remote IP which made the request""" requestIpAddress: String """The IP of the agent session which made the request""" sessionIpAddress: String } """Kinds of contexts in which an audit event can be performed""" union AuditContext =AuditAPIContext | AuditAgentAPIContext | AuditWebContext """Audit record of an event which occurred in the system""" type AuditEvent implements Node{ """The actor who caused this event""" actor: AuditActor """The context in which this event occurred""" context: AuditContext """The changed data in the event""" data: JSON id: ID! """The time at which this event occurred""" occurredAt: DateTime! """The subject of this event""" subject: AuditSubject """The type of event""" type: AuditEventType! """The public UUID for the event""" uuid: ID! } """All the possible types of an Audit Event""" enum AuditEventType { API_ACCESS_TOKEN_CREATED API_ACCESS_TOKEN_DELETED API_ACCESS_TOKEN_ORGANIZATION_ACCESS_REVOKED API_ACCESS_TOKEN_UPDATED AGENT_TOKEN_CREATED AGENT_TOKEN_REVOKED AGENT_TOKEN_UPDATED AUTHORIZATION_CREATED AUTHORIZATION_DELETED CLUSTER_CREATED CLUSTER_DELETED CLUSTER_PERMISSION_CREATED CLUSTER_PERMISSION_DELETED CLUSTER_QUEUE_CREATED CLUSTER_QUEUE_DELETED CLUSTER_QUEUE_TOKEN_CREATED CLUSTER_QUEUE_TOKEN_DELETED CLUSTER_QUEUE_TOKEN_UPDATED CLUSTER_QUEUE_UPDATED CLUSTER_TOKEN_CREATED CLUSTER_TOKEN_DELETED CLUSTER_TOKEN_UPDATED CLUSTER_UPDATED NOTIFICATION_SERVICE_BROKEN NOTIFICATION_SERVICE_CREATED NOTIFICATION_SERVICE_DELETED NOTIFICATION_SERVICE_DISABLED NOTIFICATION_SERVICE_ENABLED NOTIFICATION_SERVICE_UPDATED ORGANIZATION_BANNER_CREATED ORGANIZATION_BANNER_DELETED ORGANIZATION_BANNER_UPDATED ORGANIZATION_BUILD_EXPORT_UPDATED ORGANIZATION_CREATED ORGANIZATION_DELETED ORGANIZATION_INVITATION_ACCEPTED ORGANIZATION_INVITATION_CREATED ORGANIZATION_INVITATION_RESENT ORGANIZATION_INVITATION_REVOKED ORGANIZATION_MEMBER_CREATED ORGANIZATION_MEMBER_DELETED ORGANIZATION_MEMBER_UPDATED ORGANIZATION_TEAMS_DISABLED ORGANIZATION_TEAMS_ENABLED ORGANIZATION_UPDATED PIPELINE_CREATED PIPELINE_DELETED PIPELINE_SCHEDULE_CREATED PIPELINE_SCHEDULE_DELETED PIPELINE_SCHEDULE_UPDATED PIPELINE_TEMPLATE_CREATED PIPELINE_TEMPLATE_DELETED PIPELINE_TEMPLATE_UPDATED PIPELINE_UPDATED PIPELINE_VISIBILITY_CHANGED PIPELINE_WEBHOOK_URL_ROTATED SCM_PIPELINE_SETTINGS_CREATED SCM_PIPELINE_SETTINGS_DELETED SCM_PIPELINE_SETTINGS_UPDATED SCM_REPOSITORY_HOST_CREATED SCM_REPOSITORY_HOST_DESTROYED SCM_REPOSITORY_HOST_UPDATED SCM_SERVICE_CREATED SCM_SERVICE_DELETED SCM_SERVICE_UPDATED SSO_PROVIDER_CREATED SSO_PROVIDER_DELETED SSO_PROVIDER_DISABLED SSO_PROVIDER_ENABLED SSO_PROVIDER_UPDATED SECRET_CREATED SECRET_DELETED SECRET_QUERIED SECRET_READ SECRET_UPDATED SUBSCRIPTION_PLAN_ADDED SUBSCRIPTION_PLAN_CHANGE_SCHEDULED SUBSCRIPTION_PLAN_CHANGED SUITE_API_TOKEN_REGENERATED SUITE_CREATED SUITE_DELETED SUITE_MONITOR_CREATED SUITE_MONITOR_DELETED SUITE_MONITOR_UPDATED SUITE_UPDATED SUITE_VISIBILITY_CHANGED TEAM_CREATED TEAM_DELETED TEAM_MEMBER_CREATED TEAM_MEMBER_DELETED TEAM_MEMBER_UPDATED TEAM_PIPELINE_CREATED TEAM_PIPELINE_DELETED TEAM_PIPELINE_UPDATED TEAM_SUITE_CREATED TEAM_SUITE_DELETED TEAM_SUITE_UPDATED TEAM_UPDATED USER_API_ACCESS_TOKEN_ORGANIZATION_ACCESS_ADDED USER_API_ACCESS_TOKEN_ORGANIZATION_ACCESS_REMOVED USER_EMAIL_CREATED USER_EMAIL_DELETED USER_EMAIL_MARKED_PRIMARY USER_EMAIL_VERIFIED USER_PASSWORD_RESET USER_PASSWORD_RESET_REQUESTED USER_TOTP_ACTIVATED USER_TOTP_CREATED USER_TOTP_DELETED USER_UPDATED } """The subject of an AuditEvent""" type AuditSubject { """The GraphQL ID for the subject""" id: ID! """The name or short description of this subject""" name: String """The node corresponding to the subject, if available""" node: AuditSubjectNode """The type of this subject""" type: AuditSubjectType """The public UUID of this subject""" uuid: ID! } """Kinds of subjects which can have audit events performed on them""" union AuditSubjectNode =APIAccessToken | AgentToken | AuthorizationBitbucket | AuthorizationGitHub | AuthorizationGitHubEnterprise | Cluster | ClusterPermission | ClusterQueue | ClusterQueueToken | ClusterToken | Email | NotificationServiceSlack | NotificationServiceWebhook | Organization | OrganizationBanner | OrganizationInvitation | OrganizationMember | Pipeline | PipelineSchedule | PipelineTemplate | SCMPipelineSettings | SCMRepositoryHost | SCMService | SSOProviderGitHubApp | SSOProviderGoogleGSuite | SSOProviderSAML | Secret | Subscription | Suite | TOTP | Team | TeamMember | TeamPipeline | TeamSuite | User """All the possible types of subjects in an Audit Event""" enum AuditSubjectType { USER_TOTP CLUSTER_PERMISSION USER CLUSTER SECRET ORGANIZATION_MEMBER ORGANIZATION PIPELINE TEAM SSO_PROVIDER SUITE SUBSCRIPTION AUTHORIZATION AGENT_TOKEN API_ACCESS_TOKEN CLUSTER_QUEUE CLUSTER_TOKEN CLUSTER_QUEUE_TOKEN NOTIFICATION_SERVICE ORGANIZATION_BANNER ORGANIZATION_INVITATION PIPELINE_SCHEDULE PIPELINE_TEMPLATE TEAM_MEMBER TEAM_PIPELINE TEAM_SUITE SCM_SERVICE SCM_PIPELINE_SETTINGS SCM_REPOSITORY_HOST SUITE_MONITOR USER_EMAIL } """Context for an audit event created during a web request""" type AuditWebContext { """The remote IP which made the request""" requestIpAddress: String """The client supplied user agent which made the request""" requestUserAgent: String """When the session started, if available""" sessionCreatedAt: DateTime """When the session was escalated, if available and escalated""" sessionEscalatedAt: DateTime """The session's authenticated user, if available""" sessionUser: User """The session's authenticated user's uuid""" sessionUserUuid: ID } interface Authorization { id: ID! } """A Bitbucket account authorized with a Buildkite account""" type AuthorizationBitbucket implements Authorization & Node{ """ID of the object.""" id: ID! } type AuthorizationConnection implements Connection{ count: Int! edges: [AuthorizationEdge] pageInfo: PageInfo } type AuthorizationEdge { cursor: String! node: Authorization } """A GitHub account authorized with a Buildkite account""" type AuthorizationGitHub implements Authorization & Node{ """ID of the object.""" id: ID! } """A GitHub app authorized with a Buildkite account""" type AuthorizationGitHubApp implements Authorization & Node{ """ID of the object.""" id: ID! } """A GitHub Enterprise account authorized with a Buildkite account""" type AuthorizationGitHubEnterprise implements Authorization & Node{ """ID of the object.""" id: ID! } """A Google account authorized with a Buildkite account""" type AuthorizationGoogle implements Authorization & Node{ """ID of the object.""" id: ID! } """A SAML account authorized with a Buildkite account""" type AuthorizationSAML implements Authorization & Node{ """ID of the object.""" id: ID! } """The type of the authorization""" enum AuthorizationType { """GitHub Authorization""" GITHUB """GitHub Enterprise Authorization""" GITHUB_ENTERPRISE """Bitbucket Authorization""" BITBUCKET } """An avatar belonging to a user""" type Avatar { """The URL of the avatar""" url: String! } """Represents `true` or `false` values.""" scalar Boolean """A build from a pipeline""" type Build implements Node{ annotations( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String style: [AnnotationStyle!] """Order the annotations""" order: AnnotationOrder ): AnnotationConnection """The current blocked state of the build""" blockedState: BuildBlockedStates """The branch for the build""" branch: String! """The time when the build was cancelled""" canceledAt: DateTime """The user who canceled this build. If the build was canceled, and this value is null, then it was canceled automatically by Buildkite""" canceledBy: User """The fully-qualified commit for the build""" commit: String! """The time when the build was created""" createdAt: DateTime createdBy: BuildCreator """Custom environment variables passed to this build""" env: [String!] """The time when the build finished""" finishedAt: DateTime id: ID! jobs( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String type: [JobTypes!] state: [JobStates!] priority: JobPrioritySearch agentQueryRules: [String!] concurrency: JobConcurrencySearch """Whether or not the command job passed. Passing `false` will return all failed jobs (including "soft failed" jobs)""" passed: Boolean """Filtering jobs based on related step information""" step: JobStepSearch """Order the jobs""" order: JobOrder ): JobConnection """The message for the build""" message: String metaData( first: Int last: Int ): BuildMetaDataConnection """The number of the build""" number: Int! organization: Organization! pipeline: Pipeline! pullRequest: PullRequest """The build that this build was rebuilt from""" rebuiltFrom: Build """The time when the build became scheduled for running""" scheduledAt: DateTime """Where the build was created""" source: BuildSource! """The time when the build started running""" startedAt: DateTime """The current state of the build""" state: BuildStates! """The job that this build was triggered from""" triggeredFrom: JobTypeTrigger """The URL for the build""" url: String! """The UUID for the build""" uuid: String! } """Autogenerated input type of BuildAnnotate""" input BuildAnnotateInput { """Autogenerated input type of BuildAnnotate""" clientMutationId: String """Autogenerated input type of BuildAnnotate""" buildID: ID! """Autogenerated input type of BuildAnnotate""" body: String """Autogenerated input type of BuildAnnotate""" style: AnnotationStyle """Autogenerated input type of BuildAnnotate""" context: String """Autogenerated input type of BuildAnnotate""" append: Boolean """Autogenerated input type of BuildAnnotate""" priority: Int } """Autogenerated return type of BuildAnnotate.""" type BuildAnnotatePayload { annotation: Annotation annotationEdge: AnnotationEdge build: Build """A unique identifier for the client performing the mutation.""" clientMutationId: String } """Author for a build""" input BuildAuthorInput { """Author for a build""" name: String! """Author for a build""" email: String! } """All the possible blocked states a build can be in""" enum BuildBlockedStates { """The blocked build is running""" RUNNING """The blocked build is passed""" PASSED """The blocked build is failed""" FAILED } """Autogenerated input type of BuildCancel""" input BuildCancelInput { """Autogenerated input type of BuildCancel""" clientMutationId: String """Autogenerated input type of BuildCancel""" id: ID! } """Autogenerated return type of BuildCancel.""" type BuildCancelPayload { build: Build! """A unique identifier for the client performing the mutation.""" clientMutationId: String } type BuildConnection implements Connection{ count: Int! edges: [BuildEdge] pageInfo: PageInfo } """Autogenerated input type of BuildCreate""" input BuildCreateInput { """Autogenerated input type of BuildCreate""" clientMutationId: String """Autogenerated input type of BuildCreate""" pipelineID: ID! """Autogenerated input type of BuildCreate""" message: String """Autogenerated input type of BuildCreate""" commit: String """Autogenerated input type of BuildCreate""" branch: String """Autogenerated input type of BuildCreate""" env: [String!] """Autogenerated input type of BuildCreate""" metaData: [BuildMetaDataInput!] """Autogenerated input type of BuildCreate""" author: BuildAuthorInput } """Autogenerated return type of BuildCreate.""" type BuildCreatePayload { build: Build """A unique identifier for the client performing the mutation.""" clientMutationId: String } """Either a `User` or an `UnregisteredUser` type""" union BuildCreator =UnregisteredUser | User type BuildEdge { cursor: String! node: Build } """A comment on a build""" type BuildMetaData { """The key used to set this meta data""" key: String! """The value set to this meta data""" value: String! } type BuildMetaDataConnection implements Connection{ count: Int! edges: [BuildMetaDataEdge] pageInfo: PageInfo } type BuildMetaDataEdge { cursor: String! node: BuildMetaData } """Meta-data key/value pairs for a build""" input BuildMetaDataInput { """Meta-data key/value pairs for a build""" key: String! """Meta-data key/value pairs for a build""" value: String! } """Autogenerated input type of BuildRebuild""" input BuildRebuildInput { """Autogenerated input type of BuildRebuild""" clientMutationId: String """Autogenerated input type of BuildRebuild""" id: ID! } """Autogenerated return type of BuildRebuild.""" type BuildRebuildPayload { build: Build! """A unique identifier for the client performing the mutation.""" clientMutationId: String rebuild: Build! } interface BuildSource { name: String! } """A build was triggered via an API""" type BuildSourceAPI implements BuildSource{ name: String! } """A build was triggered manually via the frontend""" type BuildSourceFrontend implements BuildSource{ name: String! } """A build was triggered via a schedule""" type BuildSourceSchedule implements BuildSource{ name: String! """The associated schedule that created this build. Will be `null` if the associated schedule has been deleted.""" pipelineSchedule: PipelineSchedule } """A build was triggered via a trigger job""" type BuildSourceTriggerJob implements BuildSource{ name: String! } """A build was triggered via a Webhook""" type BuildSourceWebhook implements BuildSource{ """Provider specific headers sent along with the webhook. This will return null if the webhook has been purged by Buildkite.""" headers: [String!] name: String! """The body of the webhook. Buildkite only stores webhook data for a short period of time, so if this returns null - then the webhook data has been purged by Buildkite""" payload: JSON """The UUID for this webhook. This will return null if the webhook has been purged by Buildkite""" uuid: String } """All the possible states a build can be in""" enum BuildStates { """The build was skipped""" SKIPPED """The build is currently being created""" CREATING """The build has yet to start running jobs""" SCHEDULED """The build is currently running jobs""" RUNNING """The build passed""" PASSED """The build failed""" FAILED """The build is failing""" FAILING """The build is currently being canceled""" CANCELING """The build was canceled""" CANCELED """The build is blocked""" BLOCKED """The build wasn't run""" NOT_RUN } """The results of a `buildkite-agent pipeline upload`""" type BuildStepUpload { """The uploaded step definition""" definition: BuildStepUploadDefinition! id: ID! """The UUID for this build step upload""" uuid: ID! } """The pipeline definition for a step upload""" type BuildStepUploadDefinition { """The uploaded step definition rendered as JSON""" json: String! """The uploaded step definition rendered as YAML""" yaml: String! } """A changelog""" type Changelog implements Node{ author: ChangelogAuthor """The body of this changelog""" body: String id: ID! """The date and time this changelog was published""" publishedAt: DateTime """The tag for this changelog""" tag: String! """The title for this changelog""" title: String! """The public UUID for this changelog""" uuid: String! } """The author of the changelog""" type ChangelogAuthor { avatar: Avatar! """The name of the author""" name: String! } type ChangelogConnection implements Connection{ count: Int! edges: [ChangelogEdge] pageInfo: PageInfo } type ChangelogEdge { cursor: String! node: Changelog } type Cluster implements Node{ """Returns agent tokens for the Cluster""" agentTokens( first: Int last: Int ): ClusterAgentTokenConnection """Color hex code for the cluster""" color: String """User who created the cluster""" createdBy: User """The default queue that agents connecting to the cluster without specifying a queue will accept jobs from""" defaultQueue: ClusterQueue """Description of the cluster""" description: String """Emoji for the cluster using Buildkite emoji syntax""" emoji: String id: ID! """Name of the cluster""" name: String! organization: Organization queues( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String """Order the cluster queues""" order: ClusterQueueOrder ): ClusterQueueConnection """The public UUID for this cluster""" uuid: ID! } type ClusterAgentTokenConnection implements Connection{ count: Int! edges: [ClusterAgentTokenEdge] pageInfo: PageInfo } """Autogenerated input type of ClusterAgentTokenCreate""" input ClusterAgentTokenCreateInput { """Autogenerated input type of ClusterAgentTokenCreate""" clientMutationId: String """Autogenerated input type of ClusterAgentTokenCreate""" organizationId: ID! """Autogenerated input type of ClusterAgentTokenCreate""" description: String! """Autogenerated input type of ClusterAgentTokenCreate""" clusterId: ID! """Autogenerated input type of ClusterAgentTokenCreate""" allowedIpAddresses: String } """Autogenerated return type of ClusterAgentTokenCreate.""" type ClusterAgentTokenCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String clusterAgentToken: ClusterToken! """The token value used to register a new agent token to this cluster. Please ensure to securely copy this value immediately upon generation as it will not be displayed again.""" tokenValue: String! } type ClusterAgentTokenEdge { cursor: String! node: ClusterToken } """Autogenerated input type of ClusterAgentTokenRevoke""" input ClusterAgentTokenRevokeInput { """Autogenerated input type of ClusterAgentTokenRevoke""" clientMutationId: String """Autogenerated input type of ClusterAgentTokenRevoke""" id: ID! """Autogenerated input type of ClusterAgentTokenRevoke""" organizationId: ID! } """Autogenerated return type of ClusterAgentTokenRevoke.""" type ClusterAgentTokenRevokePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedClusterAgentTokenId: ID! } """Autogenerated input type of ClusterAgentTokenUpdate""" input ClusterAgentTokenUpdateInput { """Autogenerated input type of ClusterAgentTokenUpdate""" clientMutationId: String """Autogenerated input type of ClusterAgentTokenUpdate""" id: ID! """Autogenerated input type of ClusterAgentTokenUpdate""" organizationId: ID! """Autogenerated input type of ClusterAgentTokenUpdate""" description: String! """Autogenerated input type of ClusterAgentTokenUpdate""" allowedIpAddresses: String } """Autogenerated return type of ClusterAgentTokenUpdate.""" type ClusterAgentTokenUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String clusterAgentToken: ClusterToken! } type ClusterConnection implements Connection{ count: Int! edges: [ClusterEdge] pageInfo: PageInfo } """Autogenerated input type of ClusterCreate""" input ClusterCreateInput { """Autogenerated input type of ClusterCreate""" clientMutationId: String """Autogenerated input type of ClusterCreate""" organizationId: ID! """Autogenerated input type of ClusterCreate""" name: String! """Autogenerated input type of ClusterCreate""" description: String """Autogenerated input type of ClusterCreate""" emoji: String """Autogenerated input type of ClusterCreate""" color: String } """Autogenerated return type of ClusterCreate.""" type ClusterCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String cluster: Cluster! } """Autogenerated input type of ClusterDelete""" input ClusterDeleteInput { """Autogenerated input type of ClusterDelete""" clientMutationId: String """Autogenerated input type of ClusterDelete""" organizationId: ID! """Autogenerated input type of ClusterDelete""" id: ID! } """Autogenerated return type of ClusterDelete.""" type ClusterDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedClusterId: ID! } type ClusterEdge { cursor: String! node: Cluster } """The different orders you can sort clusters by""" enum ClusterOrder { """Order by name alphabetically""" NAME """Order by the most recently created clusters first""" RECENTLY_CREATED } type ClusterPermission { actor: ClusterPermissionActor """Whether the actor can add pipelines to this cluster""" can_add_pipelines: Boolean! """Whether the actor can manage the associated cluster""" can_manage: Boolean! """Whether the actor can see this cluster's tokens""" can_see_tokens: Boolean! cluster: Cluster id: ID! """The public UUID for this cluster permission""" uuid: ID! } """Actor to whom a cluster permission is applied""" union ClusterPermissionActor =OrganizationMember | Team type ClusterQueue implements Node{ cluster: Cluster createdBy: User description: String """States whether job dispatch is paused for this cluster queue""" dispatchPaused: Boolean! """The time this queue was paused""" dispatchPausedAt: DateTime """The user who paused this cluster queue""" dispatchPausedBy: User """Note describing why job dispatch was paused for this cluster queue""" dispatchPausedNote: String id: ID! key: String! """The public UUID for this cluster queue""" uuid: ID! } type ClusterQueueConnection implements Connection{ count: Int! edges: [ClusterQueueEdge] pageInfo: PageInfo } """Autogenerated input type of ClusterQueueCreate""" input ClusterQueueCreateInput { """Autogenerated input type of ClusterQueueCreate""" clientMutationId: String """Autogenerated input type of ClusterQueueCreate""" organizationId: ID! """Autogenerated input type of ClusterQueueCreate""" clusterId: ID! """Autogenerated input type of ClusterQueueCreate""" key: String! """Autogenerated input type of ClusterQueueCreate""" description: String } """Autogenerated return type of ClusterQueueCreate.""" type ClusterQueueCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String clusterQueue: ClusterQueue! } """Autogenerated input type of ClusterQueueDelete""" input ClusterQueueDeleteInput { """Autogenerated input type of ClusterQueueDelete""" clientMutationId: String """Autogenerated input type of ClusterQueueDelete""" organizationId: ID! """Autogenerated input type of ClusterQueueDelete""" id: ID! } """Autogenerated return type of ClusterQueueDelete.""" type ClusterQueueDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedClusterQueueId: ID! } type ClusterQueueEdge { cursor: String! node: ClusterQueue } """The different orders you can sort cluster queues by""" enum ClusterQueueOrder { """Order by key alphabetically""" KEY """Order by the most recently created cluster queues first""" RECENTLY_CREATED } """Autogenerated input type of ClusterQueuePauseDispatch""" input ClusterQueuePauseDispatchInput { """Autogenerated input type of ClusterQueuePauseDispatch""" clientMutationId: String """Autogenerated input type of ClusterQueuePauseDispatch""" id: ID! """Autogenerated input type of ClusterQueuePauseDispatch""" note: String } """Autogenerated return type of ClusterQueuePauseDispatch.""" type ClusterQueuePauseDispatchPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String queue: ClusterQueue! } """Autogenerated input type of ClusterQueueResumeDispatch""" input ClusterQueueResumeDispatchInput { """Autogenerated input type of ClusterQueueResumeDispatch""" clientMutationId: String """Autogenerated input type of ClusterQueueResumeDispatch""" id: ID! } """Autogenerated return type of ClusterQueueResumeDispatch.""" type ClusterQueueResumeDispatchPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String queue: ClusterQueue! } """A token used to register an agent with a Buildkite cluster queue""" type ClusterQueueToken implements Node{ """A list of CIDR-notation IPv4 addresses from which agents can use this token. Please note that this feature is not yet available to all organizations""" allowedIpAddresses: String cluster: Cluster clusterQueue: ClusterQueue createdBy: User """A description for this cluster queue token""" description: String! id: ID! """The public UUID for this cluster queue token""" uuid: ID! } """Autogenerated input type of ClusterQueueUpdate""" input ClusterQueueUpdateInput { """Autogenerated input type of ClusterQueueUpdate""" clientMutationId: String """Autogenerated input type of ClusterQueueUpdate""" organizationId: ID! """Autogenerated input type of ClusterQueueUpdate""" id: ID! """Autogenerated input type of ClusterQueueUpdate""" description: String } """Autogenerated return type of ClusterQueueUpdate.""" type ClusterQueueUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String clusterQueue: ClusterQueue! } """A token used to connect an agent in cluster to Buildkite""" type ClusterToken implements Node{ """A list of CIDR-notation IPv4 addresses from which agents can use this token. Please note that this feature is not yet available to all organizations""" allowedIpAddresses: String cluster: Cluster createdBy: User """A description about what this cluster agent token is used for""" description: String id: ID! """The token value used to register a new agent to this tokens cluster. This will soon return an empty string before we finally remove this field.""" token: String! """The public UUID for this cluster token""" uuid: ID! } """Autogenerated input type of ClusterUpdate""" input ClusterUpdateInput { """Autogenerated input type of ClusterUpdate""" clientMutationId: String """Autogenerated input type of ClusterUpdate""" organizationId: ID! """Autogenerated input type of ClusterUpdate""" id: ID! """Autogenerated input type of ClusterUpdate""" name: String """Autogenerated input type of ClusterUpdate""" description: String """Autogenerated input type of ClusterUpdate""" emoji: String """Autogenerated input type of ClusterUpdate""" color: String """Autogenerated input type of ClusterUpdate""" defaultQueueId: ID } """Autogenerated return type of ClusterUpdate.""" type ClusterUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String cluster: Cluster! } interface Connection { count: Int! pageInfo: PageInfo } """An ISO-8601 encoded UTC date string""" scalar DateTime type Dependency { """Is this dependency allowed to fail""" allowFailure: Boolean! id: ID! """The step key or step identifier that this step depends on""" key: String """The UUID for this dependency""" uuid: ID! } type DependencyConnection implements Connection{ count: Int! edges: [DependencyEdge] pageInfo: PageInfo } type DependencyEdge { cursor: String! node: Dependency } """A job dispatch for a particular Organization""" type Dispatch { id: ID! """The public UUID for this organization dispatch""" uuid: String! } """An email address""" type Email implements Node{ """The email address""" address: String! id: ID! """Whether the email address is the user's primary address""" primary: Boolean! """The public UUID for this email""" uuid: ID! """Whether the email address has been verified by the user""" verified: Boolean! } """The connection type for Email.""" type EmailConnection implements Connection{ count: Int! """A list of edges.""" edges: [EmailEdge] """A list of nodes.""" nodes: [Email] pageInfo: PageInfo } """Autogenerated input type of EmailCreate""" input EmailCreateInput { """Autogenerated input type of EmailCreate""" clientMutationId: String """Autogenerated input type of EmailCreate""" address: String! } """Autogenerated return type of EmailCreate.""" type EmailCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String emailEdge: EmailEdge! viewer: Viewer! } """An edge in a connection.""" type EmailEdge { """A cursor for use in pagination.""" cursor: String! """The item at the end of the edge.""" node: Email } """Autogenerated input type of EmailResendVerification""" input EmailResendVerificationInput { """Autogenerated input type of EmailResendVerification""" clientMutationId: String """Autogenerated input type of EmailResendVerification""" id: ID! } """Autogenerated return type of EmailResendVerification.""" type EmailResendVerificationPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String email: Email! } """A shared GraphQL query""" type GraphQLSnippet { """When this GraphQL snippet was created""" createdAt: DateTime! id: ID! """The default operation name for this snippet""" operationName: String """The query of this GraphQL snippet""" query: String! """The URL for the GraphQL snippet""" url: String! """The public UUID for this snippet""" uuid: ID! } """Autogenerated input type of GraphQLSnippetCreate""" input GraphQLSnippetCreateInput { """Autogenerated input type of GraphQLSnippetCreate""" clientMutationId: String """Autogenerated input type of GraphQLSnippetCreate""" query: String! """Autogenerated input type of GraphQLSnippetCreate""" operationName: ID } """Autogenerated return type of GraphQLSnippetCreate.""" type GraphQLSnippetCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String graphQLSnippet: GraphQLSnippet! } """Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"VXNlci0xMA=="`) or integer (such as `4`) input value will be accepted as an ID.""" scalar ID """An ISO 8601-encoded date""" scalar ISO8601Date """Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.""" scalar Int """Represents non-fractional signed whole numeric values. `JSInt` can represent values between -(2^53) + 1 and 2^53 - 1. """ scalar JSInt """A blob of JSON represented as a pretty formatted string""" scalar JSON """Kinds of jobs that can exist on a build""" union Job =JobTypeBlock | JobTypeCommand | JobTypeTrigger | JobTypeWait """Concurrency configuration for a job""" type JobConcurrency { """The concurrency group""" group: String! """The maximum amount of jobs in the concurrency that are allowed to run at any given time""" limit: Int! } """Searching for concurrency groups on jobs""" input JobConcurrencySearch { """Searching for concurrency groups on jobs""" group: [String!] } type JobConnection implements Connection{ count: Int! edges: [JobEdge] pageInfo: PageInfo } type JobEdge { cursor: String! node: Job } interface JobEvent { actor: JobEventActor! id: ID! job: JobTypeCommand! timestamp: DateTime! type: JobEventType! uuid: ID! } """The actor who was responsible for the job event""" type JobEventActor { """The node corresponding to this actor if available""" node: JobEventActorNodeUnion """The type of this actor""" type: JobEventActorType! """The public UUID of this actor if available""" uuid: ID } """Actor types that can create events on a job""" union JobEventActorNodeUnion =Agent | Dispatch | User """All the actors that can have created a job event""" enum JobEventActorType { """The actor was a user""" USER """The actor was an agent""" AGENT """The actor was the system""" SYSTEM """The actor was the dispatcher""" DISPATCH } """An event created when the dispatcher assigns the job to an agent""" type JobEventAssigned implements JobEvent & Node{ """The actor that caused this event to occur""" actor: JobEventActor! """The agent the job was assigned to""" assignedAgent: Agent id: ID! """The job that this event belongs to""" job: JobTypeCommand! """The time when the event occurred""" timestamp: DateTime! """The type of event""" type: JobEventType! """The public UUID for this job event""" uuid: ID! } """An event created when the job creates new build steps via pipeline upload""" type JobEventBuildStepUploadCreated implements JobEvent & Node{ """The actor that caused this event to occur""" actor: JobEventActor! buildStepUpload: BuildStepUpload! id: ID! """The job that this event belongs to""" job: JobTypeCommand! """The time when the event occurred""" timestamp: DateTime! """The type of event""" type: JobEventType! """The public UUID for this job event""" uuid: ID! } """An event created when the job is canceled""" type JobEventCanceled implements JobEvent & Node{ """The actor that caused this event to occur""" actor: JobEventActor! exitStatus: JSInt! id: ID! """The job that this event belongs to""" job: JobTypeCommand! """The termination signal which killed the command, if the command was killed""" signal: String """If the termination signal was sent by the agent, the reason the agent took that action. If this field is null, and the `signal` field is not null, the command was killed by another process or by the operating system.""" signalReason: JobEventSignalReason """The time when the event occurred""" timestamp: DateTime! """The type of event""" type: JobEventType! """The public UUID for this job event""" uuid: ID! } type JobEventConnection implements Connection{ count: Int! edges: [JobEventEdge] pageInfo: PageInfo } type JobEventEdge { cursor: String! node: JobEvent! } """An event created when the job is finished""" type JobEventFinished implements JobEvent & Node{ """The actor that caused this event to occur""" actor: JobEventActor! """The exit status returned by the command on the agent. A value of `-1` indicates either that the agent was lost or the process was killed. If the process was killed, the `signal` field will be non-null.""" exitStatus: JSInt! id: ID! """The job that this event belongs to""" job: JobTypeCommand! """The termination signal which killed the command, if the command was killed""" signal: String """If the termination signal was sent by the agent, the reason the agent took that action. If this field is null, and the `signal` field is not null, the command was killed by another process or by the operating system.""" signalReason: JobEventSignalReason """The time when the event occurred""" timestamp: DateTime! """The type of event""" type: JobEventType! """The public UUID for this job event""" uuid: ID! } """A generic event type that doesn't have any additional meta-information associated with the event""" type JobEventGeneric implements JobEvent & Node{ """The actor that caused this event to occur""" actor: JobEventActor! id: ID! """The job that this event belongs to""" job: JobTypeCommand! """The time when the event occurred""" timestamp: DateTime! """The type of event""" type: JobEventType! """The public UUID for this job event""" uuid: ID! } """An event created when the job is retried""" type JobEventRetried implements JobEvent & Node{ """The actor that caused this event to occur""" actor: JobEventActor! automaticRule: JobRetryRuleAutomatic id: ID! """The job that this event belongs to""" job: JobTypeCommand! retriedInJob: JobTypeCommand """The time when the event occurred""" timestamp: DateTime! """The type of event""" type: JobEventType! """The public UUID for this job event""" uuid: ID! } """The reason why a signal was sent to the job's process, or why the process did not start""" enum JobEventSignalReason { """The agent sent the signal to the process because the agent was stopped""" AGENT_STOP """The agent sent the signal to the process because the job was canceled""" CANCEL """The agent was unable to start the job process, often due to memory or resource constraints. Note that in this case, no signal was sent to the process, it simply never started.""" PROCESS_RUN_ERROR """The agent refused the job. Note that in this case, no signal was sent to the process, the job was not run at all.""" AGENT_REFUSED """The agent refused the job because the signature could not be verified. Note that in this case, no signal was sent to the process, the job was not run at all.""" SIGNATURE_REJECTED } """An event created when the job is timed out""" type JobEventTimedOut implements JobEvent & Node{ """The actor that caused this event to occur""" actor: JobEventActor! exitStatus: JSInt! id: ID! """The job that this event belongs to""" job: JobTypeCommand! """The termination signal which killed the command, if the command was killed""" signal: String """If the termination signal was sent by the agent, the reason the agent took that action. If this field is null, and the `signal` field is not null, the command was killed by another process or by the operating system.""" signalReason: JobEventSignalReason """The time when the event occurred""" timestamp: DateTime! """The type of event""" type: JobEventType! """The public UUID for this job event""" uuid: ID! } """All the possible types of events that happen to a Job""" enum JobEventType { """The Job was assigned to an agent""" ASSIGNED """The agent took too long to accept the job""" ASSIGNED_EXPIRED """The Job was accepted by an agent""" ACCEPTED """The agent took too long to start the job""" ACCEPTED_EXPIRED """The Job was started by an agent""" STARTED """The Job was finished by an agent""" FINISHED """The Job was canceled""" CANCELED """The Job was timed out""" TIMED_OUT """The Job was retried either automatically or by a user""" RETRIED """The Job was changed""" CHANGED """The Job was unblocked by a user""" UNBLOCKED """The Job was scheduled""" SCHEDULED """The Job sent a notification""" NOTIFICATION """The Job was marked for cancelation by a user""" CANCELATION """The Job is limited by a concurrency group""" LIMITED """The Job uploaded steps to the current build""" BUILD_STEP_UPLOAD_CREATED """The Job expired before it was started on an agent""" EXPIRED """The agent was stopped while processing this job""" AGENT_STOPPED """The agent disconnected while processing this job""" AGENT_DISCONNECTED """The agent was lost while processing this job""" AGENT_LOST """The job log exceeded the limit""" LOG_SIZE_LIMIT_EXCEEDED } interface JobInterface { retried: Boolean! retriedBy: User retriesCount: Int retrySource: Job retryType: JobRetryTypes uuid: String! } """A record of job minutes usage, aggregated by day and pipeline.""" type JobMinutesUsage implements ResourceUsageInterface{ aggregatedOn: ISO8601Date! pipeline: Pipeline pipelineId: ID! """The recorded usage in seconds. For billing purposes, seconds are summed for a billing period and rounded down to the nearest minute.""" seconds: Int! } """The different orders you can sort jobs by""" enum JobOrder { """Order by the most recently assigned jobs first""" RECENTLY_ASSIGNED """Order by the most recently created jobs first""" RECENTLY_CREATED } """The priority with which a job will run""" type JobPriority { number: Int } """Search jobs by priority""" input JobPrioritySearch { """Search jobs by priority""" number: [Int!] } """Automatic retry rule configuration""" type JobRetryRuleAutomatic { exitStatus: String limit: String signal: String signalReason: String } """Retry Rules for a job""" type JobRetryRules { automatic: [JobRetryRuleAutomatic] manual: Boolean } """The retry types that can be made on a Job""" enum JobRetryTypes { MANUAL AUTOMATIC } """All the possible states a job can be in""" enum JobStates { """The job has just been created and doesn't have a state yet""" PENDING """The job is waiting on a `wait` step to finish""" WAITING """The job was in a `WAITING` state when the build failed""" WAITING_FAILED """The job is waiting on a `block` step to finish""" BLOCKED """The job was in a `BLOCKED` state when the build failed""" BLOCKED_FAILED """This `block` job has been manually unblocked""" UNBLOCKED """This `block` job was in an `UNBLOCKED` state when the build failed""" UNBLOCKED_FAILED """The job is waiting on a concurrency group check before becoming either `LIMITED` or `SCHEDULED`""" LIMITING """The job is waiting for jobs with the same concurrency group to finish""" LIMITED """The job is scheduled and waiting for an agent""" SCHEDULED """The job has been assigned to an agent, and it's waiting for it to accept""" ASSIGNED """The job was accepted by the agent, and now it's waiting to start running""" ACCEPTED """The job is running""" RUNNING """The job has finished""" FINISHED """The job is currently canceling""" CANCELING """The job was canceled""" CANCELED """The job is timing out for taking too long""" TIMING_OUT """The job timed out""" TIMED_OUT """The job was skipped""" SKIPPED """The jobs configuration means that it can't be run""" BROKEN """The job expired before it was started on an agent""" EXPIRED } """Searching for jobs based on step information""" input JobStepSearch { """Searching for jobs based on step information""" key: [String!] } """A type of job that requires a user to unblock it before proceeding in a build pipeline""" type JobTypeBlock implements JobInterface & Node{ """The build that this job is a part of""" build: Build id: ID! """Whether or not this job can be unblocked yet (may be waiting on another job to finish)""" isUnblockable: Boolean """The label of this block step""" label: String """If this job has been retried""" retried: Boolean! """The user that retried this job""" retriedBy: User """The number of times the job has been retried""" retriesCount: Int """The job that was retried to create this job""" retrySource: Job """The type of retry that was performed on this job""" retryType: JobRetryTypes """The state of the job""" state: JobStates! """The step that defined this job. Some older jobs in the system may not have an associated step""" step: StepInput """The time when the job was created""" unblockedAt: DateTime """The user that unblocked this job""" unblockedBy: User """The UUID for this job""" uuid: String! } """Autogenerated input type of JobTypeBlockUnblock""" input JobTypeBlockUnblockInput { """Autogenerated input type of JobTypeBlockUnblock""" clientMutationId: String """Autogenerated input type of JobTypeBlockUnblock""" id: ID! """Autogenerated input type of JobTypeBlockUnblock""" fields: JSON } """Autogenerated return type of JobTypeBlockUnblock.""" type JobTypeBlockUnblockPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String jobTypeBlock: JobTypeBlock! } """A type of job that runs a command on an agent""" type JobTypeCommand implements JobInterface & Node{ """The agent that is running the job""" agent: Agent """The ruleset used to find an agent to run this job""" agentQueryRules: [String!] """Artifacts uploaded to this job""" artifacts( first: Int last: Int ): ArtifactConnection """A glob of files to automatically upload after the job finishes""" automaticArtifactUploadPaths: String """The build that this job is a part of""" build: Build """The time when the job was cancelled""" canceledAt: DateTime """The cluster of this job""" cluster: Cluster """The cluster queue of this job""" clusterQueue: ClusterQueue """The command the job will run""" command: String """Concurrency information related to a job""" concurrency: JobConcurrency """The time when the job was created""" createdAt: DateTime """Environment variables for this job""" env: [String!] """Job events""" events( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String ): JobEventConnection! """The exit status returned by the command on the agent""" exitStatus: String """The time when the job was expired""" expiredAt: DateTime """The time when the job finished""" finishedAt: DateTime id: ID! """The label of the job""" label: String """The matrix configuration values for this particular job""" matrix: JSON """The index of this job within the parallel job group it is a part of. Null if this job is not part of a parallel job group.""" parallelGroupIndex: Int """The total number of jobs in the parallel job group this job is a part of. Null if this job is not part of a parallel job group.""" parallelGroupTotal: Int """If the job has finished and passed""" passed: Boolean! """The pipeline that this job is a part of""" pipeline: Pipeline """The priority of this job""" priority: JobPriority! """If this job has been retried""" retried: Boolean! """The user that retried this job""" retriedBy: User """The number of times the job has been retried""" retriesCount: Int """Job retry rules""" retryRules: JobRetryRules """The job that was retried to create this job""" retrySource: Job """The type of retry that was performed on this job""" retryType: JobRetryTypes """The time when the job became available to be run by an agent""" runnableAt: DateTime """The time when the job became scheduled for running""" scheduledAt: DateTime """The termination signal which killed the command, if the command was killed""" signal: String """If the termination signal was sent by the agent, the reason the agent took that action. If this field is null, and the `signal` field is not null, the command was killed by another process or by the operating system.""" signalReason: JobEventSignalReason """If the job soft failed""" softFailed: Boolean! """The time when the job started running""" startedAt: DateTime """The state of the job""" state: JobStates! """The step that defined this job. Some older jobs in the system may not have an associated step""" step: StepCommand """The URL for the job""" url: String! """The UUID for this job""" uuid: String! } """Autogenerated input type of JobTypeCommandCancel""" input JobTypeCommandCancelInput { """Autogenerated input type of JobTypeCommandCancel""" clientMutationId: String """Autogenerated input type of JobTypeCommandCancel""" id: ID! } """Autogenerated return type of JobTypeCommandCancel.""" type JobTypeCommandCancelPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String jobTypeCommand: JobTypeCommand! } """Autogenerated input type of JobTypeCommandRetry""" input JobTypeCommandRetryInput { """Autogenerated input type of JobTypeCommandRetry""" clientMutationId: String """Autogenerated input type of JobTypeCommandRetry""" id: ID! } """Autogenerated return type of JobTypeCommandRetry.""" type JobTypeCommandRetryPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String jobTypeCommand: JobTypeCommand! retriedInJobTypeCommand: JobTypeCommand! } """A type of job that triggers another build on a pipeline""" type JobTypeTrigger implements JobInterface & Node{ """Whether the triggered build runs asynchronously or not""" async: Boolean! """The build that this job is a part of""" build: Build id: ID! """The label of this trigger step""" label: String """If this job has been retried""" retried: Boolean! """The user that retried this job""" retriedBy: User """The number of times the job has been retried""" retriesCount: Int """The job that was retried to create this job""" retrySource: Job """The type of retry that was performed on this job""" retryType: JobRetryTypes """The state of the job""" state: JobStates! """The step that defined this job. Some older jobs in the system may not have an associated step""" step: StepTrigger """The build that this job triggered""" triggered: Build """The UUID for this job""" uuid: String! } """A type of job that waits for all previous jobs to pass before proceeding the build pipeline""" type JobTypeWait implements JobInterface & Node{ """The build that this job is a part of""" build: Build id: ID! """The label of this wait step""" label: String """If this job has been retried""" retried: Boolean! """The user that retried this job""" retriedBy: User """The number of times the job has been retried""" retriesCount: Int """The job that was retried to create this job""" retrySource: Job """The type of retry that was performed on this job""" retryType: JobRetryTypes """The state of the job""" state: JobStates! """The step that defined this job. Some older jobs in the system may not have an associated step""" step: StepWait """The UUID for this job""" uuid: String! } """All the possible types of jobs that can exist""" enum JobTypes { """A job that runs a command on an agent""" COMMAND """A job that waits for all previous jobs to finish""" WAIT """A job that blocks a pipeline from progressing until it's manually unblocked""" BLOCK """A job that triggers another build on a pipeline""" TRIGGER } """The root for mutations in this schema""" type Mutation { """Instruct an agent to stop accepting new build jobs and shut itself down.""" agentStop( """Parameters for AgentStop""" input: AgentStopInput! ): AgentStopPayload """Create a new unclustered agent token.""" agentTokenCreate( """Parameters for AgentTokenCreate""" input: AgentTokenCreateInput! ): AgentTokenCreatePayload """Revoke an unclustered agent token.""" agentTokenRevoke( """Parameters for AgentTokenRevoke""" input: AgentTokenRevokeInput! ): AgentTokenRevokePayload """Authorize an API Access Token Code generated by an API Application. Please note this mutation is private and cannot be executed externally.""" apiAccessTokenCodeAuthorize( """Parameters for APIAccessTokenCodeAuthorizeMutation""" input: APIAccessTokenCodeAuthorizeMutationInput! ): APIAccessTokenCodeAuthorizeMutationPayload """Annotate a build with information to appear on the build page.""" buildAnnotate( """Parameters for BuildAnnotate""" input: BuildAnnotateInput! ): BuildAnnotatePayload """Cancel a build.""" buildCancel( """Parameters for BuildCancel""" input: BuildCancelInput! ): BuildCancelPayload """Create a build.""" buildCreate( """Parameters for BuildCreate""" input: BuildCreateInput! ): BuildCreatePayload """Rebuild a build.""" buildRebuild( """Parameters for BuildRebuild""" input: BuildRebuildInput! ): BuildRebuildPayload """Create an agent token for a cluster.""" clusterAgentTokenCreate( """Parameters for ClusterAgentTokenCreate""" input: ClusterAgentTokenCreateInput! ): ClusterAgentTokenCreatePayload """Revokes an agent token for a cluster.""" clusterAgentTokenRevoke( """Parameters for ClusterAgentTokenRevoke""" input: ClusterAgentTokenRevokeInput! ): ClusterAgentTokenRevokePayload """Updates an agent token for a cluster.""" clusterAgentTokenUpdate( """Parameters for ClusterAgentTokenUpdate""" input: ClusterAgentTokenUpdateInput! ): ClusterAgentTokenUpdatePayload """Create a cluster.""" clusterCreate( """Parameters for ClusterCreate""" input: ClusterCreateInput! ): ClusterCreatePayload """Delete a cluster.""" clusterDelete( """Parameters for ClusterDelete""" input: ClusterDeleteInput! ): ClusterDeletePayload """Create a cluster queue.""" clusterQueueCreate( """Parameters for ClusterQueueCreate""" input: ClusterQueueCreateInput! ): ClusterQueueCreatePayload """Delete a cluster queue.""" clusterQueueDelete( """Parameters for ClusterQueueDelete""" input: ClusterQueueDeleteInput! ): ClusterQueueDeletePayload """This will prevent dispatch of jobs to agents on this queue. You can add an optional note describing the reason for pausing.""" clusterQueuePauseDispatch( """Parameters for ClusterQueuePauseDispatch""" input: ClusterQueuePauseDispatchInput! ): ClusterQueuePauseDispatchPayload """This will resume dispatch of jobs on this queue.""" clusterQueueResumeDispatch( """Parameters for ClusterQueueResumeDispatch""" input: ClusterQueueResumeDispatchInput! ): ClusterQueueResumeDispatchPayload """Updates a cluster queue.""" clusterQueueUpdate( """Parameters for ClusterQueueUpdate""" input: ClusterQueueUpdateInput! ): ClusterQueueUpdatePayload """Updates a cluster.""" clusterUpdate( """Parameters for ClusterUpdate""" input: ClusterUpdateInput! ): ClusterUpdatePayload """Add a new email address for the current user""" emailCreate( """Parameters for EmailCreate""" input: EmailCreateInput! ): EmailCreatePayload """Resend a verification email.""" emailResendVerification( """Parameters for EmailResendVerification""" input: EmailResendVerificationInput! ): EmailResendVerificationPayload """Create a GraphQL snippet.""" graphQLSnippetCreate( """Parameters for GraphQLSnippetCreate""" input: GraphQLSnippetCreateInput! ): GraphQLSnippetCreatePayload """Unblocks a build's "Block pipeline" job.""" jobTypeBlockUnblock( """Parameters for JobTypeBlockUnblock""" input: JobTypeBlockUnblockInput! ): JobTypeBlockUnblockPayload """Cancel a job.""" jobTypeCommandCancel( """Parameters for JobTypeCommandCancel""" input: JobTypeCommandCancelInput! ): JobTypeCommandCancelPayload """Retry a job.""" jobTypeCommandRetry( """Parameters for JobTypeCommandRetry""" input: JobTypeCommandRetryInput! ): JobTypeCommandRetryPayload """Dismisses a notice from the Buildkite UI. This mutation is idempotent so if you dismiss the same notice multiple times, it will return the original `dismissedAt` time""" noticeDismiss( """Parameters for NoticeDismiss""" input: NoticeDismissInput! ): NoticeDismissPayload """Revokes access to an organization for a user's API access token. The organization can not be re-added to the same token, however the user can create a new token and add the organization to that token.""" organizationApiAccessTokenRevoke( """Parameters for OrganizationAPIAccessTokenRevokeMutation""" input: OrganizationAPIAccessTokenRevokeMutationInput! ): OrganizationAPIAccessTokenRevokeMutationPayload """Sets an allowlist of IP addresses for API access to an organization. Please note that this is a beta feature and is not yet available to all organizations. """ organizationApiIpAllowlistUpdate( """Parameters for OrganizationAPIIPAllowlistUpdateMutation""" input: OrganizationAPIIPAllowlistUpdateMutationInput! ): OrganizationAPIIPAllowlistUpdateMutationPayload """Delete the system banner""" organizationBannerDelete( """Parameters for OrganizationBannerDelete""" input: OrganizationBannerDeleteInput! ): OrganizationBannerDeletePayload """Retrieves the active system banner for provided organization, then updates it with input data. If active banner is not found, a new banner is created with the provided input.""" organizationBannerUpsert( """Parameters for OrganizationBannerUpsert""" input: OrganizationBannerUpsertInput! ): OrganizationBannerUpsertPayload """Sets whether the organization requires two-factor authentication for all members.""" organizationEnforceTwoFactorAuthenticationForMembersUpdate( """Parameters for OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutation""" input: OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutationInput! ): OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutationPayload """Send email invitations to this organization.""" organizationInvitationCreate( """Parameters for OrganizationInvitationCreate""" input: OrganizationInvitationCreateInput! ): OrganizationInvitationCreatePayload """Resend an organization invitation email.""" organizationInvitationResend( """Parameters for OrganizationInvitationResend""" input: OrganizationInvitationResendInput! ): OrganizationInvitationResendPayload """Revoke an invitation to an organization so that it can no longer be accepted.""" organizationInvitationRevoke( """Parameters for OrganizationInvitationRevoke""" input: OrganizationInvitationRevokeInput! ): OrganizationInvitationRevokePayload """Remove a user from an organization.""" organizationMemberDelete( """Parameters for OrganizationMemberDelete""" input: OrganizationMemberDeleteInput! ): OrganizationMemberDeletePayload """Change a user's role within an organization.""" organizationMemberUpdate( """Parameters for OrganizationMemberUpdate""" input: OrganizationMemberUpdateInput! ): OrganizationMemberUpdatePayload """Specify the maximum timeframe to revoke organization access from inactive API tokens.""" organizationRevokeInactiveTokensAfterUpdate( """Parameters for OrganizationRevokeInactiveTokensAfterUpdateMutation""" input: OrganizationRevokeInactiveTokensAfterUpdateMutationInput! ): OrganizationRevokeInactiveTokensAfterUpdateMutationPayload """Archive a pipeline.""" pipelineArchive( """Parameters for PipelineArchive""" input: PipelineArchiveInput! ): PipelineArchivePayload """Create a pipeline.""" pipelineCreate( """Parameters for PipelineCreate""" input: PipelineCreateInput! ): PipelineCreatePayload """Create SCM webhooks for a pipeline.""" pipelineCreateWebhook( """Parameters for PipelineCreateWebhook""" input: PipelineCreateWebhookInput! ): PipelineCreateWebhookPayload """Delete a pipeline.""" pipelineDelete( """Parameters for PipelineDelete""" input: PipelineDeleteInput! ): PipelineDeletePayload """Favorite a pipeline.""" pipelineFavorite( """Parameters for PipelineFavorite""" input: PipelineFavoriteInput! ): PipelineFavoritePayload """Rotate a pipeline's webhook URL. Note that the old webhook URL will stop working immediately and so must be updated quickly to avoid interruption. """ pipelineRotateWebhookURL( """Parameters for PipelineRotateWebhookURL""" input: PipelineRotateWebhookURLInput! ): PipelineRotateWebhookURLPayload """Create a scheduled build on pipeline.""" pipelineScheduleCreate( """Parameters for PipelineScheduleCreate""" input: PipelineScheduleCreateInput! ): PipelineScheduleCreatePayload """Delete a scheduled build on pipeline.""" pipelineScheduleDelete( """Parameters for PipelineScheduleDelete""" input: PipelineScheduleDeleteInput! ): PipelineScheduleDeletePayload """Update a scheduled build on pipeline.""" pipelineScheduleUpdate( """Parameters for PipelineScheduleUpdate""" input: PipelineScheduleUpdateInput! ): PipelineScheduleUpdatePayload """Create a pipeline template.""" pipelineTemplateCreate( """Parameters for PipelineTemplateCreate""" input: PipelineTemplateCreateInput! ): PipelineTemplateCreatePayload """Delete a pipeline template.""" pipelineTemplateDelete( """Parameters for PipelineTemplateDelete""" input: PipelineTemplateDeleteInput! ): PipelineTemplateDeletePayload """Update a pipeline template.""" pipelineTemplateUpdate( """Parameters for PipelineTemplateUpdate""" input: PipelineTemplateUpdateInput! ): PipelineTemplateUpdatePayload """Unarchive a pipeline.""" pipelineUnarchive( """Parameters for PipelineUnarchive""" input: PipelineUnarchiveInput! ): PipelineUnarchivePayload """Change the settings for a pipeline.""" pipelineUpdate( """Parameters for PipelineUpdate""" input: PipelineUpdateInput! ): PipelineUpdatePayload """Create a SSO provider.""" ssoProviderCreate( """Parameters for SSOProviderCreate""" input: SSOProviderCreateInput! ): SSOProviderCreatePayload """Delete a SSO provider.""" ssoProviderDelete( """Parameters for SSOProviderDelete""" input: SSOProviderDeleteInput! ): SSOProviderDeletePayload """Disable a SSO provider.""" ssoProviderDisable( """Parameters for SSOProviderDisable""" input: SSOProviderDisableInput! ): SSOProviderDisablePayload """Enable a SSO provider.""" ssoProviderEnable( """Parameters for SSOProviderEnable""" input: SSOProviderEnableInput! ): SSOProviderEnablePayload """Change the settings for a SSO provider.""" ssoProviderUpdate( """Parameters for SSOProviderUpdate""" input: SSOProviderUpdateInput! ): SSOProviderUpdatePayload """Create a team.""" teamCreate( """Parameters for TeamCreate""" input: TeamCreateInput! ): TeamCreatePayload """Delete a team.""" teamDelete( """Parameters for TeamDelete""" input: TeamDeleteInput! ): TeamDeletePayload """Add a user to a team.""" teamMemberCreate( """Parameters for TeamMemberCreate""" input: TeamMemberCreateInput! ): TeamMemberCreatePayload """Remove a user from a team.""" teamMemberDelete( """Parameters for TeamMemberDelete""" input: TeamMemberDeleteInput! ): TeamMemberDeletePayload """Update a user's role in a team.""" teamMemberUpdate( """Parameters for TeamMemberUpdate""" input: TeamMemberUpdateInput! ): TeamMemberUpdatePayload """Add a pipeline to a team.""" teamPipelineCreate( """Parameters for TeamPipelineCreate""" input: TeamPipelineCreateInput! ): TeamPipelineCreatePayload """Remove a pipeline from a team.""" teamPipelineDelete( """Parameters for TeamPipelineDelete""" input: TeamPipelineDeleteInput! ): TeamPipelineDeletePayload """Update a pipeline's access level within a team.""" teamPipelineUpdate( """Parameters for TeamPipelineUpdate""" input: TeamPipelineUpdateInput! ): TeamPipelineUpdatePayload """Add a suite to a team.""" teamSuiteCreate( """Parameters for TeamSuiteCreate""" input: TeamSuiteCreateInput! ): TeamSuiteCreatePayload """Remove a suite from a team.""" teamSuiteDelete( """Parameters for TeamSuiteDelete""" input: TeamSuiteDeleteInput! ): TeamSuiteDeletePayload """Update a suite's access level within a team.""" teamSuiteUpdate( """Parameters for TeamSuiteUpdate""" input: TeamSuiteUpdateInput! ): TeamSuiteUpdatePayload """Change the settings for a team.""" teamUpdate( """Parameters for TeamUpdate""" input: TeamUpdateInput! ): TeamUpdatePayload """Activate a previously-generated TOTP configuration, and its Recovery Codes. Once activated, both this TOTP configuration, and the associated Recovery Codes will become active for the user. Any previous TOTP configuration or Recovery Codes will no longer be usable. This mutation is private, requires an escalated session, and cannot be accessed via the public GraphQL API. """ totpActivate( """Parameters for TOTPActivate""" input: TOTPActivateInput! ): TOTPActivatePayload """Create a new TOTP configuration for the current user. This will produce a TOTP configuration with an associated set of Recovery Codes. The Recovery Codes must be presented to the user prior to the TOTP's activation with `totpActivate`. Neither TOTP configuration nor Recovery Codes will be usable until they have been activated. This mutation is private, requires an escalated session, and cannot be accessed via the public GraphQL API. """ totpCreate( """Parameters for TOTPCreate""" input: TOTPCreateInput! ): TOTPCreatePayload """Delete a TOTP configuration. If a TOTP configuration was active, it will no longer be used for logging on to the user's account. Any Recovery Codes associated with the TOTP configuration will also no longer be usable. This mutation is private, requires an escalated session, and cannot be accessed via the public GraphQL API. """ totpDelete( """Parameters for TOTPDelete""" input: TOTPDeleteInput! ): TOTPDeletePayload """Generate a new set of Recovery Codes for a given TOTP. The new Recovery Codes will immediately replace any existing recovery codes. This mutation is private, requires an escalated session, and cannot be accessed via the public GraphQL API. """ totpRecoveryCodesRegenerate( """Parameters for TOTPRecoveryCodesRegenerate""" input: TOTPRecoveryCodesRegenerateInput! ): TOTPRecoveryCodesRegeneratePayload } """An object with an ID.""" interface Node { """An object with an ID.""" id: ID! } """A notice or notice that a user sees in the Buildkite UI""" type Notice { """The time when this notice was dismissed from the UI""" dismissedAt: DateTime id: ID! """The namespace of this notice""" namespace: NoticeNamespaces! """The scope within the namespace""" scope: String! } """Autogenerated input type of NoticeDismiss""" input NoticeDismissInput { """Autogenerated input type of NoticeDismiss""" clientMutationId: String """Autogenerated input type of NoticeDismiss""" id: ID! } """Autogenerated return type of NoticeDismiss.""" type NoticeDismissPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String notice: Notice } """All the possible namespaces for a notice""" enum NoticeNamespaces { """A change to an existing feature""" CHANGE """The user has had an email suggested to them""" EMAIL_SUGGESTION """A new feature was added""" FEATURE """An event announcement""" EVENT } interface NotificationService { description: String! id: ID! name: String! } """Deliver notifications to Slack""" type NotificationServiceSlack implements Node & NotificationService{ """The description of this service""" description: String! id: ID! """The name of the service provider""" name: String! } """Deliver notifications to a custom URL""" type NotificationServiceWebhook implements NotificationService{ """The description of this service""" description: String! id: ID! """The name of the service provider""" name: String! } """A operating system that an agent can run on""" type OperatingSystem { """The name of the operating system""" name: String! } """An organization""" type Organization implements Node{ """Returns agent access tokens for an Organization. By default returns all tokens, whether revoked or non-revoked.""" agentTokens( first: Int last: Int """Filter tokens by whether they are revoked or not""" revoked: Boolean ): AgentTokenConnection agents( first: Int after: String last: Int before: String """Search agents for the given query terms case insensitively across name and meta data""" search: String """Filter agents to those only having the matching meta data""" metaData: [String!] """Filter agents by membership of a given cluster""" cluster: ID """Filter agents to those within a given cluster queue""" clusterQueue: [ID!] """Pass `false` to exclude agents that belong to a cluster queue""" clustered: Boolean """Filter agents by whether they are running a job or not""" isRunningJob: Boolean ): AgentConnection """A space-separated allowlist of IP addresses that can access the organization via the GraphQL or REST API""" allowedApiIpAddresses: String """Returns user API access tokens that can access this organization""" apiAccessTokens( """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the elements in the list that come before the specified cursor.""" before: String """Returns the first _n_ elements from the list.""" first: Int """Returns the last _n_ elements from the list.""" last: Int ): OrganizationAPIAccessTokenConnection! auditEvents( first: Int after: String last: Int before: String """Filter events which occurred from the given date and time""" occurredAtFrom: DateTime """Filter events which occurred until the given date and time""" occurredAtTo: DateTime """Filter the events by type""" type: [AuditEventType!] """Filter the events by the type of actor who initiated them""" actorType: [AuditActorType!] """Filter the events by the IDs of the actors who initiated them""" actor: [ID!] """Filter the events by the type of subject they relate to""" subjectType: [AuditSubjectType!] """Filter the events by the IDs of the subject they relate to""" subject: [ID!] """Order the events""" order: OrganizationAuditEventOrders """Filter the events by the UUIDs of the subject they relate to""" subjectUUID: [ID!] ): OrganizationAuditEventConnection """Returns active banners for this organization.""" banners( """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the elements in the list that come before the specified cursor.""" before: String """Returns the first _n_ elements from the list.""" first: Int """Returns the last _n_ elements from the list.""" last: Int ): OrganizationBannerConnection! """Return cluster in the Organization by UUID""" cluster( id: ID! ): Cluster """Returns clusters for an Organization""" clusters( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String """Order the clusters""" order: ClusterOrder ): ClusterConnection """The URL to an icon representing this organization""" iconUrl: String id: ID! invitations( first: Int after: String last: Int before: String state: [OrganizationInvitationStates!] """Order the invitations""" order: OrganizationInvitationOrders ): OrganizationInvitationConnection """Whether teams is enabled for this organization""" isTeamsEnabled: Boolean! jobs( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String type: [JobTypes!] state: [JobStates!] priority: JobPrioritySearch agentQueryRules: [String!] concurrency: JobConcurrencySearch """Whether or not the command job passed. Passing `false` will return all failed jobs (including "soft failed" jobs)""" passed: Boolean """Filtering jobs based on related step information""" step: JobStepSearch """Order the jobs""" order: JobOrder """Filter jobs by membership of a given cluster""" cluster: ID """Filter jobs to those within a given cluster queue""" clusterQueue: [ID!] """Pass `false` to exclude jobs that belong to a cluster queue""" clustered: Boolean ): JobConnection """Returns users within the organization""" members( first: Int after: String last: Int before: String """Search members named like the given query case insensitively""" search: String """The primary email of the team member""" email: String """Filter the members by team""" team: TeamSelector """Search members by their role""" role: [OrganizationMemberRole!] security: OrganizationMemberSecurityInput sso: OrganizationMemberSSOInput """Order the members""" order: OrganizationMemberOrder ): OrganizationMemberConnection """Whether this organization requires 2FA to access (Please note that this is a beta feature and is not yet available to all organizations.)""" membersRequireTwoFactorAuthentication: Boolean! """The name of the organization""" name: String! permissions: OrganizationPermissions! """Return all the pipeline templates the current user has access to for this organization""" pipelineTemplates( first: Int last: Int after: String before: String """Order the pipeline templates""" order: PipelineTemplateOrder ): PipelineTemplateConnection """Return all the pipelines the current user has access to for this organization""" pipelines( first: Int after: String last: Int before: String """Search pipelines named like the given query case insensitively""" search: String repository: PipelineRepositoryInput """Filter pipelines by membership of a given cluster""" cluster: ID """Pass `false` to exclude pipelines that belong to a cluster""" clustered: Boolean """Filter pipelines based on whether or not they've been archived. If not provided, all pipelines are returned regardless of archived state.""" archived: Boolean """Filter the pipelines by team""" team: TeamSelector """Only return favorited pipelines""" favorite: Boolean """Order the pipelines""" order: PipelineOrders """Filter pipelines with those that have particular tags""" tags: [String!] createdAtFrom: DateTime createdAtTo: DateTime ): PipelineConnection """Whether this organization is visible to everyone, including people outside it""" public: Boolean! """API tokens with access to this organization will be automatically revoked after this many seconds of inactivity. A `null` value indicates never revoke inactive tokens.""" revokeInactiveTokensAfter: RevokeInactiveTokenPeriod """The slug used to represent the organization in URLs""" slug: String! """The single sign-on configuration of this organization""" sso: OrganizationSSO """Single sign on providers created for an organization""" ssoProviders( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String ): SSOProviderConnection """Return all the suite the current user has access to for this organization""" suites( first: Int after: String last: Int before: String """Search suites named like the given query case insensitively""" search: String """Filter the suites by team""" team: TeamSelector """Order the suites""" order: SuiteOrders createdAtFrom: DateTime createdAtTo: DateTime ): SuiteConnection """Returns teams within the organization that the viewer can see""" teams( first: Int after: String last: Int before: String """Search teams""" search: String """Filter teams by pipeline""" pipeline: PipelineSelector """Filter teams by user membership""" user: UserSelector """Search teams by their privacy""" privacy: [TeamPrivacy!] """Order the teams""" order: TeamOrder ): TeamConnection """Returns the resource usage data for this organization.""" usage( """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the elements in the list that come before the specified cursor.""" before: String """Returns the first _n_ elements from the list.""" first: Int """Returns the last _n_ elements from the list.""" last: Int """Filter aggregations performed from this date""" aggregatedOnFrom: ISO8601Date """Filter aggregations performed until this date""" aggregatedOnTo: ISO8601Date """Filter results by resource type""" resource: [ResourceUsageType!] """Filter results by the associated Pipeline ID""" pipelineIds: [ID!] """Filter results by the associated Suite ID""" suiteIds: [ID!] ): UsageUnionConnection! """The public UUID for this organization""" uuid: String! } """Information on user API Access Tokens which can access the Organization. Excludes the token attribute""" type OrganizationAPIAccessToken { createdAt: DateTime! """A description of the token""" description: String id: ID! """The IP address of the last request to the Buildkite API""" ipAddress: String """The last time the token was used to access the Buildkite API""" lastAccessedAt: DateTime """The user associated with this token""" owner: User """The organization scopes that the user's token has access to""" scopes: [APIAccessTokenScopes!]! """The public UUID for the API Access Token""" uuid: ID! } """The connection type for OrganizationAPIAccessToken.""" type OrganizationAPIAccessTokenConnection { """A list of edges.""" edges: [OrganizationAPIAccessTokenEdge] """A list of nodes.""" nodes: [OrganizationAPIAccessToken] """Information to aid in pagination.""" pageInfo: PageInfo! } """An edge in a connection.""" type OrganizationAPIAccessTokenEdge { """A cursor for use in pagination.""" cursor: String! """The item at the end of the edge.""" node: OrganizationAPIAccessToken } """Autogenerated input type of OrganizationAPIAccessTokenRevokeMutation""" input OrganizationAPIAccessTokenRevokeMutationInput { """Autogenerated input type of OrganizationAPIAccessTokenRevokeMutation""" clientMutationId: String """Autogenerated input type of OrganizationAPIAccessTokenRevokeMutation""" organizationId: ID! """Autogenerated input type of OrganizationAPIAccessTokenRevokeMutation""" apiAccessTokenId: ID! } """Autogenerated return type of OrganizationAPIAccessTokenRevokeMutation.""" type OrganizationAPIAccessTokenRevokeMutationPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String revokedApiAccessTokenId: ID! } """Autogenerated input type of OrganizationAPIIPAllowlistUpdateMutation""" input OrganizationAPIIPAllowlistUpdateMutationInput { """Autogenerated input type of OrganizationAPIIPAllowlistUpdateMutation""" clientMutationId: String """Autogenerated input type of OrganizationAPIIPAllowlistUpdateMutation""" organizationID: ID! """Autogenerated input type of OrganizationAPIIPAllowlistUpdateMutation""" ipAddresses: String! } """Autogenerated return type of OrganizationAPIIPAllowlistUpdateMutation.""" type OrganizationAPIIPAllowlistUpdateMutationPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organization: Organization } type OrganizationAuditEventConnection implements Connection{ count: Int! edges: [OrganizationAuditEventEdge] pageInfo: PageInfo } type OrganizationAuditEventEdge { cursor: String! node: AuditEvent } """The different orders you can sort audit events by""" enum OrganizationAuditEventOrders { """Order by the most recently occurring events first""" RECENTLY_OCCURRED } """System banner of an organization""" type OrganizationBanner implements Node{ id: ID! """The banner message""" message: String! """The UUID of the organization banner""" uuid: String! } """The connection type for OrganizationBanner.""" type OrganizationBannerConnection { """A list of edges.""" edges: [OrganizationBannerEdge] """A list of nodes.""" nodes: [OrganizationBanner] """Information to aid in pagination.""" pageInfo: PageInfo! } """Autogenerated input type of OrganizationBannerDelete""" input OrganizationBannerDeleteInput { """Autogenerated input type of OrganizationBannerDelete""" clientMutationId: String """Autogenerated input type of OrganizationBannerDelete""" organizationId: ID! } """Autogenerated return type of OrganizationBannerDelete.""" type OrganizationBannerDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedBannerId: ID! } """An edge in a connection.""" type OrganizationBannerEdge { """A cursor for use in pagination.""" cursor: String! """The item at the end of the edge.""" node: OrganizationBanner } """Autogenerated input type of OrganizationBannerUpsert""" input OrganizationBannerUpsertInput { """Autogenerated input type of OrganizationBannerUpsert""" clientMutationId: String """Autogenerated input type of OrganizationBannerUpsert""" organizationId: ID! """Autogenerated input type of OrganizationBannerUpsert""" message: String! } """Autogenerated return type of OrganizationBannerUpsert.""" type OrganizationBannerUpsertPayload { banner: OrganizationBanner! """A unique identifier for the client performing the mutation.""" clientMutationId: String } type OrganizationConnection implements Connection{ count: Int! edges: [OrganizationEdge] pageInfo: PageInfo } type OrganizationEdge { cursor: String! node: Organization } """Autogenerated input type of OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutation""" input OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutationInput { """Autogenerated input type of OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutation""" clientMutationId: String """Autogenerated input type of OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutation""" organizationId: ID! """Autogenerated input type of OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutation""" membersRequireTwoFactorAuthentication: Boolean! } """Autogenerated return type of OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutation.""" type OrganizationEnforceTwoFactorAuthenticationForMembersUpdateMutationPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organization: Organization! } """A pending invitation to a user to join this organization""" type OrganizationInvitation implements Node{ """The time when the invitation was accepted""" acceptedAt: DateTime """The user that accepted this invite""" acceptedBy: User """The time when the invitation was created""" createdAt: DateTime """The user that added invited this email address""" createdBy: User """The email address of this invitation""" email: String! """The time when the invitation was automatically expired""" expiredAt: DateTime id: ID! organization: Organization permissions: OrganizationInvitationPermissions! """The time when this invitation was revoked""" revokedAt: DateTime """The user that revoked this invitation""" revokedBy: User """The role the user will have in the organization once they've accepted the invitation""" role: OrganizationMemberRole! """The slug of the invitation that can be used to find an invitation in the query root""" slug: String! sso: OrganizationInvitationSSOType! """The current state of the invitation""" state: OrganizationInvitationStates! """Teams that have been assigned to this invitation""" teams( """Returns the first _n_ elements from the list.""" first: Int ): OrganizationInvitationTeamAssignmentConnection """The UUID of the invitation""" uuid: String! } type OrganizationInvitationConnection implements Connection{ count: Int! edges: [OrganizationInvitationEdge] pageInfo: PageInfo } """Autogenerated input type of OrganizationInvitationCreate""" input OrganizationInvitationCreateInput { """Autogenerated input type of OrganizationInvitationCreate""" clientMutationId: String """Autogenerated input type of OrganizationInvitationCreate""" organizationID: ID! """Autogenerated input type of OrganizationInvitationCreate""" emails: [String!]! """Autogenerated input type of OrganizationInvitationCreate""" role: OrganizationMemberRole """Autogenerated input type of OrganizationInvitationCreate""" sso: OrganizationInvitationSSOInput """Autogenerated input type of OrganizationInvitationCreate""" teams: [OrganizationInvitationTeamAssignmentInput!] } """Autogenerated return type of OrganizationInvitationCreate.""" type OrganizationInvitationCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String invitationEdges: [OrganizationInvitationEdge] organization: Organization } type OrganizationInvitationEdge { cursor: String! node: OrganizationInvitation } """The different orders you can sort organization invitations by""" enum OrganizationInvitationOrders { """Order by email address alphabetically""" EMAIL """Order by the most recently created invitations first""" RECENTLY_CREATED } """Permissions information about what actions the current user can do against this invitation""" type OrganizationInvitationPermissions { """Whether the user can resend this invitation""" organizationInvitationResend: Permission """Whether the user can revoke this invitation""" organizationInvitationRevoke: Permission } """Autogenerated input type of OrganizationInvitationResend""" input OrganizationInvitationResendInput { """Autogenerated input type of OrganizationInvitationResend""" clientMutationId: String """Autogenerated input type of OrganizationInvitationResend""" id: ID! } """Autogenerated return type of OrganizationInvitationResend.""" type OrganizationInvitationResendPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organizationInvitation: OrganizationInvitation! } """Autogenerated input type of OrganizationInvitationRevoke""" input OrganizationInvitationRevokeInput { """Autogenerated input type of OrganizationInvitationRevoke""" clientMutationId: String """Autogenerated input type of OrganizationInvitationRevoke""" id: ID! } """Autogenerated return type of OrganizationInvitationRevoke.""" type OrganizationInvitationRevokePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organization: Organization! organizationInvitation: OrganizationInvitation! organizationInvitationEdge: OrganizationInvitationEdge! } input OrganizationInvitationSSOInput { mode: OrganizationMemberSSOModeEnum! } """Information about the SSO setup for this invited organization member""" type OrganizationInvitationSSOType { """The SSO mode of the invited organization member""" mode: OrganizationMemberSSOModeEnum } """All the possible states that an organization invitation can be""" enum OrganizationInvitationStates { """The invitation is waiting for a user to accept it""" PENDING """The invitation was accepted by the person it was sent to""" ACCEPTED """The invitation wasn't accepted and the link has expired""" EXPIRED """The invitation was revoked and can no longer be accepted""" REVOKED } """A team that has been assigned to an invitation""" type OrganizationInvitationTeamAssignment { id: ID! """The role that the user will have once they've accepted the invite""" role: TeamMemberRole! """The team that this assignment refers to""" team: Team! } type OrganizationInvitationTeamAssignmentConnection implements Connection{ count: Int! edges: [OrganizationInvitationTeamAssignmentEdge] pageInfo: PageInfo } type OrganizationInvitationTeamAssignmentEdge { cursor: String! node: OrganizationInvitationTeamAssignment } """Used to assign teams to organization invitation in mutations""" input OrganizationInvitationTeamAssignmentInput { """Used to assign teams to organization invitation in mutations""" id: ID! """Used to assign teams to organization invitation in mutations""" role: TeamMemberRole! } """A member of an organization""" type OrganizationMember implements Node{ """Whether or not organizations are required to pay for this user""" complimentary: Boolean! """The time when this user was added to the organization""" createdAt: DateTime! """The user that added invited this user""" createdBy: User id: ID! organization: Organization! permissions: OrganizationMemberPermissions! """Pipelines the user has access to within the organization""" pipelines( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String """Search within the pipelines the user has access to""" search: String """Order the pipelines returned""" order: PipelineOrders ): OrganizationMemberPipelineConnection! """The users role within the organization""" role: OrganizationMemberRole! security: OrganizationMemberSecurity! sso: OrganizationMemberSSO! """Teams that this user is a part of within the organization""" teams( first: Int after: String last: Int before: String """Order the members returned""" order: TeamMemberOrder ): TeamMemberConnection! user: User! """The public UUID for this organization member""" uuid: String! } type OrganizationMemberConnection implements Connection{ count: Int! edges: [OrganizationMemberEdge] pageInfo: PageInfo } """Autogenerated input type of OrganizationMemberDelete""" input OrganizationMemberDeleteInput { """Autogenerated input type of OrganizationMemberDelete""" clientMutationId: String """Autogenerated input type of OrganizationMemberDelete""" id: ID! } """Autogenerated return type of OrganizationMemberDelete.""" type OrganizationMemberDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedOrganizationMemberID: ID! organization: Organization user: User } type OrganizationMemberEdge { cursor: String! node: OrganizationMember } """The different orders you can sort members by""" enum OrganizationMemberOrder { """Order by name alphabetically""" NAME """Order by the most recently created members first""" RECENTLY_CREATED """Order by relevance when searching for members""" RELEVANCE } """Permissions information about what actions the current user can do against the organization membership record""" type OrganizationMemberPermissions { """Whether the user can delete the user from the organization""" organizationMemberDelete: Permission """Whether the user can update the organization's members role information""" organizationMemberUpdate: Permission } """Represents the connection between a user an a pipeline within an organization""" type OrganizationMemberPipeline { """The pipeline the user has access to within the organization""" pipeline: Pipeline! } type OrganizationMemberPipelineConnection implements Connection{ count: Int! edges: [OrganizationMemberPipelineEdge] pageInfo: PageInfo } type OrganizationMemberPipelineEdge { cursor: String! node: OrganizationMemberPipeline } """The roles a user can be within an organization""" enum OrganizationMemberRole { """The user is a regular member of the organization""" MEMBER """Has full access to the entire organization""" ADMIN } """Information about the SSO setup for this organization member""" type OrganizationMemberSSO { """SSO authorizations provided by your organization that have been created for this user""" authorizations( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String """Filter authorizations by state""" state: [SSOAuthorizationState!] ): SSOAuthorizationConnection """The SSO mode of the organization member""" mode: OrganizationMemberSSOModeEnum } input OrganizationMemberSSOInput { mode: OrganizationMemberSSOModeEnum! } """The SSO authorization modes you can use on a member""" enum OrganizationMemberSSOModeEnum { """The member must use SSO to access your organization""" REQUIRED """The member can either use SSO or their email & password""" OPTIONAL } """Information about what security settings the user has enabled in Buildkite""" type OrganizationMemberSecurity { """If the user has secured their Buildkite user account with a password""" passwordProtected: Boolean! """If the user has enabled Two Factor Authentication""" twoFactorEnabled: Boolean! } input OrganizationMemberSecurityInput { twoFactorEnabled: Boolean passwordProtected: Boolean } """Autogenerated input type of OrganizationMemberUpdate""" input OrganizationMemberUpdateInput { """Autogenerated input type of OrganizationMemberUpdate""" clientMutationId: String """Autogenerated input type of OrganizationMemberUpdate""" id: ID! """Autogenerated input type of OrganizationMemberUpdate""" role: OrganizationMemberRole """Autogenerated input type of OrganizationMemberUpdate""" sso: OrganizationMemberSSOInput } """Autogenerated return type of OrganizationMemberUpdate.""" type OrganizationMemberUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organizationMember: OrganizationMember } """Permissions information about what actions the current user can do against the organization""" type OrganizationPermissions { """Whether the user can create agent tokens""" agentTokenCreate: Permission """Whether the user can access agent tokens""" agentTokenView: Permission """Whether the user can create a see a list of agents in organization""" agentView: Permission """Whether the user can access audit events for the organization""" auditEventsView: Permission """Whether the user can change the notification services for the organization""" notificationServiceUpdate: Permission """Whether the user can view and manage billing for the organization""" organizationBillingUpdate: Permission """Whether the user can invite members from an organization""" organizationInvitationCreate: Permission """Whether the user can update/remove members from an organization""" organizationMemberUpdate: Permission """Whether the user can see members in the organization""" organizationMemberView: Permission """Whether the user can see sensitive information about members in the organization""" organizationMemberViewSensitive: Permission """Whether the user can change the organization name and related source code provider settings""" organizationUpdate: Permission """Whether the user can create a new pipeline in the organization""" pipelineCreate: Permission """Whether the user can create a new pipeline without adding it to any teams within the organization""" pipelineCreateWithoutTeams: Permission """Whether the user can create a see a list of pipelines in organization""" pipelineView: Permission """Whether the user can change SSO Providers for the organization""" ssoProviderCreate: Permission """Whether the user can change SSO Providers for the organization""" ssoProviderUpdate: Permission """Whether the user can create a see a list of suites in organization""" suiteView: Permission """Whether the user can administer one or all the teams in the organization""" teamAdmin: Permission """Whether the user can create teams for the organization""" teamCreate: Permission """Whether the user can toggle teams on/off for the organization""" teamEnabledChange: Permission """Whether the user can see teams in the organization""" teamView: Permission } """Autogenerated input type of OrganizationRevokeInactiveTokensAfterUpdateMutation""" input OrganizationRevokeInactiveTokensAfterUpdateMutationInput { """Autogenerated input type of OrganizationRevokeInactiveTokensAfterUpdateMutation""" clientMutationId: String """Autogenerated input type of OrganizationRevokeInactiveTokensAfterUpdateMutation""" organizationId: ID! """Autogenerated input type of OrganizationRevokeInactiveTokensAfterUpdateMutation""" revokeInactiveTokensAfter: RevokeInactiveTokenPeriod! } """Autogenerated return type of OrganizationRevokeInactiveTokensAfterUpdateMutation.""" type OrganizationRevokeInactiveTokensAfterUpdateMutationPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organization: Organization } """Single sign-on settings for an organization""" type OrganizationSSO { """Whether this account is configured for single sign-on""" isEnabled: Boolean! """The single sign-on provider for this organization""" provider: OrganizationSSOProvider } """Single sign-on provider information for an organization""" type OrganizationSSOProvider { name: String! } """Information about pagination in a connection.""" type PageInfo { """When paginating forwards, the cursor to continue.""" endCursor: String """When paginating forwards, are there more items?""" hasNextPage: Boolean! """When paginating backwards, are there more items?""" hasPreviousPage: Boolean! """When paginating backwards, the cursor to continue.""" startCursor: String } """The result of checking a permissions""" type Permission { allowed: Boolean! code: String message: String } """A pipeline""" type Pipeline implements Node{ """Whether existing builds can be rebuilt as new builds.""" allowRebuilds: Boolean """Whether this pipeline has been archived""" archived: Boolean! """The time when the pipeline was archived""" archivedAt: DateTime """The user that archived this pipeline""" archivedBy: User """A branch filter pattern to limit which pushed branches trigger builds on this pipeline.""" branchConfiguration: String """Returns the builds for this pipeline""" builds( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String state: [BuildStates!] """Use `%default` to search by the Pipelines default branch""" branch: [String!] commit: [String!] metaData: [String!] createdAtFrom: DateTime createdAtTo: DateTime ): BuildConnection """When a new build is created on a branch, any previous builds that are running on the same branch will be automatically cancelled""" cancelIntermediateBuilds: Boolean! """Limit which branches build cancelling applies to, for example `!main` will ensure that the main branch won't have it's builds automatically cancelled.""" cancelIntermediateBuildsBranchFilter: String cluster: Cluster """The color of the pipeline""" color: String """The shortest length to which any git commit ID may be truncated while guaranteeing referring to a unique commit""" commitShortLength: Int! """The time when the pipeline was created""" createdAt: DateTime """The user who created the pipeline""" createdBy: User """The default branch for this pipeline""" defaultBranch: String """The default timeout in minutes for all command steps in this pipeline. This can still be overridden in any command step""" defaultTimeoutInMinutes: Int """The short description of the pipeline""" description: String """The emoji of the pipeline""" emoji: String """Returns true if the viewer has favorited this pipeline""" favorite: Boolean! id: ID! jobs( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String type: [JobTypes!] state: [JobStates!] priority: JobPrioritySearch agentQueryRules: [String!] concurrency: JobConcurrencySearch """Whether or not the command job passed. Passing `false` will return all failed jobs (including "soft failed" jobs)""" passed: Boolean """Filtering jobs based on related step information""" step: JobStepSearch """Order the jobs""" order: JobOrder ): JobConnection """The maximum timeout in minutes for all command steps in this pipeline. Any command step without a timeout or with a timeout greater than this value will be set to this value.""" maximumTimeoutInMinutes: Int metrics( first: Int last: Int ): PipelineMetricConnection """The name of the pipeline""" name: String! """The next build number in the sequence""" nextBuildNumber: Int! organization: Organization! permissions: PipelinePermissions! pipelineTemplate: PipelineTemplate """Whether this pipeline is visible to everyone, including people outside this organization""" public: Boolean! """The repository for this pipeline""" repository: Repository """Schedules for this pipeline""" schedules( first: Int ): PipelineScheduleConnection """When a new build is created on a branch, any previous builds that haven't yet started on the same branch will be automatically marked as skipped.""" skipIntermediateBuilds: Boolean! """Limit which branches build skipping applies to, for example `!main` will ensure that the main branch won't have it's builds automatically skipped.""" skipIntermediateBuildsBranchFilter: String """The slug of the pipeline""" slug: String! steps: PipelineSteps """Tags that have been given to this pipeline""" tags: [PipelineTag!]! """Teams associated with this pipeline""" teams( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String """Search for teams associated that this pipeline is assigned to""" search: String """Order the pipelines returned""" order: TeamPipelineOrder ): TeamPipelineConnection """The URL for the pipeline""" url: String! """The UUID of the pipeline""" uuid: String! """Whether this pipeline is visible to everyone, including people outside this organization""" visibility: PipelineVisibility! """The URL to use in your repository settings for commit webhooks""" webhookURL: String! } """The access levels that can be assigned to a pipeline""" enum PipelineAccessLevels { """Allows edits, builds and reads""" MANAGE_BUILD_AND_READ """Allows builds and read only""" BUILD_AND_READ """Read only - no builds or edits""" READ_ONLY } """Autogenerated input type of PipelineArchive""" input PipelineArchiveInput { """Autogenerated input type of PipelineArchive""" clientMutationId: String """Autogenerated input type of PipelineArchive""" id: ID! } """Autogenerated return type of PipelineArchive.""" type PipelineArchivePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipeline: Pipeline! } type PipelineConnection implements Connection{ count: Int! edges: [PipelineEdge] pageInfo: PageInfo } """Autogenerated input type of PipelineCreate""" input PipelineCreateInput { """Autogenerated input type of PipelineCreate""" clientMutationId: String """Autogenerated input type of PipelineCreate""" organizationId: ID! """Autogenerated input type of PipelineCreate""" name: String! """Autogenerated input type of PipelineCreate""" description: String """Autogenerated input type of PipelineCreate""" emoji: String """Autogenerated input type of PipelineCreate""" color: String """Autogenerated input type of PipelineCreate""" visibility: PipelineVisibility """Autogenerated input type of PipelineCreate""" repository: PipelineRepositoryInput! """Autogenerated input type of PipelineCreate""" steps: PipelineStepsInput """Autogenerated input type of PipelineCreate""" skipIntermediateBuilds: Boolean """Autogenerated input type of PipelineCreate""" skipIntermediateBuildsBranchFilter: String """Autogenerated input type of PipelineCreate""" cancelIntermediateBuilds: Boolean """Autogenerated input type of PipelineCreate""" cancelIntermediateBuildsBranchFilter: String """Autogenerated input type of PipelineCreate""" allowRebuilds: Boolean """Autogenerated input type of PipelineCreate""" defaultTimeoutInMinutes: Int """Autogenerated input type of PipelineCreate""" maximumTimeoutInMinutes: Int """Autogenerated input type of PipelineCreate""" teams: [PipelineTeamAssignmentInput!] """Autogenerated input type of PipelineCreate""" defaultBranch: String """Autogenerated input type of PipelineCreate""" nextBuildNumber: Int """Autogenerated input type of PipelineCreate""" clusterId: ID """Autogenerated input type of PipelineCreate""" pipelineTemplateId: ID """Autogenerated input type of PipelineCreate""" tags: [PipelineTagInput!] """Autogenerated input type of PipelineCreate""" branchConfiguration: String } """Autogenerated return type of PipelineCreate.""" type PipelineCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String cluster: Cluster organization: Organization! pipeline: Pipeline! pipelineEdge: PipelineEdge! pipelineTemplate: PipelineTemplate } """Autogenerated input type of PipelineCreateWebhook""" input PipelineCreateWebhookInput { """Autogenerated input type of PipelineCreateWebhook""" clientMutationId: String """Autogenerated input type of PipelineCreateWebhook""" id: ID! } """Autogenerated return type of PipelineCreateWebhook.""" type PipelineCreateWebhookPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipelineID: ID! } """Autogenerated input type of PipelineDelete""" input PipelineDeleteInput { """Autogenerated input type of PipelineDelete""" clientMutationId: String """Autogenerated input type of PipelineDelete""" id: ID! } """Autogenerated return type of PipelineDelete.""" type PipelineDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedPipelineID: ID! organization: Organization! } type PipelineEdge { cursor: String! node: Pipeline } """Autogenerated input type of PipelineFavorite""" input PipelineFavoriteInput { """Autogenerated input type of PipelineFavorite""" clientMutationId: String """Autogenerated input type of PipelineFavorite""" id: ID! """Autogenerated input type of PipelineFavorite""" favorite: Boolean! } """Autogenerated return type of PipelineFavorite.""" type PipelineFavoritePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipeline: Pipeline } """A metric for a pipeline""" type PipelineMetric implements Node{ id: ID! """The label of this metric""" label: ID! """The URL for this metric""" url: String """The value for this metric""" value: String } type PipelineMetricConnection implements Connection{ count: Int! edges: [PipelineMetricEdge] pageInfo: PageInfo } type PipelineMetricEdge { cursor: String! node: PipelineMetric } """The different orders you can sort pipelines by""" enum PipelineOrders { """Order by name alphabetically""" NAME """Order by favorites first alphabetically, then the rest of the pipelines alphabetically""" NAME_WITH_FAVORITES_FIRST """Order by the most recently created pipelines first""" RECENTLY_CREATED """Order by relevance when searching for pipelines""" RELEVANCE } """Permission information about what actions the current user can do against the pipeline""" type PipelinePermissions { """Whether the user can create builds on this pipeline""" buildCreate: Permission! """Whether the user can delete this pipeline""" pipelineDelete: Permission! """Whether the user can favorite this pipeline""" pipelineFavorite: Permission! """Whether the user can create schedules on this pipeline""" pipelineScheduleCreate: Permission! """Whether the user can edit the settings of this pipeline""" pipelineUpdate: Permission! } """Repository information for a pipeline""" input PipelineRepositoryInput { """Repository information for a pipeline""" url: String! } """Autogenerated input type of PipelineRotateWebhookURL""" input PipelineRotateWebhookURLInput { """Autogenerated input type of PipelineRotateWebhookURL""" clientMutationId: String """Autogenerated input type of PipelineRotateWebhookURL""" id: ID! } """Autogenerated return type of PipelineRotateWebhookURL.""" type PipelineRotateWebhookURLPayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipeline: Pipeline! } """A schedule of when a build should automatically triggered for a Pipeline""" type PipelineSchedule implements Node{ """The branch to use for builds that this schedule triggers. Defaults to to the default branch in the Pipeline""" branch: String """Returns the builds created by this schedule""" builds( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String ): BuildConnection """The commit to use for builds that this schedule triggers. Defaults to `HEAD`""" commit: String """The time when this schedule was created""" createdAt: DateTime createdBy: User """A definition of the trigger build schedule in cron syntax""" cronline: String! """If this Pipeline schedule is currently enabled""" enabled: Boolean """Environment variables passed to any triggered builds""" env: [String!] """The time when this schedule failed""" failedAt: DateTime """If the last attempt at triggering this scheduled build fails, this will be the reason""" failedMessage: String id: ID! """A short description of the Pipeline schedule""" label: String! """The message to use for builds that this schedule triggers""" message: String """The time when this schedule will create a build next""" nextBuildAt: DateTime permissions: PipelineSchedulePermissions! pipeline: Pipeline """The UUID of the Pipeline schedule""" uuid: String! } type PipelineScheduleConnection implements Connection{ count: Int! edges: [PipelineScheduleEdge] pageInfo: PageInfo } """Autogenerated input type of PipelineScheduleCreate""" input PipelineScheduleCreateInput { """Autogenerated input type of PipelineScheduleCreate""" clientMutationId: String """Autogenerated input type of PipelineScheduleCreate""" pipelineID: ID! """Autogenerated input type of PipelineScheduleCreate""" label: String """Autogenerated input type of PipelineScheduleCreate""" cronline: String """Autogenerated input type of PipelineScheduleCreate""" message: String """Autogenerated input type of PipelineScheduleCreate""" commit: String """Autogenerated input type of PipelineScheduleCreate""" branch: String """Autogenerated input type of PipelineScheduleCreate""" env: String """Autogenerated input type of PipelineScheduleCreate""" enabled: Boolean } """Autogenerated return type of PipelineScheduleCreate.""" type PipelineScheduleCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipeline: Pipeline! pipelineScheduleEdge: PipelineScheduleEdge! } """Autogenerated input type of PipelineScheduleDelete""" input PipelineScheduleDeleteInput { """Autogenerated input type of PipelineScheduleDelete""" clientMutationId: String """Autogenerated input type of PipelineScheduleDelete""" id: ID! } """Autogenerated return type of PipelineScheduleDelete.""" type PipelineScheduleDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedPipelineScheduleID: ID! pipeline: Pipeline } type PipelineScheduleEdge { cursor: String! node: PipelineSchedule } """Permission information about what actions the current user can do against the pipeline schedule""" type PipelineSchedulePermissions { """Whether the user can delete the schedule""" pipelineScheduleDelete: Permission """Whether the user can update the schedule""" pipelineScheduleUpdate: Permission } """Autogenerated input type of PipelineScheduleUpdate""" input PipelineScheduleUpdateInput { """Autogenerated input type of PipelineScheduleUpdate""" clientMutationId: String """Autogenerated input type of PipelineScheduleUpdate""" id: ID! """Autogenerated input type of PipelineScheduleUpdate""" label: String """Autogenerated input type of PipelineScheduleUpdate""" cronline: String """Autogenerated input type of PipelineScheduleUpdate""" message: String """Autogenerated input type of PipelineScheduleUpdate""" commit: String """Autogenerated input type of PipelineScheduleUpdate""" branch: String """Autogenerated input type of PipelineScheduleUpdate""" env: String """Autogenerated input type of PipelineScheduleUpdate""" enabled: Boolean } """Autogenerated return type of PipelineScheduleUpdate.""" type PipelineScheduleUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipelineSchedule: PipelineSchedule! } """A Pipeline identifier using a slug, and optionally negated with a leading `!`""" scalar PipelineSelector """Steps defined on a pipeline""" type PipelineSteps { """A YAML representation of the pipeline steps""" yaml: YAML } """Step definition for a pipeline""" input PipelineStepsInput { """Step definition for a pipeline""" yaml: String! } """A tag associated with a pipeline""" type PipelineTag { """The label for this tag""" label: String! } """Tag associated with a pipeline""" input PipelineTagInput { """Tag associated with a pipeline""" label: String! } """Used to assign teams to pipelines""" input PipelineTeamAssignmentInput { """Used to assign teams to pipelines""" id: ID! """Used to assign teams to pipelines""" accessLevel: PipelineAccessLevels } """A template defining a fixed step configuration for a pipeline""" type PipelineTemplate implements Node{ """If the pipeline template is available for assignment by non admin users""" available: Boolean! """A YAML representation of the step configuration""" configuration: YAML! """The time when the template was created""" createdAt: DateTime! """The user who created the template""" createdBy: User! """The short description of the template""" description: String id: ID! """The name of the template""" name: String! """The last time the template was changed""" updatedAt: DateTime! """The user who last updated the template""" updatedBy: User! """The UUID for the template""" uuid: ID! } type PipelineTemplateConnection implements Connection{ count: Int! edges: [PipelineTemplateEdge] pageInfo: PageInfo } """Autogenerated input type of PipelineTemplateCreate""" input PipelineTemplateCreateInput { """Autogenerated input type of PipelineTemplateCreate""" clientMutationId: String """Autogenerated input type of PipelineTemplateCreate""" organizationId: ID! """Autogenerated input type of PipelineTemplateCreate""" name: String! """Autogenerated input type of PipelineTemplateCreate""" description: String """Autogenerated input type of PipelineTemplateCreate""" configuration: String! """Autogenerated input type of PipelineTemplateCreate""" available: Boolean } """Autogenerated return type of PipelineTemplateCreate.""" type PipelineTemplateCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipelineTemplate: PipelineTemplate! } """Autogenerated input type of PipelineTemplateDelete""" input PipelineTemplateDeleteInput { """Autogenerated input type of PipelineTemplateDelete""" clientMutationId: String """Autogenerated input type of PipelineTemplateDelete""" organizationId: ID! """Autogenerated input type of PipelineTemplateDelete""" id: ID! } """Autogenerated return type of PipelineTemplateDelete.""" type PipelineTemplateDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedPipelineTemplateId: ID! } type PipelineTemplateEdge { cursor: String! node: PipelineTemplate } """The different orders you can sort pipeline templates by""" enum PipelineTemplateOrder { """Order by name alphabetically""" NAME """Order by the most recently created pipeline templates first""" RECENTLY_CREATED } """Autogenerated input type of PipelineTemplateUpdate""" input PipelineTemplateUpdateInput { """Autogenerated input type of PipelineTemplateUpdate""" clientMutationId: String """Autogenerated input type of PipelineTemplateUpdate""" organizationId: ID! """Autogenerated input type of PipelineTemplateUpdate""" id: ID! """Autogenerated input type of PipelineTemplateUpdate""" name: String """Autogenerated input type of PipelineTemplateUpdate""" description: String """Autogenerated input type of PipelineTemplateUpdate""" configuration: String """Autogenerated input type of PipelineTemplateUpdate""" available: Boolean } """Autogenerated return type of PipelineTemplateUpdate.""" type PipelineTemplateUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipelineTemplate: PipelineTemplate! } """Autogenerated input type of PipelineUnarchive""" input PipelineUnarchiveInput { """Autogenerated input type of PipelineUnarchive""" clientMutationId: String """Autogenerated input type of PipelineUnarchive""" id: ID! } """Autogenerated return type of PipelineUnarchive.""" type PipelineUnarchivePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipeline: Pipeline! } """Autogenerated input type of PipelineUpdate""" input PipelineUpdateInput { """Autogenerated input type of PipelineUpdate""" clientMutationId: String """Autogenerated input type of PipelineUpdate""" id: ID! """Autogenerated input type of PipelineUpdate""" name: String """Autogenerated input type of PipelineUpdate""" description: String """Autogenerated input type of PipelineUpdate""" emoji: String """Autogenerated input type of PipelineUpdate""" color: String """Autogenerated input type of PipelineUpdate""" visibility: PipelineVisibility """Autogenerated input type of PipelineUpdate""" repository: PipelineRepositoryInput """Autogenerated input type of PipelineUpdate""" steps: PipelineStepsInput """Autogenerated input type of PipelineUpdate""" defaultBranch: String """Autogenerated input type of PipelineUpdate""" nextBuildNumber: Int """Autogenerated input type of PipelineUpdate""" skipIntermediateBuilds: Boolean """Autogenerated input type of PipelineUpdate""" skipIntermediateBuildsBranchFilter: String """Autogenerated input type of PipelineUpdate""" cancelIntermediateBuilds: Boolean """Autogenerated input type of PipelineUpdate""" cancelIntermediateBuildsBranchFilter: String """Autogenerated input type of PipelineUpdate""" allowRebuilds: Boolean """Autogenerated input type of PipelineUpdate""" defaultTimeoutInMinutes: Int """Autogenerated input type of PipelineUpdate""" maximumTimeoutInMinutes: Int """Autogenerated input type of PipelineUpdate""" clusterId: ID """Autogenerated input type of PipelineUpdate""" pipelineTemplateId: ID """Autogenerated input type of PipelineUpdate""" archived: Boolean """Autogenerated input type of PipelineUpdate""" tags: [PipelineTagInput!] """Autogenerated input type of PipelineUpdate""" branchConfiguration: String } """Autogenerated return type of PipelineUpdate.""" type PipelineUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipeline: Pipeline! } """The visibility of the pipeline""" enum PipelineVisibility { """The pipeline is public""" PUBLIC """The pipeline is private""" PRIVATE } """A pull request on a provider""" type PullRequest { id: String! } """The query root for this schema""" type Query { """Find an agent by its slug""" agent( """The UUID for the agent, prefixed by its organization's slug i.e. `acme-inc/0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" slug: ID! ): Agent """Find an agent token by its slug""" agentToken( """The UUID for the agent token, prefixed by its organization's slug i.e. `acme-inc/0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" slug: ID! ): AgentToken """Find a API Access Token code""" apiAccessTokenCode( """The code provided by the Auth API""" code: ID! ): APIAccessTokenCode """Find an artifact by its UUID""" artifact( uuid: ID! ): Artifact """Find an audit event via its uuid""" auditEvent( """The UUID for the audit event i.e. `0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" uuid: ID! ): AuditEvent """Find a build""" build( """The number of the build, prefixed with its organization and pipeline. i.e. `acme-inc/my-pipeline/123`""" slug: ID """The UUID of the build""" uuid: ID ): Build """Find a GraphQL snippet""" graphQLSnippet( """The UUID for this GraphQL snippet""" uuid: String! ): GraphQLSnippet """Find a build job""" job( uuid: ID! ): Job """Fetches an object given its ID.""" node( """ID of the object.""" id: ID! ): Node """Find a notification service via its UUID""" notificationService( """The UUID for the notification service i.e. `0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" uuid: ID! ): NotificationService """Find an organization""" organization( """The slug of the organization""" slug: ID """The UUID of the organization""" uuid: ID ): Organization """Find an organization invitation via its slug""" organizationInvitation( """The UUID for the invitation, prefixed by its organization's slug i.e. `acme-inc/0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" slug: ID! ): OrganizationInvitation """Find an organization membership via its slug""" organizationMember( """The UUID for the membership, prefixed by its organization's slug i.e. `acme-inc/0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" slug: ID! ): OrganizationMember """Find a pipeline""" pipeline( """The slug of the pipeline, prefixed with its organization. i.e. `acme-inc/my-pipeline`""" slug: ID """The UUID of the pipeline""" uuid: ID ): Pipeline """Find a pipeline schedule by its slug""" pipelineSchedule( """The UUID for the pipeline schedule, prefixed by its organization and pipeline's slug i.e. `acme-inc/my-pipeline/0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" slug: ID! ): PipelineSchedule """Find a pipeline template""" pipelineTemplate( """The UUID of the pipeline template""" uuid: ID! ): PipelineTemplate """Find a secret via its uuid. This does not contain the value of the secret or encrypted material.""" secret( """The UUID for the secret i.e. `0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" uuid: ID! ): Secret """Find an sso provider either using it's slug, or UUID""" ssoProvider( """The slug for the sso provider, prefixed by its organization's slug i.e. `acme-inc/0bd5ea7c-89b3-4f40-8ca3-ffac805771eb`""" slug: ID """The UUID of the sso provider""" uuid: ID ): SSOProvider """Find a team""" team( """The slug of the team, prefixed with its organization. i.e. `acme-inc/awesome-team`""" slug: ID! ): Team """Context of the current user using the GraphQL API""" viewer: Viewer } """A recovery code""" type RecoveryCode { """The recovery code.""" code: String! """Whether the recovery codes is used""" consumed: Boolean! """Foo""" consumedAt: String } """A batch of recovery codes""" type RecoveryCodeBatch { """Whether the batch of recovery codes is active""" active: Boolean! """The recovery codes from this batch. Codes are consumed when used, and codes will be included in this list whether consumed or not""" codes: [RecoveryCode!]! id: ID! } """A repository associated with a pipeline""" type Repository { """The repository’s provider""" provider: RepositoryProvider """The git URL for this repository""" url: String! } interface RepositoryProvider { name: String! url: String webhookUrl: String } """A pipeline's repository is being provided by Beanstalk""" type RepositoryProviderBeanstalk implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by Bitbucket""" type RepositoryProviderBitbucket implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by Bitbucket Server""" type RepositoryProviderBitbucketServer implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by Codebase""" type RepositoryProviderCodebase implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by GitHub""" type RepositoryProviderGithub implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by GitHub Enterprise""" type RepositoryProviderGithubEnterprise implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by GitLab""" type RepositoryProviderGitlab implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by GitLab Community Edition""" type RepositoryProviderGitlabCommunity implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by GitLab Enterprise Edition""" type RepositoryProviderGitlabEnterprise implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """A pipeline's repository is being provided by a service unknown to Buildkite""" type RepositoryProviderUnknown implements RepositoryProvider{ """The name of the provider""" name: String! """This URL to the provider’s web interface""" url: String """The URL to use when setting up webhooks from the provider to trigger Buildkite builds""" webhookUrl: String } """An aggregate of resource usage, grouped by day and resource.""" interface ResourceUsageInterface { """An aggregate of resource usage, grouped by day and resource.""" aggregatedOn: ISO8601Date! } """All types of billable resources""" enum ResourceUsageType { """These records represent a pipeline's job minutes usage for a single day""" JOB_MINUTES """These records represent a suite's test executions usage for a single day""" TEST_EXECUTIONS } """API tokens with access to this organization will be automatically revoked after this many days of inactivity.""" enum RevokeInactiveTokenPeriod { """Revoke organization access from API tokens after 30 days of inactivity""" DAYS_30 """Revoke organization access from API tokens after 60 days of inactivity""" DAYS_60 """Revoke organization access from API tokens after 90 days of inactivity""" DAYS_90 """Revoke organization access from API tokens after 180 days of inactivity""" DAYS_180 """Revoke organization access from API tokens after 365 days of inactivity""" DAYS_365 """Never revoke organization access from inactive API tokens""" NEVER } type SCMPipelineSettings { id: ID! } type SCMRepositoryHost { id: ID! } type SCMService { id: ID! } type SSOAuthorization { """The time when this SSO Authorization was created""" createdAt: DateTime! """The time when this SSO Authorization was expired""" expiredAt: DateTime id: ID! """Details around the identity provided by the SSO provider""" identity: SSOAuthorizationIdentity """The time when this SSO Authorization was manually revoked""" revokedAt: DateTime """The SSO provider associated with this authorization""" ssoProvider: SSOProvider! """The current state of the SSO Authorization""" state: SSOAuthorizationState! """The user associated with this authorization""" user: User """The time when this SSO Authorization was destroyed because the user logged out""" userSessionDestroyedAt: DateTime """The public UUID for this SSO authorization""" uuid: String! } type SSOAuthorizationConnection implements Connection{ count: Int! edges: [SSOAuthorizationEdge] pageInfo: PageInfo } type SSOAuthorizationEdge { cursor: String! node: SSOAuthorization } type SSOAuthorizationIdentity { """The avatar URL provided in this identity""" avatarURL: String """The email addresses provided in this identity""" email: String """The name provided in this identity""" name: String """The identifier provided in this identity""" uid: String } """All the possible states an SSO Authorization""" enum SSOAuthorizationState { """The authorization has been verified and is in use""" VERIFIED """The authorization was verified but has since been destroyed as the user logged out of that session""" VERIFIED_USER_SESSION_DESTROYED """The authorization was verified but has since been manually revoked""" VERIFIED_REVOKED """The authorization was verified but has since expired""" VERIFIED_EXPIRED } interface SSOProvider { createdAt: DateTime! createdBy: User! disabledAt: DateTime disabledBy: User disabledReason: String emailDomain: String emailDomainVerificationAddress: String emailDomainVerifiedAt: DateTime enabledAt: DateTime enabledBy: User id: ID! note: String organization: Organization pinSessionToIpAddress: Boolean sessionDurationInHours: Int state: SSOProviderStates! testAuthorizationRequired: Boolean type: SSOProviderTypes! url: String! uuid: ID! } type SSOProviderConnection implements Connection{ count: Int! edges: [SSOProviderEdge] pageInfo: PageInfo } """Autogenerated input type of SSOProviderCreate""" input SSOProviderCreateInput { """Autogenerated input type of SSOProviderCreate""" clientMutationId: String """Autogenerated input type of SSOProviderCreate""" organizationId: ID! """Autogenerated input type of SSOProviderCreate""" type: SSOProviderTypes! """Autogenerated input type of SSOProviderCreate""" note: String """Autogenerated input type of SSOProviderCreate""" sessionDurationInHours: Int """Autogenerated input type of SSOProviderCreate""" pinSessionToIpAddress: Boolean """Autogenerated input type of SSOProviderCreate""" emailDomain: String """Autogenerated input type of SSOProviderCreate""" emailDomainVerificationAddress: String """Autogenerated input type of SSOProviderCreate""" identityProvider: SSOProviderSAMLIdP """Autogenerated input type of SSOProviderCreate""" digestMethod: SSOProviderSAMLXMLSecurity """Autogenerated input type of SSOProviderCreate""" signatureMethod: SSOProviderSAMLRSAXMLSecurity """Autogenerated input type of SSOProviderCreate""" githubOrganizationName: String """Autogenerated input type of SSOProviderCreate""" googleHostedDomain: String """Autogenerated input type of SSOProviderCreate""" discloseGoogleHostedDomain: Boolean } """Autogenerated return type of SSOProviderCreate.""" type SSOProviderCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organization: Organization! ssoProvider: SSOProvider! ssoProviderEdge: SSOProviderEdge! } """Autogenerated input type of SSOProviderDelete""" input SSOProviderDeleteInput { """Autogenerated input type of SSOProviderDelete""" clientMutationId: String """Autogenerated input type of SSOProviderDelete""" id: ID! } """Autogenerated return type of SSOProviderDelete.""" type SSOProviderDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedSSOProviderId: ID! organization: Organization! } """Autogenerated input type of SSOProviderDisable""" input SSOProviderDisableInput { """Autogenerated input type of SSOProviderDisable""" clientMutationId: String """Autogenerated input type of SSOProviderDisable""" id: ID! """Autogenerated input type of SSOProviderDisable""" disabledReason: String } """Autogenerated return type of SSOProviderDisable.""" type SSOProviderDisablePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String ssoProvider: SSOProvider! } type SSOProviderEdge { cursor: String! node: SSOProvider } """Autogenerated input type of SSOProviderEnable""" input SSOProviderEnableInput { """Autogenerated input type of SSOProviderEnable""" clientMutationId: String """Autogenerated input type of SSOProviderEnable""" id: ID! } """Autogenerated return type of SSOProviderEnable.""" type SSOProviderEnablePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String ssoProvider: SSOProvider! } """Single sign-on provided by GitHub""" type SSOProviderGitHubApp implements Node & SSOProvider{ """The time when this SSO Provider was created""" createdAt: DateTime! """The user that created this SSO Provider""" createdBy: User! """The time when this SSO Provider was disabled""" disabledAt: DateTime """The user that disabled this SSO Provider""" disabledBy: User """The reason this SSO Provider was disabled""" disabledReason: String """An email domain whose addresses should be offered this SSO Provider during login.""" emailDomain: String emailDomainVerificationAddress: String emailDomainVerifiedAt: DateTime """The time when this SSO Provider was enabled""" enabledAt: DateTime """The user that enabled this SSO Provider""" enabledBy: User """The name of the organization on GitHub that the user must be in for an SSO authorization to be verified""" githubOrganizationName: String! id: ID! """An extra message that can be added the Authorization screen of an SSO Provider""" note: String organization: Organization """Defaults to false. If true, users are required to re-authenticate when their IP address changes.""" pinSessionToIpAddress: Boolean """How long a session should last before requiring re-authorization. A `null` value indicates an infinite session.""" sessionDurationInHours: Int """The current state of the SSO Provider""" state: SSOProviderStates! """Whether the SSO Provider requires a test authorization. If true, the provider can not yet be activated.""" testAuthorizationRequired: Boolean """The type of SSO Provider""" type: SSOProviderTypes! """The authorization URL for this SSO Provider""" url: String! """The UUID for this SSO Provider""" uuid: ID! } """Single sign-on provided by Google""" type SSOProviderGoogleGSuite implements Node & SSOProvider{ """The time when this SSO Provider was created""" createdAt: DateTime! """The user that created this SSO Provider""" createdBy: User! """The time when this SSO Provider was disabled""" disabledAt: DateTime """The user that disabled this SSO Provider""" disabledBy: User """The reason this SSO Provider was disabled""" disabledReason: String """Whether or not the hosted domain should be presented to the user during SSO""" discloseGoogleHostedDomain: Boolean! """An email domain whose addresses should be offered this SSO Provider during login.""" emailDomain: String emailDomainVerificationAddress: String emailDomainVerifiedAt: DateTime """The time when this SSO Provider was enabled""" enabledAt: DateTime """The user that enabled this SSO Provider""" enabledBy: User """The Google hosted domain that is required to be present in OAuth""" googleHostedDomain: String! id: ID! """An extra message that can be added the Authorization screen of an SSO Provider""" note: String organization: Organization """Defaults to false. If true, users are required to re-authenticate when their IP address changes.""" pinSessionToIpAddress: Boolean """How long a session should last before requiring re-authorization. A `null` value indicates an infinite session.""" sessionDurationInHours: Int """The current state of the SSO Provider""" state: SSOProviderStates! """Whether the SSO Provider requires a test authorization. If true, the provider can not yet be activated.""" testAuthorizationRequired: Boolean """The type of SSO Provider""" type: SSOProviderTypes! """The authorization URL for this SSO Provider""" url: String! """The UUID for this SSO Provider""" uuid: ID! } """Single sign-on provided via SAML""" type SSOProviderSAML implements Node & SSOProvider{ """The time when this SSO Provider was created""" createdAt: DateTime! """The user that created this SSO Provider""" createdBy: User! """The algorithm used to calculate the digest value during a SAML exchange""" digestMethod: SSOProviderSAMLXMLSecurity! """The time when this SSO Provider was disabled""" disabledAt: DateTime """The user that disabled this SSO Provider""" disabledBy: User """The reason this SSO Provider was disabled""" disabledReason: String """An email domain whose addresses should be offered this SSO Provider during login.""" emailDomain: String emailDomainVerificationAddress: String emailDomainVerifiedAt: DateTime """The time when this SSO Provider was enabled""" enabledAt: DateTime """The user that enabled this SSO Provider""" enabledBy: User id: ID! """Information about the IdP""" identityProvider: SSOProviderSAMLIdPType """An extra message that can be added the Authorization screen of an SSO Provider""" note: String organization: Organization """Defaults to false. If true, users are required to re-authenticate when their IP address changes.""" pinSessionToIpAddress: Boolean serviceProvider: SSOProviderSAMLSPType! """How long a session should last before requiring re-authorization. A `null` value indicates an infinite session.""" sessionDurationInHours: Int """The algorithm used to calculate the signature value during a SAML exchange""" signatureMethod: SSOProviderSAMLRSAXMLSecurity! """The current state of the SSO Provider""" state: SSOProviderStates! """Whether the SSO Provider requires a test authorization. If true, the provider can not yet be activated.""" testAuthorizationRequired: Boolean """The type of SSO Provider""" type: SSOProviderTypes! """The authorization URL for this SSO Provider""" url: String! """The UUID for this SSO Provider""" uuid: ID! } input SSOProviderSAMLIdP { issuer: String ssoURL: String certificate: String metadata: SSOProviderSAMLIdPMetadata } input SSOProviderSAMLIdPMetadata { xml: XML url: String } """Information about the IdP for a SAML SSO Provider""" type SSOProviderSAMLIdPType { """The certificated provided by the IdP""" certificate: String """The IdP Issuer value for this SSO Provider""" issuer: String """The metadata used to configure this SSO provider if it was provided""" metadata: SSOProviderSAMLMetadataType """The name of the IdP Service. Returns nil if no name can be guessed from the SSO URL""" name: String """The IdP SSO URL for this SSO Provider""" ssoURL: String } """SAML metadata used for configuration""" type SSOProviderSAMLMetadataType { """The URL that this metadata can be publicly accessed at""" url: String """The XML for this metadata""" xml: XML } """XML RSA security algorithms used in the SAML exchange""" enum SSOProviderSAMLRSAXMLSecurity { """http://www.w3.org/2000/09/xmldsig#rsa-sha1""" RSA_SHA1 """http://www.w3.org/2001/04/xmldsig-more#rsa-sha256""" RSA_SHA256 """http://www.w3.org/2001/04/xmldsig-more#rsa-sha384""" RSA_SHA384 """http://www.w3.org/2001/04/xmldsig-more#rsa-sha512""" RSA_SHA512 } """Information about Buildkite as a SAML Service Provider""" type SSOProviderSAMLSPType { """The IdP Issuer value for this SSO Provider""" issuer: String """The metadata used to configure this SSO provider if it was provided""" metadata: SSOProviderSAMLMetadataType """The IdP SSO URL for this SSO Provider""" ssoURL: String } """XML security algorithms used in the SAML exchange""" enum SSOProviderSAMLXMLSecurity { """http://www.w3.org/2000/09/xmldsig#sha1""" SHA1 """http://www.w3.org/2001/04/xmlenc#sha256""" SHA256 """http://www.w3.org/2001/04/xmldsig-more#sha384""" SHA384 """http://www.w3.org/2001/04/xmlenc#sha512""" SHA512 } """All the possible states an SSO Provider can be in""" enum SSOProviderStates { """The SSO Provider has been created, but has not been enabled for use yet""" CREATED """The SSO Provider has been setup correctly and can be used by users""" ENABLED """The SSO Provider has been disabled and can't be used directly""" DISABLED } """All the possible SSO Provider types""" enum SSOProviderTypes { """An SSO Provider configured to use SAML""" SAML """A SSO Provider configured to use Google G Suite for authorization""" GOOGLE_GSUITE """A SSO Provider configured to use a GitHub App for authorization""" GITHUB_APP } """Autogenerated input type of SSOProviderUpdate""" input SSOProviderUpdateInput { """Autogenerated input type of SSOProviderUpdate""" clientMutationId: String """Autogenerated input type of SSOProviderUpdate""" id: ID! """Autogenerated input type of SSOProviderUpdate""" note: String """Autogenerated input type of SSOProviderUpdate""" sessionDurationInHours: Int """Autogenerated input type of SSOProviderUpdate""" pinSessionToIpAddress: Boolean """Autogenerated input type of SSOProviderUpdate""" emailDomain: String """Autogenerated input type of SSOProviderUpdate""" emailDomainVerificationAddress: String """Autogenerated input type of SSOProviderUpdate""" identityProvider: SSOProviderSAMLIdP """Autogenerated input type of SSOProviderUpdate""" digestMethod: SSOProviderSAMLXMLSecurity """Autogenerated input type of SSOProviderUpdate""" signatureMethod: SSOProviderSAMLRSAXMLSecurity """Autogenerated input type of SSOProviderUpdate""" githubOrganizationName: String """Autogenerated input type of SSOProviderUpdate""" googleHostedDomain: String """Autogenerated input type of SSOProviderUpdate""" discloseGoogleHostedDomain: Boolean } """Autogenerated return type of SSOProviderUpdate.""" type SSOProviderUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String ssoProvider: SSOProvider! } """A secret hosted by Buildkite. This does not contain the secret value or encrypted material.""" type Secret implements Node{ """The cluster that the secret belongs to""" cluster: Cluster """The time this secret was created""" createdAt: DateTime """A description about what this secret is used for""" description: String """The time this secret was destroyed""" destroyedAt: DateTime id: ID! """The key value used to name the secret""" key: String! """The organization that the secret belongs to""" organization: Organization! """The time this secret was updated""" updatedAt: DateTime """The public UUID for the secret""" uuid: ID! } interface Step { conditional: String dependencies: DependencyConnection key: String uuid: String! } """A step in a build that runs a command on an agent""" type StepCommand implements Step{ """The conditional evaluated for this step""" conditional: String """Dependencies of this job""" dependencies( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String ): DependencyConnection """The user-defined key for this step""" key: String """The UUID for this step""" uuid: String! } """An input step collects information from a user""" type StepInput implements Step{ """The conditional evaluated for this step""" conditional: String """Dependencies of this job""" dependencies( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String ): DependencyConnection """The user-defined key for this step""" key: String """The UUID for this step""" uuid: String! } """A trigger step creates a build on another pipeline""" type StepTrigger implements Step{ """The conditional evaluated for this step""" conditional: String """Dependencies of this job""" dependencies( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String ): DependencyConnection """The user-defined key for this step""" key: String """The UUID for this step""" uuid: String! } """A wait step waits for all previous steps to have successfully completed before allowing following jobs to continue""" type StepWait implements Step{ """The conditional evaluated for this step""" conditional: String """Dependencies of this job""" dependencies( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String ): DependencyConnection """The user-defined key for this step""" key: String """The UUID for this step""" uuid: String! } """Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.""" scalar String type Subscription { id: ID! } """A suite""" type Suite implements Node{ """The application name for the suite""" applicationName: String """The hex code for the suite navatar background color in the Test Suites page""" color: String """The time when the suite was created""" createdAt: DateTime """The default branch for this suite""" defaultBranch: String """The emoji that will display as a suite navatar in the Test Suites page""" emoji: String id: ID! """The name of the suite""" name: String! organization: Organization! """The slug of the suite""" slug: String! """Teams associated with this suite""" teams( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String """Search for teams associated that this suite is assigned to""" search: String """Order the suites returned""" order: TeamSuiteOrder ): TeamSuiteConnection """The URL for the suite""" url: String! uuid: String! } """The access levels that can be assigned to a suite""" enum SuiteAccessLevels { """Allows edits and reads""" MANAGE_AND_READ """Read only""" READ_ONLY } type SuiteConnection implements Connection{ count: Int! edges: [SuiteEdge] pageInfo: PageInfo } type SuiteEdge { cursor: String! node: Suite } """The different orders you can sort suites by""" enum SuiteOrders { """Order by name alphabetically""" NAME """Order by the most recently created suites first""" RECENTLY_CREATED """Order by relevance when searching for suites""" RELEVANCE } """A TOTP configuration""" type TOTP { id: ID! """The recovery code batch associated with this TOTP configuration""" recoveryCodes: RecoveryCodeBatch! """Whether the TOTP configuration has been verified yet""" verified: Boolean! } """Autogenerated input type of TOTPActivate""" input TOTPActivateInput { """Autogenerated input type of TOTPActivate""" clientMutationId: String """Autogenerated input type of TOTPActivate""" id: ID! """Autogenerated input type of TOTPActivate""" token: String! } """Autogenerated return type of TOTPActivate.""" type TOTPActivatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String totp: TOTP! viewer: Viewer! } """Autogenerated input type of TOTPCreate""" input TOTPCreateInput { """Autogenerated input type of TOTPCreate""" clientMutationId: String } """Autogenerated return type of TOTPCreate.""" type TOTPCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String """The URI to enter into your one-time password generator. Usually presented to the user as a QR Code""" provisioningUri: String! totp: TOTP! } """Autogenerated input type of TOTPDelete""" input TOTPDeleteInput { """Autogenerated input type of TOTPDelete""" clientMutationId: String """Autogenerated input type of TOTPDelete""" id: ID! } """Autogenerated return type of TOTPDelete.""" type TOTPDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String viewer: Viewer! } """Autogenerated input type of TOTPRecoveryCodesRegenerate""" input TOTPRecoveryCodesRegenerateInput { """Autogenerated input type of TOTPRecoveryCodesRegenerate""" clientMutationId: String """Autogenerated input type of TOTPRecoveryCodesRegenerate""" totpId: ID! } """Autogenerated return type of TOTPRecoveryCodesRegenerate.""" type TOTPRecoveryCodesRegeneratePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String recoveryCodes: RecoveryCodeBatch! totp: TOTP! } """An organization team""" type Team implements Node{ """The time when this team was created""" createdAt: DateTime! """The user that created this team""" createdBy: User """New organization members will be granted this role on this team""" defaultMemberRole: TeamMemberRole! """A description of the team""" description: String id: ID! """Add new organization members to this team by default""" isDefaultTeam: Boolean! """Users that are part of this team""" members( first: Int after: String last: Int before: String """Search team members named like the given query case insensitively""" search: String """Search team members by their role""" role: [TeamMemberRole!] """Order the members returned""" order: TeamMemberOrder ): TeamMemberConnection """Whether or not team members can create new pipelines in this team""" membersCanCreatePipelines: Boolean! """Whether or not team members can delete pipelines in this team""" membersCanDeletePipelines: Boolean! """The name of the team""" name: String! """The organization that this team is a part of""" organization: Organization permissions: TeamPermissions! """Pipelines associated with this team""" pipelines( first: Int after: String last: Int before: String """Search pipelines named like the given query case insensitively""" search: String """Order the pipelines returned""" order: TeamPipelineOrder ): TeamPipelineConnection """The privacy setting for this team""" privacy: TeamPrivacy! """The slug of the team""" slug: String! """Suites associated with this team""" suites( first: Int after: String last: Int before: String """Order the suites returned""" order: TeamSuiteOrder ): TeamSuiteConnection """The public UUID for this team""" uuid: ID! } type TeamConnection implements Connection{ count: Int! edges: [TeamEdge] pageInfo: PageInfo } """Autogenerated input type of TeamCreate""" input TeamCreateInput { """Autogenerated input type of TeamCreate""" clientMutationId: String """Autogenerated input type of TeamCreate""" organizationID: ID! """Autogenerated input type of TeamCreate""" name: String! """Autogenerated input type of TeamCreate""" description: String """Autogenerated input type of TeamCreate""" privacy: TeamPrivacy! """Autogenerated input type of TeamCreate""" isDefaultTeam: Boolean! """Autogenerated input type of TeamCreate""" defaultMemberRole: TeamMemberRole! """Autogenerated input type of TeamCreate""" membersCanCreatePipelines: Boolean """Autogenerated input type of TeamCreate""" membersCanDeletePipelines: Boolean } """Autogenerated return type of TeamCreate.""" type TeamCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String organization: Organization! teamEdge: TeamEdge! } """Autogenerated input type of TeamDelete""" input TeamDeleteInput { """Autogenerated input type of TeamDelete""" clientMutationId: String """Autogenerated input type of TeamDelete""" id: ID! } """Autogenerated return type of TeamDelete.""" type TeamDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedTeamID: ID! organization: Organization! } type TeamEdge { cursor: String! node: Team } """An member of a team""" type TeamMember implements Node{ """The time when the team member was added""" createdAt: DateTime! """The user that added this team member""" createdBy: User id: ID! """The organization member associated with this team member""" organizationMember: OrganizationMember permissions: TeamMemberPermissions! """The users role within the team""" role: TeamMemberRole! """The team associated with this team member""" team: Team """The user associated with this team member""" user: User """The public UUID for this team member""" uuid: ID! } type TeamMemberConnection implements Connection{ count: Int! edges: [TeamMemberEdge] pageInfo: PageInfo } """Autogenerated input type of TeamMemberCreate""" input TeamMemberCreateInput { """Autogenerated input type of TeamMemberCreate""" clientMutationId: String """Autogenerated input type of TeamMemberCreate""" teamID: ID! """Autogenerated input type of TeamMemberCreate""" userID: ID! """Autogenerated input type of TeamMemberCreate""" role: TeamMemberRole } """Autogenerated return type of TeamMemberCreate.""" type TeamMemberCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String team: Team teamMemberEdge: TeamMemberEdge } """Autogenerated input type of TeamMemberDelete""" input TeamMemberDeleteInput { """Autogenerated input type of TeamMemberDelete""" clientMutationId: String """Autogenerated input type of TeamMemberDelete""" id: ID! } """Autogenerated return type of TeamMemberDelete.""" type TeamMemberDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedTeamMemberID: ID! team: Team } type TeamMemberEdge { cursor: String! node: TeamMember } """The different orders you can sort team members by""" enum TeamMemberOrder { """Order by name alphabetically""" NAME """Order by most relevant results when doing a search""" RELEVANCE """Order by the most recently added members first""" RECENTLY_CREATED } """Permissions information about what actions the current user can do against the team membership record""" type TeamMemberPermissions { """Whether the user can delete the user from the team""" teamMemberDelete: Permission """Whether the user can update the team's members admin status""" teamMemberUpdate: Permission } """The roles a user can be within a team""" enum TeamMemberRole { """The user is a regular member of the team""" MEMBER """The user can manage pipelines and users within the team""" MAINTAINER } """Autogenerated input type of TeamMemberUpdate""" input TeamMemberUpdateInput { """Autogenerated input type of TeamMemberUpdate""" clientMutationId: String """Autogenerated input type of TeamMemberUpdate""" id: ID! """Autogenerated input type of TeamMemberUpdate""" role: TeamMemberRole! } """Autogenerated return type of TeamMemberUpdate.""" type TeamMemberUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String teamMember: TeamMember! } """The different orders you can sort teams by""" enum TeamOrder { """Order by name alphabetically""" NAME """Order by the most recently created teams first""" RECENTLY_CREATED """Order by relevance when searching for teams""" RELEVANCE } """Permissions information about what actions the current user can do against the team""" type TeamPermissions { """Whether the user can see the pipelines within the team""" pipelineView: Permission """Whether the user can delete the team""" teamDelete: Permission """Whether the user can administer add members from the organization to this team""" teamMemberCreate: Permission """Whether the user can add pipelines from other teams to this one""" teamPipelineCreate: Permission """Whether the user can add suites from other teams to this one""" teamSuiteCreate: Permission """Whether the user can update the team's name and description""" teamUpdate: Permission } """An pipeline that's been assigned to a team""" type TeamPipeline implements Node{ """The access level users have to this pipeline""" accessLevel: PipelineAccessLevels! """The time when the pipeline was added""" createdAt: DateTime! """The user that added this pipeline to the team""" createdBy: User id: ID! permissions: TeamPipelinePermissions! """The pipeline associated with this team member""" pipeline: Pipeline """The team associated with this team member""" team: Team """The public UUID for this team member""" uuid: ID! } """A collection of TeamPipeline records""" type TeamPipelineConnection implements Connection{ count: Int! edges: [TeamPipelineEdge] pageInfo: PageInfo } """Autogenerated input type of TeamPipelineCreate""" input TeamPipelineCreateInput { """Autogenerated input type of TeamPipelineCreate""" clientMutationId: String """Autogenerated input type of TeamPipelineCreate""" teamID: ID! """Autogenerated input type of TeamPipelineCreate""" pipelineID: ID! """Autogenerated input type of TeamPipelineCreate""" accessLevel: PipelineAccessLevels } """Autogenerated return type of TeamPipelineCreate.""" type TeamPipelineCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String pipeline: Pipeline team: Team teamPipeline: TeamPipeline teamPipelineEdge: TeamPipelineEdge } """Autogenerated input type of TeamPipelineDelete""" input TeamPipelineDeleteInput { """Autogenerated input type of TeamPipelineDelete""" clientMutationId: String """Autogenerated input type of TeamPipelineDelete""" id: ID! """Autogenerated input type of TeamPipelineDelete""" force: Boolean } """Autogenerated return type of TeamPipelineDelete.""" type TeamPipelineDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedTeamPipelineID: ID! team: Team } type TeamPipelineEdge { cursor: String! node: TeamPipeline } """The different orders you can sort pipelines by""" enum TeamPipelineOrder { """Order by name alphabetically""" NAME """Order by most relevant results when doing a search""" RELEVANCE """Order by the most recently added pipelines first""" RECENTLY_CREATED } """Permission information about what actions the current user can do against the team pipelines""" type TeamPipelinePermissions { """Whether the user can delete the pipeline from the team""" teamPipelineDelete: Permission """Whether the user can update the pipeline connection to the team""" teamPipelineUpdate: Permission } """Autogenerated input type of TeamPipelineUpdate""" input TeamPipelineUpdateInput { """Autogenerated input type of TeamPipelineUpdate""" clientMutationId: String """Autogenerated input type of TeamPipelineUpdate""" id: ID! """Autogenerated input type of TeamPipelineUpdate""" accessLevel: PipelineAccessLevels! } """Autogenerated return type of TeamPipelineUpdate.""" type TeamPipelineUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String teamPipeline: TeamPipeline! } """Whether a team is visible or secret within an organization""" enum TeamPrivacy { """Visible to all members of the organization""" VISIBLE """Visible to organization administrators and members""" SECRET } """A Team identifier using a slug, and optionally negated with a leading `!`""" scalar TeamSelector """A suite that's been assigned to a team""" type TeamSuite implements Node{ """The access level users have to this suite""" accessLevel: SuiteAccessLevels! """The time when the suite was added""" createdAt: DateTime! """The user that added this suite to the team""" createdBy: User id: ID! permissions: TeamSuitePermissions! """The suite associated with this team member""" suite: Suite """The team associated with this team member""" team: Team """The public UUID for this team suite""" uuid: String! } """A collection of TeamSuite records""" type TeamSuiteConnection implements Connection{ count: Int! edges: [TeamSuiteEdge] pageInfo: PageInfo } """Autogenerated input type of TeamSuiteCreate""" input TeamSuiteCreateInput { """Autogenerated input type of TeamSuiteCreate""" clientMutationId: String """Autogenerated input type of TeamSuiteCreate""" teamID: ID! """Autogenerated input type of TeamSuiteCreate""" suiteID: ID! """Autogenerated input type of TeamSuiteCreate""" accessLevel: SuiteAccessLevels } """Autogenerated return type of TeamSuiteCreate.""" type TeamSuiteCreatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String suite: Suite team: Team teamSuite: TeamSuite teamSuiteEdge: TeamSuiteEdge } """Autogenerated input type of TeamSuiteDelete""" input TeamSuiteDeleteInput { """Autogenerated input type of TeamSuiteDelete""" clientMutationId: String """Autogenerated input type of TeamSuiteDelete""" id: ID! """Autogenerated input type of TeamSuiteDelete""" force: Boolean } """Autogenerated return type of TeamSuiteDelete.""" type TeamSuiteDeletePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String deletedTeamSuiteID: ID! team: Team } type TeamSuiteEdge { cursor: String! node: TeamSuite } """The different orders you can sort suites by""" enum TeamSuiteOrder { """Order by name alphabetically""" NAME """Order by most relevant results when doing a search""" RELEVANCE """Order by the most recently added suites first""" RECENTLY_CREATED } """Permission information about what actions the current user can do against the team suites""" type TeamSuitePermissions { """Whether the user can delete the suite from the team""" teamSuiteDelete: Permission """Whether the user can update the suite connection to the team""" teamSuiteUpdate: Permission } """Autogenerated input type of TeamSuiteUpdate""" input TeamSuiteUpdateInput { """Autogenerated input type of TeamSuiteUpdate""" clientMutationId: String """Autogenerated input type of TeamSuiteUpdate""" id: ID! """Autogenerated input type of TeamSuiteUpdate""" accessLevel: SuiteAccessLevels! } """Autogenerated return type of TeamSuiteUpdate.""" type TeamSuiteUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String teamSuite: TeamSuite! } """Autogenerated input type of TeamUpdate""" input TeamUpdateInput { """Autogenerated input type of TeamUpdate""" clientMutationId: String """Autogenerated input type of TeamUpdate""" id: ID! """Autogenerated input type of TeamUpdate""" name: String! """Autogenerated input type of TeamUpdate""" description: String """Autogenerated input type of TeamUpdate""" privacy: TeamPrivacy """Autogenerated input type of TeamUpdate""" isDefaultTeam: Boolean! """Autogenerated input type of TeamUpdate""" defaultMemberRole: TeamMemberRole! """Autogenerated input type of TeamUpdate""" membersCanCreatePipelines: Boolean """Autogenerated input type of TeamUpdate""" membersCanDeletePipelines: Boolean } """Autogenerated return type of TeamUpdate.""" type TeamUpdatePayload { """A unique identifier for the client performing the mutation.""" clientMutationId: String team: Team! } """A record of test executions usage, aggregated by day and test suite.""" type TestExecutionsUsage implements ResourceUsageInterface{ aggregatedOn: ISO8601Date! """The recorded usage.""" executions: Int! suite: Suite suiteId: ID! } """A person who hasn’t signed up to Buildkite""" type UnregisteredUser { avatar: Avatar! """The email for the user""" email: String """The name of the user""" name: String } """The possible resource usage types""" union UsageUnion =JobMinutesUsage | TestExecutionsUsage """The connection type for UsageUnion.""" type UsageUnionConnection { """A list of edges.""" edges: [UsageUnionEdge] """A list of nodes.""" nodes: [UsageUnion] """Information to aid in pagination.""" pageInfo: PageInfo! } """An edge in a connection.""" type UsageUnionEdge { """A cursor for use in pagination.""" cursor: String! """The item at the end of the edge.""" node: UsageUnion } """A user""" type User implements Node{ avatar: Avatar! """If this user account is an official bot managed by Buildkite""" bot: Boolean! """Returns builds that this user has created.""" builds( first: Int last: Int state: [BuildStates!] branch: [String!] metaData: [String!] ): BuildConnection """The primary email for the user""" email: String! """Does the user have a password set""" hasPassword: Boolean! id: ID! """The name of the user""" name: String! """The public UUID of the user""" uuid: String! } """A User identifier using a UUID, and optionally negated with a leading `!`""" scalar UserSelector """Represents the current user session""" type Viewer implements Node{ authorizations( """Returns the first _n_ elements from the list.""" first: Int """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the last _n_ elements from the list.""" last: Int """Returns the elements in the list that come before the specified cursor.""" before: String type: [AuthorizationType!] ): AuthorizationConnection builds( first: Int last: Int state: [BuildStates!] branch: String metaData: [String!] ): BuildConnection changelogs( first: Int last: Int read: Boolean ): ChangelogConnection """Emails associated with the current user""" emails( """Returns the elements in the list that come after the specified cursor.""" after: String """Returns the elements in the list that come before the specified cursor.""" before: String """Returns the first _n_ elements from the list.""" first: Int """Returns the last _n_ elements from the list.""" last: Int """Filter by whether the email is verified or not""" verified: Boolean ): EmailConnection """The ID of the current user""" id: ID! jobs( first: Int after: String last: Int before: String type: [JobTypes!] state: [JobStates!] priority: JobPrioritySearch agentQueryRules: [String!] """Order the jobs""" order: JobOrder ): JobConnection notice( namespace: NoticeNamespaces! scope: String! ): Notice organizations( first: Int last: Int ): OrganizationConnection """The current user's permissions""" permissions: ViewerPermissions! """The user's active TOTP configuration, if any. This field is private, requires an escalated session, and cannot be accessed via the public GraphQL API.""" totp( id: ID ): TOTP """The current user""" user: User } """Permissions information about what actions the current user can do""" type ViewerPermissions { """Whether the viewer can configure two-factor authentication""" totpConfigure: Permission! } """A blob of XML represented as a pretty formatted string""" scalar XML """A blob of YAML""" scalar YAML