From bcfc634347ce0cd861e6c8381e39326c38890603 Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 11:51:22 -0700 Subject: [PATCH 1/9] add service option and update readme (finally) --- README.md | 219 +++++++++++++++++++++++++++++++++++++-------- cli/flags.go | 4 + cli/gh-services.go | 29 ++++++ cli/prs.go | 118 ++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 35 deletions(-) create mode 100644 cli/gh-services.go diff --git a/README.md b/README.md index d60eec2..4a40754 100644 --- a/README.md +++ b/README.md @@ -4,90 +4,239 @@ ![lint](https://github.com/katbyte/tctest/actions/workflows/lint.yaml/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/katbyte/tctest)](https://goreportcard.com/report/github.com/katbyte/tctest) -A command-line utility to trigger builds in teamcity to run provider acceptance tests. Given a PR# it can find the files modified, tests to run and generate a TEST_PATTERN. +A command-line utility to trigger builds in TeamCity to run provider acceptance tests. Given a PR number it can find the files modified, discover the tests to run, and generate a `TEST_PATTERN` automatically. Example: ![pr-example](.github/images/example.png) -basic help: +Basic help: ![help](.github/images/help.png) ## Installation -To install `tctest` from the command line, you can run: ```bash -go install github.com/katbyte/tctest +go install github.com/katbyte/tctest@latest ``` ## Configuration -While all commands can be configured from the command line, environment variables can be used instead. By creating a file such as [`set_env_example.sh`](.github/images/set_env_example.sh), it can then be sourced: +All options can be passed as command-line flags but most can also be set via environment variables. Create a file like [`set_env_example.sh`](.github/images/set_env_example.sh) and source it: + ![env](.github/images/env.png) -## Basic Usage +### Environment Variables + +| Variable | Flag | Description | +|---|---|---| +| `TCTEST_SERVER` | `--server`, `-s` | TeamCity server URL | +| `TCTEST_BUILDTYPEID` | `--buildtypeid`, `-b` | TeamCity build configuration ID | +| `TCTEST_TOKEN_TC` | `--token-tc`, `-t` | TeamCity authentication token | +| `TCTEST_USER` | `--username` | TeamCity username (alternative to token) | +| `TCTEST_PASS` | `--password` | TeamCity password (alternative to token) | +| `TCTEST_PROPERTIES` | `--properties`, `-p` | Default build parameters in `KEY=VALUE;KEY2=VALUE2` format | +| `GITHUB_TOKEN` | `--token-gh` | GitHub OAuth token | +| `TCTEST_REPO` | `--repo`, `-r` | GitHub repository (e.g. `hashicorp/terraform-provider-azurerm`) | +| `TCTEST_FILEREGEX` | `--fileregex` | Regex to filter PR files for test discovery | +| `TCTEST_SPLIT_TESTS_ON` | `--splitteston` | Character to split test names on (default: `_`) | +| `TCTEST_WAIT` | `--wait`, `-w` | Wait for builds to complete | +| `TCTEST_LATESTBUILD` | `--latest` | Get the latest build | +| `TCTEST_SKIP_QUEUE` | `--skip-queue`, `-q` | Put the build to the top of the queue | +| `TCTEST_OPEN_BROWSER` | `--open`, `-o` | Open PR and build URLs in the browser | +| `TCTEST_BUILD_TAGS` | `--tag` | Build tags to add to triggered builds | + +## Commands + +### `branch` — Run tests on a branch + +Triggers acceptance tests matching a regex for a specific branch. -To run a build on a branch with a test pattern: -```bash -tctest branch master TestAcc -s ci.katbyte.me -b AzureRm -u katbyte -``` -or when environment variables are set: ```bash +# with flags +tctest branch master TestAcc -s ci.katbyte.me -b AzureRm + +# with environment variables set tctest branch master TestAcc + +# alias +tctest b master TestAcc ``` -## For a PR +### `pr` — Run tests for a PR + +Discovers tests from modified PR files and triggers builds. If no test regex is specified, it automatically determines which tests to run based on the changed files. -To run a build on the merge branch with a specific test pattern: ```bash -tctest pr 3232 TestAcc -s ci.katbyte.me -b AzureRm -u katbyte -r terraform-providers/terraform-provider-azurerm +# auto-discover tests from PR files +tctest pr 3232 + +# specify a test pattern manually +tctest pr 3232 TestAccAzureRMVirtualNetwork + +# multiple PRs at once +tctest pr 3232,5454,7676 + +# wait for builds to complete and show results +tctest pr 3232 --wait + +# open PR and build in browser +tctest pr 3232 --open ``` +#### Service targeting with `--service` + +Use `--service` to target specific service(s). When used without `--all`, it still discovers tests from PR files but only triggers builds for the specified services. With `--all`, it runs `TestAcc` (all tests) for those services. -If no test pattern is specified the modified files in the PR will be checked and it will be generated automatically: ```bash -tctest pr 3232 +# discover tests from PR, but only run for the network service +tctest pr 3232 --service network + +# discover tests from PR for multiple services +tctest pr 3232 --service network,compute + +# run ALL tests for a specific service (no test discovery) +tctest pr 3232 --service network --all + +# run ALL tests for ALL services (no test discovery) +tctest pr 3232 --service all --all + +# invalid service names will error with a list of valid services +tctest pr 3232 --service fakesvc +# ERROR: invalid service(s): fakesvc +# valid services: aadb2c, advisor, apimanagement, ... ``` -Multiple PRs can be specified at once +#### Run all discovered tests with `--all` + +Without `--service`, `--all` overrides the discovered test regex with `TestAcc` to run all tests for the affected services: + ```bash -tctest pr 3232,5454,7676 -```` +tctest pr 3232 --all +``` +#### Post a GitHub comment with `--comment` / `-c` + +Adds `POST_GITHUB_COMMENT=true` to the build properties, telling TeamCity to post test results as a comment on the PR: -To list all the tests discovered for a given PR: ```bash -tctest list 3232 +tctest pr 3232 --comment +tctest pr 3232 -c ``` -To run tests against a PR and display results when complete: +### `prs` — Run tests for multiple PRs with filters + +Discovers all open PRs matching specified filters and triggers builds for each. + ```bash -tctest pr 3232 --wait +# all open PRs by specific authors +tctest prs -a katbyte,author2 + +# PRs with specific labels (all must match) +tctest prs -l needs-testing,service/network + +# PRs with any matching label +tctest prs --f-labels-any needs-testing,ready-for-review + +# PRs by author with a specific label +tctest prs -a katbyte -l needs-testing + +# PRs not in draft +tctest prs -d + +# PRs created within the last 24 hours +tctest prs --f-created-time 24h + +# PRs updated within the last 2 hours +tctest prs --f-updated-time 2h + +# PRs with a specific milestone +tctest prs -m v3.0.0 + +# PRs without a specific milestone +tctest prs -m -v3.0.0 + +# PRs matching a title regex (case-insensitive) +tctest prs --f-title-regex "network.*fix" + +# combine filters with a custom test pattern +tctest prs TestAccAzureRM -a katbyte -l needs-testing ``` -## Build results: -*By TeamCity Build Number* +#### Filter flags + +| Flag | Short | Description | +|---|---|---| +| `--f-authors` | `-a` | Only test PRs by these authors (comma-separated) | +| `--f-labels-all` | `-l` | Only test PRs matching **all** label conditions. Prefix with `-` to negate | +| `--f-labels-any` | | Only test PRs matching **any** label condition. Prefix with `-` to negate | +| `--f-milestone` | `-m` | Filter by milestone. Prefix with `-` to exclude | +| `--f-drafts` | `-d` | Filter out draft PRs | +| `--f-created-time` | | Only PRs created within this duration (e.g. `24h`, `7d`) | +| `--f-updated-time` | | Only PRs updated within this duration | +| `--f-title-regex` | | Filter PRs by title using case-insensitive regex | + +### `list` — Preview discovered tests + +Lists the tests that would be triggered for a PR without actually starting a build. -To show the PASS/FAIL/SKIP results for a TeamCity build number: ```bash -tctest results 12345 +tctest list 3232 ``` -To wait for a running or queued build to complete and then show the results: +### `results` — Show build results + +#### By TeamCity build ID + ```bash +# show PASS/FAIL/SKIP results +tctest results 12345 + +# wait for a running build to complete, then show results tctest results 12345 --wait ``` -*By Github PR Number* +#### By GitHub PR number -To show the PASS/FAIL/SKIP results for **all** TeamCity builds for a Github PR: ```bash +# show results for all builds for a PR tctest results pr 12345 -``` -To show the PASS/FAIL/SKIP results for the **latest** TeamCity build for a Github PR: -```bash + +# show results for only the latest build tctest results pr 12345 --latest + +# wait for builds to complete, then show results +tctest results pr 12345 --wait ``` -To wait for a running or queued build to complete and then show the results: + +### `version` — Print version + ```bash -tctest results pr 12345 --wait +tctest version ``` + +## Build Options + +These flags apply to any command that triggers a build: + +| Flag | Short | Description | +|---|---|---| +| `--properties` | `-p` | Build parameters in `KEY=VALUE;KEY2=VALUE2` format | +| `--comment` | `-c` | Post a GitHub comment with test results (`POST_GITHUB_COMMENT=true`) | +| `--skip-queue` | `-q` | Put the build to the top of the queue | +| `--wait` | `-w` | Wait for the build to complete before exiting | +| `--tag` | | Add tags to the triggered build (comma-separated) | +| `--queue-timeout` | | Minutes to wait for a queued build to start (default: 60) | +| `--run-timeout` | | Minutes to wait for a running build to finish (default: 60) | +| `--open` | `-o` | Open the PR and build URL in the browser | + +## Test Discovery + +When no test regex is provided, `tctest` automatically discovers tests by: + +1. Listing all files modified in the PR +2. Filtering to resource, data source, and ephemeral files (configurable via `--fileregex`) +3. Deriving test file names (e.g. `resource_foo.go` → `resource_foo_test.go`) +4. Also discovering related test files (e.g. `resource_foo_list_test.go`, `resource_foo_data_source_test.go`) +5. Downloading test files and extracting test function names using Go AST parsing +6. Grouping tests by service and triggering a separate build per service + +Files in `/client/`, `/parse/`, `/validate/` subdirectories and `registration.go`/`resourceids.go` are automatically skipped. Deleted files are also excluded. diff --git a/cli/flags.go b/cli/flags.go index a418a94..feb8cf8 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -13,6 +13,7 @@ type FlagData struct { TC FlagsTeamCity OpenInBrowser bool RunAllTests bool + Services []string } type FlagsGitHub struct { @@ -60,6 +61,7 @@ func configureFlags(root *cobra.Command) error { pflags.BoolVarP(&flags.OpenInBrowser, "open", "o", false, "Open the PR and build in a browser") pflags.BoolVarP(&flags.RunAllTests, "all", "", false, "run all tests when none are found by passing TestAcc") + pflags.StringSliceVar(&flags.Services, "service", []string{}, "force trigger builds for specific services (comma-separated), use 'all' to trigger all services") pflags.StringVar(&flags.GH.Token, "token-gh", "", "github oauth token (consider exporting token to GITHUB_TOKEN instead)") pflags.StringVarP(&flags.GH.Repo, "repo", "r", "", "repository the pr resides in, such as terraform-providers/terraform-provider-azurerm") @@ -103,6 +105,7 @@ func configureFlags(root *cobra.Command) error { "splitteston": "TCTEST_SPLIT_TESTS_ON", "wait": "TCTEST_WAIT", "all": "", + "service": "", "queue-timeout": "", "run-timeout": "", "f-authors": "", @@ -149,6 +152,7 @@ func GetFlags() FlagData { return FlagData{ OpenInBrowser: viper.GetBool("open"), RunAllTests: viper.GetBool("all"), + Services: viper.GetStringSlice("service"), GH: FlagsGitHub{ Repo: viper.GetString("repo"), Token: viper.GetString("token-gh"), diff --git a/cli/gh-services.go b/cli/gh-services.go new file mode 100644 index 0000000..7ffe95c --- /dev/null +++ b/cli/gh-services.go @@ -0,0 +1,29 @@ +package cli + +import ( + "fmt" + "sort" + + "github.com/katbyte/tctest/lib/clog" +) + +// ListServices lists all service directory names under internal/services/ in the repo +func (gr GithubRepo) ListServices() ([]string, error) { + client, ctx := gr.NewClient() + + clog.Log.Debugf("listing services for %s/%s...", gr.Owner, gr.Name) + _, dirContents, _, err := client.Repositories.GetContents(ctx, gr.Owner, gr.Name, "internal/services", nil) + if err != nil { + return nil, fmt.Errorf("failed to list services directory for %s/%s: %w", gr.Owner, gr.Name, err) + } + + var services []string + for _, entry := range dirContents { + if entry.GetType() == "dir" { + services = append(services, entry.GetName()) + } + } + + sort.Strings(services) + return services, nil +} diff --git a/cli/prs.go b/cli/prs.go index 15b5f5c..6705b9d 100644 --- a/cli/prs.go +++ b/cli/prs.go @@ -17,6 +17,11 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e } sort.Ints(prNumbers) + // if --service is specified, bypass test discovery and trigger builds directly + if len(f.Services) > 0 { + return f.runServiceBuilds(prNumbers, prs, testRegExParam) + } + ok := 0 for _, number := range prNumbers { title := prs[number] @@ -75,3 +80,116 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e return nil } + +func (f FlagData) runServiceBuilds(prNumbers []int, prs map[int]string, testRegExParam string) error { + gr := f.NewRepo() + + // resolve which services to run + services := f.Services + + // fetch valid services from repo for validation or 'all' expansion + c.Printf("Fetching service list from %s/%s...\n", gr.Owner, gr.Name) + validServices, err := gr.ListServices() + if err != nil { + return fmt.Errorf("failed to list services: %w", err) + } + c.Printf(" found %d services\n", len(validServices)) + + validSet := make(map[string]bool, len(validServices)) + for _, s := range validServices { + validSet[s] = true + } + + // handle 'all' + isAll := len(services) == 1 && strings.EqualFold(services[0], "all") + if isAll { + services = validServices + c.Printf(" using all services\n") + } else { + // validate each specified service + var invalid []string + for _, s := range services { + if !validSet[s] { + invalid = append(invalid, s) + } + } + if len(invalid) > 0 { + return fmt.Errorf("invalid service(s): %s\nvalid services: %s", strings.Join(invalid, ", "), strings.Join(validServices, ", ")) + } + } + + serviceSet := make(map[string]bool, len(services)) + for _, s := range services { + serviceSet[s] = true + } + + ok := 0 + for _, number := range prNumbers { + title := prs[number] + + // if --all is set, skip discovery and trigger TestAcc for each service + if f.RunAllTests { + testRegEx := testRegExParam + if testRegEx == "" { + testRegEx = "TestAcc" + } + + c.Printf("PR #%d %s (--all: running %s)\n", number, title, testRegEx) + for _, s := range services { + serviceInfo := "[" + s + "]" + buildTypeID := viper.GetString("buildtypeid") + "_" + strings.ToUpper(s) + branch := fmt.Sprintf("refs/pull/%d/merge", number) + + if err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo); err != nil { + c.Printf(" ERROR: Unable to trigger build: %v\n", err) + } + fmt.Println() + } + ok++ + continue + } + + // normal discovery, but filter to only the specified services + serviceTests, err := f.GetPrTests(number, title) + if err != nil { + c.Printf(" ERROR: discovering tests: %v\n\n", err) + continue + } + + if serviceTests == nil { + c.Printf(" ERROR: service tests is nil\n\n") + continue + } + + for s, tests := range *serviceTests { + if !serviceSet[s] { + continue + } + + serviceInfo := "[" + s + "]" + + testRegEx := testRegExParam + if testRegEx == "" { + if len(tests) == 0 { + c.Printf(" %sERROR: no tests found, use --all to run all tests\n", serviceInfo) + continue + } + testRegEx = "(" + strings.Join(tests, "|") + ")" + } + + buildTypeID := viper.GetString("buildtypeid") + "_" + strings.ToUpper(s) + branch := fmt.Sprintf("refs/pull/%d/merge", number) + + if err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo); err != nil { + c.Printf(" ERROR: Unable to trigger build: %v\n", err) + } + fmt.Println() + } + + ok++ + } + c.Printf("triggered tests for %d PRs across %d services!\n\n", ok, len(services)) + + return nil +} + From cbb2409c1feb2c275c66fccf0a6e2d6af2d85c4c Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 11:56:10 -0700 Subject: [PATCH 2/9] clean code --- cli/prs.go | 163 +++++++++++++++++++++++------------------------------ 1 file changed, 69 insertions(+), 94 deletions(-) diff --git a/cli/prs.go b/cli/prs.go index 6705b9d..e506126 100644 --- a/cli/prs.go +++ b/cli/prs.go @@ -17,14 +17,32 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e } sort.Ints(prNumbers) - // if --service is specified, bypass test discovery and trigger builds directly - if len(f.Services) > 0 { - return f.runServiceBuilds(prNumbers, prs, testRegExParam) + // if --service is specified, resolve and validate services up front + serviceFilter, err := f.resolveServiceFilter() + if err != nil { + return err } ok := 0 for _, number := range prNumbers { title := prs[number] + + // when --service + --all, skip discovery and trigger TestAcc for each service directly + if serviceFilter != nil && f.RunAllTests { + testRegEx := testRegExParam + if testRegEx == "" { + testRegEx = "TestAcc" + } + + c.Printf("PR #%d %s (--all: running %s)\n", number, title, testRegEx) + for _, s := range serviceFilter.services { + f.triggerServiceBuild(s, number, testRegEx) + } + ok++ + continue + } + + // discover tests from PR files serviceTests, err := f.GetPrTests(number, title) if err != nil { c.Printf(" ERROR: discovering tests: %v\n\n", err) @@ -32,12 +50,17 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e } if serviceTests == nil { - c.Printf(" ERROR: service tests in nil\n\n") + c.Printf(" ERROR: service tests is nil\n\n") continue } // trigger a build for each service for s, tests := range *serviceTests { + // if --service is set, skip services not in the filter + if serviceFilter != nil && !serviceFilter.set[s] { + continue + } + serviceInfo := "" if s != "" { serviceInfo = "[" + s + "]" @@ -46,7 +69,6 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e // generate test regex if we don't have it testRegEx := testRegExParam if testRegEx == "" { - // if no testregex and no tests throw an error (-a is required for all) if len(tests) == 0 { c.Printf(" %sERROR: no tests found, use TestAcc or --all to run all tests\n", serviceInfo) continue @@ -55,43 +77,44 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e testRegEx = "(" + strings.Join(tests, "|") + ")" } - // if all tests switch set regex to TestAcc + // if --all set regex to TestAcc if f.RunAllTests { testRegEx = "TestAcc" } - // if we have a service put it on the end of the build type id - buildTypeID := viper.GetString("buildtypeid") - if s != "" { - buildTypeID += "_" + strings.ToUpper(s) - } - - branch := fmt.Sprintf("refs/pull/%d/merge", number) - - if err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo); err != nil { - c.Printf(" ERROR: Unable to trigger build: %v\n", err) - } - fmt.Println() + f.triggerServiceBuild(s, number, testRegEx) } ok++ } - c.Printf("triggered tests for %d PRs!\n\n", ok) + + if serviceFilter != nil { + c.Printf("triggered tests for %d PRs across %d services!\n\n", ok, len(serviceFilter.services)) + } else { + c.Printf("triggered tests for %d PRs!\n\n", ok) + } return nil } -func (f FlagData) runServiceBuilds(prNumbers []int, prs map[int]string, testRegExParam string) error { - gr := f.NewRepo() +// serviceFilterResult holds the resolved and validated service filter +type serviceFilterResult struct { + services []string // ordered list of services + set map[string]bool // set for fast lookup +} - // resolve which services to run - services := f.Services +// resolveServiceFilter validates --service values against the GitHub repo. Returns nil if --service is not set. +func (f FlagData) resolveServiceFilter() (*serviceFilterResult, error) { + if len(f.Services) == 0 { + return nil, nil + } + + gr := f.NewRepo() - // fetch valid services from repo for validation or 'all' expansion c.Printf("Fetching service list from %s/%s...\n", gr.Owner, gr.Name) validServices, err := gr.ListServices() if err != nil { - return fmt.Errorf("failed to list services: %w", err) + return nil, fmt.Errorf("failed to list services: %w", err) } c.Printf(" found %d services\n", len(validServices)) @@ -101,8 +124,8 @@ func (f FlagData) runServiceBuilds(prNumbers []int, prs map[int]string, testRegE } // handle 'all' - isAll := len(services) == 1 && strings.EqualFold(services[0], "all") - if isAll { + services := f.Services + if len(services) == 1 && strings.EqualFold(services[0], "all") { services = validServices c.Printf(" using all services\n") } else { @@ -114,82 +137,34 @@ func (f FlagData) runServiceBuilds(prNumbers []int, prs map[int]string, testRegE } } if len(invalid) > 0 { - return fmt.Errorf("invalid service(s): %s\nvalid services: %s", strings.Join(invalid, ", "), strings.Join(validServices, ", ")) + return nil, fmt.Errorf("invalid service(s): %s\nvalid services: %s", strings.Join(invalid, ", "), strings.Join(validServices, ", ")) } } - serviceSet := make(map[string]bool, len(services)) + set := make(map[string]bool, len(services)) for _, s := range services { - serviceSet[s] = true + set[s] = true } - ok := 0 - for _, number := range prNumbers { - title := prs[number] - - // if --all is set, skip discovery and trigger TestAcc for each service - if f.RunAllTests { - testRegEx := testRegExParam - if testRegEx == "" { - testRegEx = "TestAcc" - } - - c.Printf("PR #%d %s (--all: running %s)\n", number, title, testRegEx) - for _, s := range services { - serviceInfo := "[" + s + "]" - buildTypeID := viper.GetString("buildtypeid") + "_" + strings.ToUpper(s) - branch := fmt.Sprintf("refs/pull/%d/merge", number) - - if err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo); err != nil { - c.Printf(" ERROR: Unable to trigger build: %v\n", err) - } - fmt.Println() - } - ok++ - continue - } - - // normal discovery, but filter to only the specified services - serviceTests, err := f.GetPrTests(number, title) - if err != nil { - c.Printf(" ERROR: discovering tests: %v\n\n", err) - continue - } - - if serviceTests == nil { - c.Printf(" ERROR: service tests is nil\n\n") - continue - } - - for s, tests := range *serviceTests { - if !serviceSet[s] { - continue - } - - serviceInfo := "[" + s + "]" + return &serviceFilterResult{services: services, set: set}, nil +} - testRegEx := testRegExParam - if testRegEx == "" { - if len(tests) == 0 { - c.Printf(" %sERROR: no tests found, use --all to run all tests\n", serviceInfo) - continue - } - testRegEx = "(" + strings.Join(tests, "|") + ")" - } +// triggerServiceBuild triggers a build for a single service on a PR +func (f FlagData) triggerServiceBuild(service string, prNumber int, testRegEx string) { + serviceInfo := "" + if service != "" { + serviceInfo = "[" + service + "]" + } - buildTypeID := viper.GetString("buildtypeid") + "_" + strings.ToUpper(s) - branch := fmt.Sprintf("refs/pull/%d/merge", number) + buildTypeID := viper.GetString("buildtypeid") + if service != "" { + buildTypeID += "_" + strings.ToUpper(service) + } - if err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo); err != nil { - c.Printf(" ERROR: Unable to trigger build: %v\n", err) - } - fmt.Println() - } + branch := fmt.Sprintf("refs/pull/%d/merge", prNumber) - ok++ + if err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo); err != nil { + c.Printf(" ERROR: Unable to trigger build: %v\n", err) } - c.Printf("triggered tests for %d PRs across %d services!\n\n", ok, len(services)) - - return nil + fmt.Println() } - From f0766ac74013c88b61a09da613f19b33507f700d Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 12:44:41 -0700 Subject: [PATCH 3/9] add option for force old ui --- cli/cmds.go | 28 +++++++++++++++--------- cli/flags.go | 41 ++++++++++++++++++++++------------ cli/gh-pr-filters.go | 52 ++++++++++++++++++++++---------------------- cli/gh-pr.go | 24 ++++++++++---------- cli/prs.go | 28 ++++++++++++++---------- cli/tc-build.go | 29 +++++++++++++----------- lib/cout/cout.go | 51 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 86 deletions(-) create mode 100644 lib/cout/cout.go diff --git a/cli/cmds.go b/cli/cmds.go index 27d788f..89730c6 100644 --- a/cli/cmds.go +++ b/cli/cmds.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/katbyte/tctest/lib/cout" "github.com/katbyte/tctest/lib/version" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -34,6 +35,13 @@ func Make() (*cobra.Command, error) { Long: `A small utility to trigger acceptance tests on teamcity. It can also pull the tests to run for a PR on github Complete documentation is available at https://github.com/katbyte/tctest`, + PersistentPreRun: func(_ *cobra.Command, _ []string) { + if viper.GetBool("silent") { + cout.Level = cout.VerbositySilent + } else if viper.GetBool("quiet") { + cout.Level = cout.VerbosityQuiet + } + }, RunE: func(_ *cobra.Command, _ []string) error { fmt.Printf("Run \"tctest help\" for more information about available tctest commands.\n") return nil @@ -71,7 +79,8 @@ Complete documentation is available at https://github.com/katbyte/tctest`, cmd.SilenceUsage = true f := GetFlags() - return f.BuildCmd(f.TC.Build.TypeID, branch, testRegEx, "") + _, _, err := f.BuildCmd(f.TC.Build.TypeID, branch, testRegEx, "") + return err }, }) @@ -127,25 +136,24 @@ Complete documentation is available at https://github.com/katbyte/tctest`, f := GetFlags() r := f.NewRepo() - c.Printf("Filters:\n") + cout.Printf("Filters:\n") filters, err := f.GetFilters() if err != nil { return fmt.Errorf("error creating filters: %w", err) } // get all pull requests - c.Printf("Retrieving all prs for %s/%s...", r.Owner, r.Name) + cout.Printf("Retrieving all prs for %s/%s...", r.Owner, r.Name) prs, err := r.GetAllPullRequests("open") // todo should this return a list not map? probably if err != nil { - c.Printf("\n\n ERROR!! %s\n", err) - return nil + return fmt.Errorf("error retrieving PRs: %w", err) } - c.Printf(" found %d\n", len(*prs)) + cout.Printf(" found %d\n", len(*prs)) prTitles := make(map[int]string) - fmt.Println("Filtering:") + cout.Printf("Filtering:\n") for _, pr := range *prs { - c.Printf(" #%d (%s>\n", pr.GetNumber(), pr.GetHTMLURL()) + cout.Printf(" #%d (%s)\n", pr.GetNumber(), pr.GetHTMLURL()) passed := true for _, f := range filters { @@ -160,10 +168,10 @@ Complete documentation is available at https://github.com/katbyte/tctest`, prTitles[pr.GetNumber()] = pr.GetTitle() } - fmt.Println() + cout.Printf("\n") } - c.Printf("testing %d prs\n\n", len(prTitles)) + cout.Printf("testing %d prs\n\n", len(prTitles)) return GetFlags().GetAndRunPrsTests(prTitles, testRegExParam) }, diff --git a/cli/flags.go b/cli/flags.go index feb8cf8..328eb78 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -14,6 +14,8 @@ type FlagData struct { OpenInBrowser bool RunAllTests bool Services []string + Quiet bool + Silent bool } type FlagsGitHub struct { @@ -50,6 +52,7 @@ type FlagsTeamCityBuild struct { Wait bool Latest bool Comment bool + ForceOldUI bool QueueTimeout int RunTimeout int Tags []string @@ -62,6 +65,8 @@ func configureFlags(root *cobra.Command) error { pflags.BoolVarP(&flags.OpenInBrowser, "open", "o", false, "Open the PR and build in a browser") pflags.BoolVarP(&flags.RunAllTests, "all", "", false, "run all tests when none are found by passing TestAcc") pflags.StringSliceVar(&flags.Services, "service", []string{}, "force trigger builds for specific services (comma-separated), use 'all' to trigger all services") + pflags.BoolVar(&flags.Quiet, "quiet", false, "minimal machine-readable output (pr, service, build id/url)") + pflags.BoolVar(&flags.Silent, "silent", false, "suppress all output") pflags.StringVar(&flags.GH.Token, "token-gh", "", "github oauth token (consider exporting token to GITHUB_TOKEN instead)") pflags.StringVarP(&flags.GH.Repo, "repo", "r", "", "repository the pr resides in, such as terraform-providers/terraform-provider-azurerm") @@ -89,6 +94,7 @@ func configureFlags(root *cobra.Command) error { pflags.IntVarP(&flags.TC.Build.QueueTimeout, "queue-timeout", "", 60, "How long to wait for a queued build to start running before tctest times out") pflags.IntVarP(&flags.TC.Build.RunTimeout, "run-timeout", "", 60, "How long to wait for a running build to finish before tctest times out") pflags.BoolVarP(&flags.TC.Build.Comment, "comment", "c", false, "Post a GitHub comment on the PR with test results (adds POST_GITHUB_COMMENT=true property)") + pflags.BoolVar(&flags.TC.Build.ForceOldUI, "build-link-force-old-ui", false, "Append &fromSakuraUI=true to build URLs to force the classic TeamCity UI") pflags.StringSliceVarP(&flags.TC.Build.Tags, "tag", "", []string{}, "TeamCity build tags to add to the triggered build, ie 'tag1,tag2'") // binding map for viper/pflag -> env @@ -106,20 +112,24 @@ func configureFlags(root *cobra.Command) error { "wait": "TCTEST_WAIT", "all": "", "service": "", - "queue-timeout": "", - "run-timeout": "", - "f-authors": "", - "f-milestone": "", - "f-labels-all": "", - "f-labels-any": "", - "f-created-time": "", - "f-updated-time": "", - "f-title-regex": "", - "latest": "TCTEST_LATESTBUILD", - "skip-queue": "TCTEST_SKIP_QUEUE", - "open": "TCTEST_OPEN_BROWSER", - "comment": "", - "tag": "TCTEST_BUILD_TAGS", + "quiet": "TCTEST_QUIET", + "silent": "TCTEST_SILENT", + "queue-timeout": "", + "run-timeout": "", + "f-authors": "", + "f-milestone": "", + "f-labels-all": "", + "f-labels-any": "", + "f-created-time": "", + "f-updated-time": "", + "f-title-regex": "", + "f-drafts": "", + "latest": "TCTEST_LATESTBUILD", + "skip-queue": "TCTEST_SKIP_QUEUE", + "open": "TCTEST_OPEN_BROWSER", + "comment": "TCTEST_COMMENT", + "build-link-force-old-ui": "TCTEST_FORCE_OLD_UI", + "tag": "TCTEST_BUILD_TAGS", } for name, env := range m { @@ -153,6 +163,8 @@ func GetFlags() FlagData { OpenInBrowser: viper.GetBool("open"), RunAllTests: viper.GetBool("all"), Services: viper.GetStringSlice("service"), + Quiet: viper.GetBool("quiet"), + Silent: viper.GetBool("silent"), GH: FlagsGitHub{ Repo: viper.GetString("repo"), Token: viper.GetString("token-gh"), @@ -181,6 +193,7 @@ func GetFlags() FlagData { Wait: viper.GetBool("wait"), Latest: viper.GetBool("latest"), Comment: viper.GetBool("comment"), + ForceOldUI: viper.GetBool("build-link-force-old-ui"), QueueTimeout: viper.GetInt("queue-timeout"), RunTimeout: viper.GetInt("run-timeout"), Tags: viper.GetStringSlice("tag"), diff --git a/cli/gh-pr-filters.go b/cli/gh-pr-filters.go index 031720c..7f5f4a1 100644 --- a/cli/gh-pr-filters.go +++ b/cli/gh-pr-filters.go @@ -7,7 +7,7 @@ import ( "time" "github.com/google/go-github/v45/github" - c "github.com/gookit/color" //nolint:misspell + "github.com/katbyte/tctest/lib/cout" ) type Filter struct { @@ -51,7 +51,7 @@ func (f FlagData) GetFilters() ([]Filter, error) { filters = append(filters, *titleFilter) } - fmt.Println() + cout.Printf("\n") return filters, nil } @@ -66,7 +66,7 @@ func GetFilterForAuthors(authors []string) *Filter { authorMap[a] = true } - c.Printf(" authors: %s\n", strings.Join(authors, ",")) + cout.Printf(" authors: %s\n", strings.Join(authors, ",")) return &Filter{ Name: "authors", @@ -74,10 +74,10 @@ func GetFilterForAuthors(authors []string) *Filter { author := pr.User.GetLogin() if _, ok := authorMap[author]; ok { - c.Printf(" author: %s\n", author) + cout.Printf(" author: %s\n", author) return true, nil } - c.Printf(" author: %s\n", author) + cout.Printf(" author: %s\n", author) return false, nil }, @@ -91,7 +91,7 @@ func GetFilterForMilestone(milestoneRaw string) *Filter { filterMilestone := strings.TrimPrefix(milestoneRaw, "-") negate := strings.HasPrefix(milestoneRaw, "-") - c.Printf(" milestone: %s\n", milestoneRaw) + cout.Printf(" milestone: %s\n", milestoneRaw) return &Filter{ Name: "milestones", @@ -100,14 +100,14 @@ func GetFilterForMilestone(milestoneRaw string) *Filter { //nolint:gocritic if strings.EqualFold(filterMilestone, milestone) && !negate { - c.Printf(" milestone: %s (%s)\n", filterMilestone, milestone) + cout.Printf(" milestone: %s (%s)\n", filterMilestone, milestone) return true, nil } else if negate { - c.Printf(" milestone: -%s (%s)\n", filterMilestone, milestone) + cout.Printf(" milestone: -%s (%s)\n", filterMilestone, milestone) return true, nil } else { //revive:disable:indent-error-flow - c.Printf(" milestone: %s (%s)\n", filterMilestone, milestone) + cout.Printf(" milestone: %s (%s)\n", filterMilestone, milestone) return false, nil } }, @@ -120,7 +120,7 @@ func GetFilterForCreatedTime(duration time.Duration) *Filter { } cutoffTime := time.Now().Add(-duration) - c.Printf(" created within: %s\n", duration.String()) + cout.Printf(" created within: %s\n", duration.String()) return &Filter{ Name: "creation-time", @@ -128,11 +128,11 @@ func GetFilterForCreatedTime(duration time.Duration) *Filter { createdAt := pr.GetCreatedAt() if createdAt.After(cutoffTime) { - c.Printf(" created: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) + cout.Printf(" created: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) return true, nil } - c.Printf(" created: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) + cout.Printf(" created: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) return false, nil }, @@ -145,7 +145,7 @@ func GetFilterForUpdatedTime(duration time.Duration) *Filter { } cutoffTime := time.Now().Add(-duration) - c.Printf(" updated within: %s\n", duration.String()) + cout.Printf(" updated within: %s\n", duration.String()) return &Filter{ Name: "creation-time", @@ -153,11 +153,11 @@ func GetFilterForUpdatedTime(duration time.Duration) *Filter { createdAt := pr.GetUpdatedAt() if createdAt.After(cutoffTime) { - c.Printf(" updated: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) + cout.Printf(" updated: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) return true, nil } - c.Printf(" updated: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) + cout.Printf(" updated: %s (%s)\n", createdAt.Format(time.RFC822), cutoffTime.Format(time.RFC822)) return false, nil }, @@ -189,7 +189,7 @@ func GetFilterForLabels(labels []string, and bool) *Filter { actionAnd = true } - c.Printf(" labels %s: %s\n", action, strings.Join(labels, ",")) + cout.Printf(" labels %s: %s\n", action, strings.Join(labels, ",")) // found := false return &Filter{ @@ -205,9 +205,9 @@ func GetFilterForLabels(labels []string, and bool) *Filter { // for each label, if actionAnd { - c.Printf(" labels all: ") + cout.Printf(" labels all:") } else { - c.Printf(" labels any: ") + cout.Printf(" labels any:") } andFail := false @@ -220,19 +220,19 @@ func GetFilterForLabels(labels []string, and bool) *Filter { //nolint:gocritic if found && !negate { orPass = true - c.Printf(" %s", filterLabel) + cout.Printf(" %s", filterLabel) } else if found && negate { andFail = true - c.Printf(" -%s", filterLabel) + cout.Printf(" -%s", filterLabel) } else if negate { orPass = true - c.Printf(" -%s", filterLabel) + cout.Printf(" -%s", filterLabel) } else { andFail = true - c.Printf(" %s", filterLabel) + cout.Printf(" %s", filterLabel) } } - fmt.Println() + cout.Println() if actionAnd { return !andFail, nil @@ -255,7 +255,7 @@ func GetFilterForTitleRegex(pattern string) (*Filter, error) { return nil, fmt.Errorf("invalid title regex pattern '%s': %w", pattern, err) } - c.Printf(" title regex: %s (case-insensitive)\n", pattern) + cout.Printf(" title regex: %s (case-insensitive)\n", pattern) return &Filter{ Name: "title-regex", @@ -263,11 +263,11 @@ func GetFilterForTitleRegex(pattern string) (*Filter, error) { title := pr.GetTitle() if re.MatchString(title) { - c.Printf(" title: %s\n", title) + cout.Printf(" title: %s\n", title) return true, nil } - c.Printf(" title: %s\n", title) + cout.Printf(" title: %s\n", title) return false, nil }, }, nil diff --git a/cli/gh-pr.go b/cli/gh-pr.go index 19aa241..e755c60 100644 --- a/cli/gh-pr.go +++ b/cli/gh-pr.go @@ -11,9 +11,9 @@ import ( "strings" "github.com/google/go-github/v45/github" - c "github.com/gookit/color" //nolint:misspell "github.com/katbyte/tctest/lib/chttp" "github.com/katbyte/tctest/lib/clog" + "github.com/katbyte/tctest/lib/cout" "github.com/pkg/browser" ) @@ -23,12 +23,12 @@ func (f FlagData) GetPrTests(number int, title string) (*map[string][]string, er gr := f.NewRepo() prURL := gr.PrURL(number) - c.Printf("Discovering tests for pr #%d %s %s\n", number, title, prURL) + cout.Printf("Discovering tests for pr #%d %s %s\n", number, title, prURL) serviceTests, err := gr.PrTests(number, f.GH.FileRegEx, f.GH.SplitTestsOn) if f.OpenInBrowser { if err := browser.OpenURL(prURL); err != nil { - c.Printf("failed to open build %s in browser", prURL) + cout.Printf("failed to open build %s in browser", prURL) } } @@ -37,9 +37,9 @@ func (f FlagData) GetPrTests(number int, title string) (*map[string][]string, er } for service, tests := range *serviceTests { - c.Printf(" %s:\n", service) + cout.Printf(" %s:\n", service) for _, t := range tests { - c.Printf(" %s\n", t) + cout.Printf(" %s\n", t) } } @@ -327,23 +327,23 @@ func (gr GithubRepo) GetAllPullRequestFiles(pri int, filterRegExStr string) (*ma } // print file regex and changed files - c.Printf(" file regex: %s\n", filterRegExStr) - c.Printf(" changed files (%d):\n", len(changedFiles)) + cout.Printf(" file regex: %s\n", filterRegExStr) + cout.Printf(" changed files (%d):\n", len(changedFiles)) for _, f := range changedFiles { dir := f[:strings.LastIndex(f, "/")+1] base := f[strings.LastIndex(f, "/")+1:] switch { case skippedFiles[f]: - c.Printf(" %s%s\n", dir, base) + cout.Printf(" %s%s\n", dir, base) case strings.HasSuffix(f, "_test.go"): - c.Printf(" %s%s\n", dir, base) + cout.Printf(" %s%s\n", dir, base) default: - c.Printf(" %s%s\n", dir, base) + cout.Printf(" %s%s\n", dir, base) } } // print test files - c.Printf(" test files (%d):\n", len(testFiles)) + cout.Printf(" test files (%d):\n", len(testFiles)) for _, f := range testFiles { dir := f[:strings.LastIndex(f, "/")+1] base := f[strings.LastIndex(f, "/")+1:] @@ -358,7 +358,7 @@ func (gr GithubRepo) GetAllPullRequestFiles(pri int, filterRegExStr string) (*ma } label := strings.Join(labels, "/") - c.Printf(" %s%s [%s]\n", dir, base, label) + cout.Printf(" %s%s [%s]\n", dir, base, label) } clog.Log.Debugf(" FOUND %d", len(result)) diff --git a/cli/prs.go b/cli/prs.go index e506126..1ab0990 100644 --- a/cli/prs.go +++ b/cli/prs.go @@ -6,6 +6,7 @@ import ( "strings" c "github.com/gookit/color" //nolint:misspell + "github.com/katbyte/tctest/lib/cout" "github.com/spf13/viper" ) @@ -34,7 +35,7 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e testRegEx = "TestAcc" } - c.Printf("PR #%d %s (--all: running %s)\n", number, title, testRegEx) + cout.Printf("PR #%d %s (--all: running %s)\n", number, title, testRegEx) for _, s := range serviceFilter.services { f.triggerServiceBuild(s, number, testRegEx) } @@ -54,6 +55,8 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e continue } + + // trigger a build for each service for s, tests := range *serviceTests { // if --service is set, skip services not in the filter @@ -89,9 +92,9 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e } if serviceFilter != nil { - c.Printf("triggered tests for %d PRs across %d services!\n\n", ok, len(serviceFilter.services)) + cout.Printf("triggered tests for %d PRs across %d services!\n\n", ok, len(serviceFilter.services)) } else { - c.Printf("triggered tests for %d PRs!\n\n", ok) + cout.Printf("triggered tests for %d PRs!\n\n", ok) } return nil @@ -99,8 +102,8 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e // serviceFilterResult holds the resolved and validated service filter type serviceFilterResult struct { - services []string // ordered list of services - set map[string]bool // set for fast lookup + services []string // ordered list of services + set map[string]bool // set for fast lookup } // resolveServiceFilter validates --service values against the GitHub repo. Returns nil if --service is not set. @@ -111,12 +114,12 @@ func (f FlagData) resolveServiceFilter() (*serviceFilterResult, error) { gr := f.NewRepo() - c.Printf("Fetching service list from %s/%s...\n", gr.Owner, gr.Name) + cout.Printf("Fetching service list from %s/%s...\n", gr.Owner, gr.Name) validServices, err := gr.ListServices() if err != nil { return nil, fmt.Errorf("failed to list services: %w", err) } - c.Printf(" found %d services\n", len(validServices)) + cout.Printf(" found %d services\n", len(validServices)) validSet := make(map[string]bool, len(validServices)) for _, s := range validServices { @@ -127,7 +130,7 @@ func (f FlagData) resolveServiceFilter() (*serviceFilterResult, error) { services := f.Services if len(services) == 1 && strings.EqualFold(services[0], "all") { services = validServices - c.Printf(" using all services\n") + cout.Printf(" using all services\n") } else { // validate each specified service var invalid []string @@ -153,7 +156,7 @@ func (f FlagData) resolveServiceFilter() (*serviceFilterResult, error) { func (f FlagData) triggerServiceBuild(service string, prNumber int, testRegEx string) { serviceInfo := "" if service != "" { - serviceInfo = "[" + service + "]" + serviceInfo = "[" + service + "]" } buildTypeID := viper.GetString("buildtypeid") @@ -163,8 +166,11 @@ func (f FlagData) triggerServiceBuild(service string, prNumber int, testRegEx st branch := fmt.Sprintf("refs/pull/%d/merge", prNumber) - if err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo); err != nil { + buildID, buildURL, err := GetFlags().BuildCmd(buildTypeID, branch, testRegEx, serviceInfo) + if err != nil { c.Printf(" ERROR: Unable to trigger build: %v\n", err) + } else { + cout.Quietf("%d@%s@%d %s\n", prNumber, service, buildID, buildURL) } - fmt.Println() + cout.Printf("\n") } diff --git a/cli/tc-build.go b/cli/tc-build.go index 54a3c64..729061a 100644 --- a/cli/tc-build.go +++ b/cli/tc-build.go @@ -6,16 +6,15 @@ import ( "regexp" "strings" - //nolint:misspell - c "github.com/gookit/color" "github.com/katbyte/tctest/lib/clog" + "github.com/katbyte/tctest/lib/cout" "github.com/pkg/browser" ) -func (f FlagData) BuildCmd(buildTypeID, branch, testRegex, service string) error { +func (f FlagData) BuildCmd(buildTypeID, branch, testRegex, service string) (int, string, error) { tc := f.NewServer() - c.Printf("triggering %s%s @ %s...\n", branch, service, buildTypeID) + cout.Printf("triggering %s%s @ %s...\n", branch, service, buildTypeID) properties := f.TC.Build.Parameters if f.TC.Build.Comment { @@ -27,23 +26,27 @@ func (f FlagData) BuildCmd(buildTypeID, branch, testRegex, service string) error buildID, buildURL, err := tc.RunBuild(buildTypeID, properties, branch, testRegex, f.TC.Build.SkipQueue) if err != nil { - return fmt.Errorf("unable to trigger build: %w", err) + return 0, "", fmt.Errorf("unable to trigger build: %w", err) } - c.Printf(" build %d queued: %s with %s\n", buildID, buildURL, testRegex) + if f.TC.Build.ForceOldUI { + buildURL += "&fromSakuraUI=true" + } + + cout.Printf(" build %d queued: %s with %s\n", buildID, buildURL, testRegex) if len(f.TC.Build.Tags) > 0 { - c.Printf(" adding labels: %v...\n", f.TC.Build.Tags) + cout.Printf(" adding labels: %v...\n", f.TC.Build.Tags) if err := tc.AddTags(buildID, f.TC.Build.Tags); err != nil { - c.Printf(" WARNING: failed to add tags to build %d: %v\n", buildID, err) + cout.Printf(" WARNING: failed to add tags to build %d: %v\n", buildID, err) } else { - c.Printf(" tags added successfully\n") + cout.Printf(" tags added successfully\n") } } if f.OpenInBrowser { if err := browser.OpenURL(buildURL); err != nil { - c.Printf("failed to open build %d in browser", buildID) + cout.Printf("failed to open build %d in browser", buildID) } } @@ -51,15 +54,15 @@ func (f FlagData) BuildCmd(buildTypeID, branch, testRegex, service string) error clog.Log.Debugf("waiting...") err := tc.WaitForBuild(buildID, f.TC.Build.QueueTimeout, f.TC.Build.RunTimeout) if err != nil { - return fmt.Errorf("error waiting for build %d to finish: %w", buildID, err) + return buildID, buildURL, fmt.Errorf("error waiting for build %d to finish: %w", buildID, err) } err = f.BuildResultsCmd(buildID) if err != nil { - return fmt.Errorf("error printing results from build %d: %w", buildID, err) + return buildID, buildURL, fmt.Errorf("error printing results from build %d: %w", buildID, err) } } - return nil + return buildID, buildURL, nil } func (f FlagData) BuildResultsCmd(buildID int) error { diff --git a/lib/cout/cout.go b/lib/cout/cout.go new file mode 100644 index 0000000..016d048 --- /dev/null +++ b/lib/cout/cout.go @@ -0,0 +1,51 @@ +package cout + +import ( + "io" + "os" + + c "github.com/gookit/color" //nolint:misspell +) + +// Verbosity levels +const ( + VerbosityNormal = iota + VerbosityQuiet + VerbositySilent +) + +// Level controls the output verbosity. Set before any output calls. +var Level = VerbosityNormal + +// Writer returns the appropriate writer for normal output (os.Stdout or discard) +func Writer() io.Writer { + if Level >= VerbosityQuiet { + return io.Discard + } + return os.Stdout +} + +// Printf prints normal output with color support (suppressed in quiet and silent modes) +func Printf(format string, args ...interface{}) { + if Level >= VerbosityQuiet { + return + } + c.Printf(format, args...) +} + +// Println prints normal output (suppressed in quiet and silent modes) +func Println(args ...interface{}) { + if Level >= VerbosityQuiet { + return + } + c.Println(args...) +} + +// Quietf prints output in quiet mode with color support (suppressed only in silent mode). +// Use this for the minimal machine-readable output. +func Quietf(format string, args ...interface{}) { + if Level >= VerbositySilent { + return + } + c.Printf(format, args...) +} From 1f1f70168ac9aafc7db429a80aa78ea010be4b79 Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 13:23:02 -0700 Subject: [PATCH 4/9] deprecate buildtypeid for build-type-id abd build-type-id-add-service-suffix --- README.md | 43 +++++++++++++++++++++++++++++++++++ cli/cmds.go | 24 ++++++++++++++++---- cli/flags.go | 54 ++++++++++++++++++++++++++------------------ cli/gh-pr-filters.go | 2 +- cli/prs.go | 9 +++++--- lib/cout/cout.go | 47 ++++++++++++++++++++++++++++++++++---- 6 files changed, 144 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 4a40754..cc31eae 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ All options can be passed as command-line flags but most can also be set via env | `TCTEST_SKIP_QUEUE` | `--skip-queue`, `-q` | Put the build to the top of the queue | | `TCTEST_OPEN_BROWSER` | `--open`, `-o` | Open PR and build URLs in the browser | | `TCTEST_BUILD_TAGS` | `--tag` | Build tags to add to triggered builds | +| `TCTEST_COMMENT` | `--comment`, `-c` | Post a GitHub comment with test results | +| `TCTEST_FORCE_OLD_UI` | `--build-link-force-old-ui` | Force build URLs to use the classic TeamCity UI | +| `TCTEST_OUTPUT_QUIET` | `--quiet` | Minimal machine-readable output | +| `TCTEST_OUTPUT_JSON` | `--json` | Output build results as a JSON array | +| `TCTEST_OUTPUT_SILENT` | `--silent` | Suppress all output | ## Commands @@ -227,6 +232,44 @@ These flags apply to any command that triggers a build: | `--queue-timeout` | | Minutes to wait for a queued build to start (default: 60) | | `--run-timeout` | | Minutes to wait for a running build to finish (default: 60) | | `--open` | `-o` | Open the PR and build URL in the browser | +| `--build-link-force-old-ui` | | Append `&fromSakuraUI=true` to build URLs to force the classic TeamCity UI | + +## Output Modes + +By default `tctest` prints colorized, verbose output. Use these flags to control output: + +| Flag | Description | +|---|---| +| *(default)* | Full colorized output with test discovery details, file listings, and build info | +| `--quiet` | One line per build: `PR@SERVICE@BUILDID URL` | +| `--json` | JSON array of all triggered builds (output at end) | +| `--silent` | Suppress all output (errors still print to stderr) | + +### Quiet output + +``` +32181@costmanagement@658292 https://hashicorp.teamcity.com/viewQueued.html?itemId=658292 +32181@mssql@658293 https://hashicorp.teamcity.com/viewQueued.html?itemId=658293 +``` + +### JSON output + +```json +[ + { + "pr": 32181, + "service": "costmanagement", + "build_number": 658292, + "url": "https://hashicorp.teamcity.com/viewQueued.html?itemId=658292" + }, + { + "pr": 32181, + "service": "mssql", + "build_number": 658293, + "url": "https://hashicorp.teamcity.com/viewQueued.html?itemId=658293" + } +] +``` ## Test Discovery diff --git a/cli/cmds.go b/cli/cmds.go index 89730c6..e5b6261 100644 --- a/cli/cmds.go +++ b/cli/cmds.go @@ -3,6 +3,7 @@ package cli import ( "errors" "fmt" + "os" "strconv" "strings" @@ -38,9 +39,22 @@ Complete documentation is available at https://github.com/katbyte/tctest`, PersistentPreRun: func(_ *cobra.Command, _ []string) { if viper.GetBool("silent") { cout.Level = cout.VerbositySilent + } else if viper.GetBool("json") { + cout.Level = cout.VerbosityJSON } else if viper.GetBool("quiet") { cout.Level = cout.VerbosityQuiet } + + // resolve legacy --buildtypeid to --build-type-id + if viper.GetString("build-type-id") == "" && viper.GetString("buildtypeid") != "" { + viper.Set("build-type-id", viper.GetString("buildtypeid")) + if !viper.GetBool("build-type-id-add-service-suffix") { + viper.Set("build-type-id-add-service-suffix", true) + } + fmt.Fprintf(os.Stderr, "WARNING: --buildtypeid/-b is deprecated and will be removed in a future version.\n") + fmt.Fprintf(os.Stderr, " Use --build-type-id instead. Note: --buildtypeid automatically appends _SERVICE\n") + fmt.Fprintf(os.Stderr, " to the build type ID. To keep this behavior, use --build-type-id-add-service-suffix.\n") + } }, RunE: func(_ *cobra.Command, _ []string) error { fmt.Printf("Run \"tctest help\" for more information about available tctest commands.\n") @@ -65,7 +79,7 @@ Complete documentation is available at https://github.com/katbyte/tctest`, Long: `For a given branch name and regex, discovers and runs acceptance tests against that branch.`, Aliases: []string{"b"}, Args: cobra.ExactArgs(2), - PreRunE: ValidateParams([]string{"server", "buildtypeid"}), + PreRunE: ValidateParams([]string{"server", "build-type-id"}), SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { branch := args[0] @@ -89,7 +103,7 @@ Complete documentation is available at https://github.com/katbyte/tctest`, Short: "triggers acceptance tests matching regex for a PR", Long: `For a given PR number, discovers and runs acceptance tests against that PR branch.`, Args: cobra.RangeArgs(1, 2), - PreRunE: ValidateParams([]string{"server", "buildtypeid", "repo", "fileregex", "splitteston"}), + PreRunE: ValidateParams([]string{"server", "build-type-id", "repo", "fileregex", "splitteston"}), SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { prs := args[0] @@ -123,7 +137,7 @@ Complete documentation is available at https://github.com/katbyte/tctest`, Short: "triggers acceptance tests for each open PR matching specified filters", Long: `TODO.`, Args: cobra.RangeArgs(0, 1), - PreRunE: ValidateParams([]string{"server", "buildtypeid", "repo", "fileregex", "splitteston"}), + PreRunE: ValidateParams([]string{"server", "build-type-id", "repo", "fileregex", "splitteston"}), SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { testRegExParam := "" @@ -168,7 +182,7 @@ Complete documentation is available at https://github.com/katbyte/tctest`, prTitles[pr.GetNumber()] = pr.GetTitle() } - cout.Printf("\n") + cout.Println() } cout.Printf("testing %d prs\n\n", len(prTitles)) @@ -222,7 +236,7 @@ Complete documentation is available at https://github.com/katbyte/tctest`, Short: "shows the test results for a specified PR #", Long: "Shows the test results for a specified PR #. If the build is still in progress, it will warn the user that results may be incomplete.", Args: cobra.RangeArgs(1, 1), - PreRunE: ValidateParams([]string{"server", "buildtypeid"}), + PreRunE: ValidateParams([]string{"server", "build-type-id"}), SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { pr, err := strconv.Atoi(args[0]) diff --git a/cli/flags.go b/cli/flags.go index 328eb78..8fb4d7a 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -15,6 +15,7 @@ type FlagData struct { RunAllTests bool Services []string Quiet bool + JSON bool Silent bool } @@ -46,16 +47,17 @@ type FlagsTeamCity struct { } type FlagsTeamCityBuild struct { - TypeID string - Parameters string - SkipQueue bool - Wait bool - Latest bool - Comment bool - ForceOldUI bool - QueueTimeout int - RunTimeout int - Tags []string + TypeID string + Parameters string + SkipQueue bool + Wait bool + Latest bool + Comment bool + ForceOldUI bool + AddServiceSuffix bool + QueueTimeout int + RunTimeout int + Tags []string } func configureFlags(root *cobra.Command) error { @@ -65,7 +67,8 @@ func configureFlags(root *cobra.Command) error { pflags.BoolVarP(&flags.OpenInBrowser, "open", "o", false, "Open the PR and build in a browser") pflags.BoolVarP(&flags.RunAllTests, "all", "", false, "run all tests when none are found by passing TestAcc") pflags.StringSliceVar(&flags.Services, "service", []string{}, "force trigger builds for specific services (comma-separated), use 'all' to trigger all services") - pflags.BoolVar(&flags.Quiet, "quiet", false, "minimal machine-readable output (pr, service, build id/url)") + pflags.BoolVar(&flags.Quiet, "quiet", false, "minimal machine-readable output (pr@service@build url)") + pflags.BoolVar(&flags.JSON, "json", false, "output build results as JSON array") pflags.BoolVar(&flags.Silent, "silent", false, "suppress all output") pflags.StringVar(&flags.GH.Token, "token-gh", "", "github oauth token (consider exporting token to GITHUB_TOKEN instead)") @@ -86,7 +89,9 @@ func configureFlags(root *cobra.Command) error { pflags.StringVarP(&flags.TC.Token, "token-tc", "t", "", "the TeamCity token to use (consider exporting token to TCTEST_TOKEN_TC instead)") pflags.StringVar(&flags.TC.User, "username", "", "the TeamCity user to use") pflags.StringVar(&flags.TC.Pass, "password", "", "the TeamCity password to use (consider exporting pass to TCTEST_PASS instead)") - pflags.StringVarP(&flags.TC.Build.TypeID, "buildtypeid", "b", "", "the TeamCity BuildTypeId to trigger") + pflags.StringVarP(&flags.TC.Build.TypeID, "buildtypeid", "b", "", "[DEPRECATED] use --build-type-id instead") + pflags.StringVar(&flags.TC.Build.TypeID, "build-type-id", "", "the TeamCity BuildTypeId to trigger") + pflags.BoolVar(&flags.TC.Build.AddServiceSuffix, "build-type-id-add-service-suffix", false, "append _SERVICE to the build type ID (legacy behavior from --buildtypeid)") pflags.StringVarP(&flags.TC.Build.Parameters, "properties", "p", "", "the TeamCity build parameters to use in 'KEY1=VALUE1;KEY2=VALUE2' format") pflags.BoolVarP(&flags.TC.Build.SkipQueue, "skip-queue", "q", false, "Put the build to the queue top") pflags.BoolVarP(&flags.TC.Build.Wait, "wait", "w", false, "Wait for the build to complete before tctest exits") @@ -100,7 +105,9 @@ func configureFlags(root *cobra.Command) error { // binding map for viper/pflag -> env m := map[string]string{ "server": "TCTEST_SERVER", - "buildtypeid": "TCTEST_BUILDTYPEID", + "buildtypeid": "TCTEST_BUILDTYPEID", + "build-type-id": "TCTEST_BUILD_TYPE_ID", + "build-type-id-add-service-suffix": "", "token-tc": "TCTEST_TOKEN_TC", "token-gh": "GITHUB_TOKEN", "username": "TCTEST_USER", @@ -112,8 +119,9 @@ func configureFlags(root *cobra.Command) error { "wait": "TCTEST_WAIT", "all": "", "service": "", - "quiet": "TCTEST_QUIET", - "silent": "TCTEST_SILENT", + "quiet": "TCTEST_OUTPUT_QUIET", + "json": "TCTEST_OUTPUT_JSON", + "silent": "TCTEST_OUTPUT_SILENT", "queue-timeout": "", "run-timeout": "", "f-authors": "", @@ -164,6 +172,7 @@ func GetFlags() FlagData { RunAllTests: viper.GetBool("all"), Services: viper.GetStringSlice("service"), Quiet: viper.GetBool("quiet"), + JSON: viper.GetBool("json"), Silent: viper.GetBool("silent"), GH: FlagsGitHub{ Repo: viper.GetString("repo"), @@ -187,13 +196,14 @@ func GetFlags() FlagData { User: viper.GetString("username"), Pass: viper.GetString("password"), Build: FlagsTeamCityBuild{ - TypeID: viper.GetString("buildtypeid"), - Parameters: viper.GetString("properties"), - SkipQueue: viper.GetBool("skip-queue"), - Wait: viper.GetBool("wait"), - Latest: viper.GetBool("latest"), - Comment: viper.GetBool("comment"), - ForceOldUI: viper.GetBool("build-link-force-old-ui"), + TypeID: viper.GetString("build-type-id"), + Parameters: viper.GetString("properties"), + SkipQueue: viper.GetBool("skip-queue"), + Wait: viper.GetBool("wait"), + Latest: viper.GetBool("latest"), + Comment: viper.GetBool("comment"), + ForceOldUI: viper.GetBool("build-link-force-old-ui"), + AddServiceSuffix: viper.GetBool("build-type-id-add-service-suffix"), QueueTimeout: viper.GetInt("queue-timeout"), RunTimeout: viper.GetInt("run-timeout"), Tags: viper.GetStringSlice("tag"), diff --git a/cli/gh-pr-filters.go b/cli/gh-pr-filters.go index 7f5f4a1..3315695 100644 --- a/cli/gh-pr-filters.go +++ b/cli/gh-pr-filters.go @@ -51,7 +51,7 @@ func (f FlagData) GetFilters() ([]Filter, error) { filters = append(filters, *titleFilter) } - cout.Printf("\n") + cout.Println() return filters, nil } diff --git a/cli/prs.go b/cli/prs.go index 1ab0990..4180da2 100644 --- a/cli/prs.go +++ b/cli/prs.go @@ -97,6 +97,8 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e cout.Printf("triggered tests for %d PRs!\n\n", ok) } + cout.FlushJSON() + return nil } @@ -159,8 +161,8 @@ func (f FlagData) triggerServiceBuild(service string, prNumber int, testRegEx st serviceInfo = "[" + service + "]" } - buildTypeID := viper.GetString("buildtypeid") - if service != "" { + buildTypeID := viper.GetString("build-type-id") + if service != "" && viper.GetBool("build-type-id-add-service-suffix") { buildTypeID += "_" + strings.ToUpper(service) } @@ -171,6 +173,7 @@ func (f FlagData) triggerServiceBuild(service string, prNumber int, testRegEx st c.Printf(" ERROR: Unable to trigger build: %v\n", err) } else { cout.Quietf("%d@%s@%d %s\n", prNumber, service, buildID, buildURL) + cout.AddResult(prNumber, service, buildID, buildURL) } - cout.Printf("\n") + cout.Println() } diff --git a/lib/cout/cout.go b/lib/cout/cout.go index 016d048..04ecbe3 100644 --- a/lib/cout/cout.go +++ b/lib/cout/cout.go @@ -1,6 +1,8 @@ package cout import ( + "encoding/json" + "fmt" "io" "os" @@ -11,12 +13,49 @@ import ( const ( VerbosityNormal = iota VerbosityQuiet + VerbosityJSON VerbositySilent ) // Level controls the output verbosity. Set before any output calls. var Level = VerbosityNormal +// BuildResult represents a single triggered build for JSON output +type BuildResult struct { + PR int `json:"pr"` + Service string `json:"service"` + BuildNumber int `json:"build_number"` + URL string `json:"url"` +} + +// jsonResults collects build results for JSON output +var jsonResults []BuildResult + +// AddResult collects a build result for JSON output +func AddResult(pr int, service string, buildNumber int, url string) { + jsonResults = append(jsonResults, BuildResult{ + PR: pr, + Service: service, + BuildNumber: buildNumber, + URL: url, + }) +} + +// FlushJSON outputs collected results as a JSON array and resets the collector +func FlushJSON() { + if Level != VerbosityJSON || len(jsonResults) == 0 { + return + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(jsonResults); err != nil { + fmt.Fprintf(os.Stderr, "error marshalling JSON: %v\n", err) + } + jsonResults = nil +} + // Writer returns the appropriate writer for normal output (os.Stdout or discard) func Writer() io.Writer { if Level >= VerbosityQuiet { @@ -25,7 +64,7 @@ func Writer() io.Writer { return os.Stdout } -// Printf prints normal output with color support (suppressed in quiet and silent modes) +// Printf prints normal output with color support (suppressed in quiet, json, and silent modes) func Printf(format string, args ...interface{}) { if Level >= VerbosityQuiet { return @@ -33,7 +72,7 @@ func Printf(format string, args ...interface{}) { c.Printf(format, args...) } -// Println prints normal output (suppressed in quiet and silent modes) +// Println prints normal output (suppressed in quiet, json, and silent modes) func Println(args ...interface{}) { if Level >= VerbosityQuiet { return @@ -41,10 +80,10 @@ func Println(args ...interface{}) { c.Println(args...) } -// Quietf prints output in quiet mode with color support (suppressed only in silent mode). +// Quietf prints output in quiet mode with color support (suppressed in json and silent modes). // Use this for the minimal machine-readable output. func Quietf(format string, args ...interface{}) { - if Level >= VerbositySilent { + if Level >= VerbosityJSON { return } c.Printf(format, args...) From e4c9cc9b8969198b4a28594b5607b61089ce526c Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 13:34:35 -0700 Subject: [PATCH 5/9] handle deprecation better --- cli/cmds.go | 15 +++------------ cli/flags.go | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/cli/cmds.go b/cli/cmds.go index e5b6261..e84b15e 100644 --- a/cli/cmds.go +++ b/cli/cmds.go @@ -3,7 +3,6 @@ package cli import ( "errors" "fmt" - "os" "strconv" "strings" @@ -36,7 +35,7 @@ func Make() (*cobra.Command, error) { Long: `A small utility to trigger acceptance tests on teamcity. It can also pull the tests to run for a PR on github Complete documentation is available at https://github.com/katbyte/tctest`, - PersistentPreRun: func(_ *cobra.Command, _ []string) { + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { if viper.GetBool("silent") { cout.Level = cout.VerbositySilent } else if viper.GetBool("json") { @@ -45,16 +44,8 @@ Complete documentation is available at https://github.com/katbyte/tctest`, cout.Level = cout.VerbosityQuiet } - // resolve legacy --buildtypeid to --build-type-id - if viper.GetString("build-type-id") == "" && viper.GetString("buildtypeid") != "" { - viper.Set("build-type-id", viper.GetString("buildtypeid")) - if !viper.GetBool("build-type-id-add-service-suffix") { - viper.Set("build-type-id-add-service-suffix", true) - } - fmt.Fprintf(os.Stderr, "WARNING: --buildtypeid/-b is deprecated and will be removed in a future version.\n") - fmt.Fprintf(os.Stderr, " Use --build-type-id instead. Note: --buildtypeid automatically appends _SERVICE\n") - fmt.Fprintf(os.Stderr, " to the build type ID. To keep this behavior, use --build-type-id-add-service-suffix.\n") - } + // TODO: remove once --buildtypeid is removed + return resolveBuildTypeID() }, RunE: func(_ *cobra.Command, _ []string) error { fmt.Printf("Run \"tctest help\" for more information about available tctest commands.\n") diff --git a/cli/flags.go b/cli/flags.go index 8fb4d7a..b710d2c 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -2,12 +2,37 @@ package cli import ( "fmt" + "os" "time" "github.com/spf13/cobra" "github.com/spf13/viper" ) +// resolveBuildTypeID handles the legacy --buildtypeid to --build-type-id migration. +// It errors if both are set, and copies the old value to the new key when only the old one is used. +// Called from PersistentPreRunE before ValidateParams so the resolved value is available for validation. +func resolveBuildTypeID() error { + oldSet := viper.GetString("buildtypeid") != "" + newSet := viper.GetString("build-type-id") != "" + + if oldSet && newSet { + return fmt.Errorf("cannot use both --buildtypeid and --build-type-id; --buildtypeid is deprecated, use --build-type-id only") + } + + if oldSet && !newSet { + viper.Set("build-type-id", viper.GetString("buildtypeid")) + if !viper.GetBool("build-type-id-add-service-suffix") { + viper.Set("build-type-id-add-service-suffix", true) + } + fmt.Fprintf(os.Stderr, "WARNING: --buildtypeid/-b is deprecated and will be removed in a future version.\n") + fmt.Fprintf(os.Stderr, " Use --build-type-id instead. Note: --buildtypeid automatically appends _SERVICE\n") + fmt.Fprintf(os.Stderr, " to the build type ID. To keep this behavior, use --build-type-id-add-service-suffix.\n") + } + + return nil +} + type FlagData struct { GH FlagsGitHub TC FlagsTeamCity From aed2609193403d7e14a0326d42b1964f5ca5b6bd Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 13:36:04 -0700 Subject: [PATCH 6/9] fix lint --- cli/cmds.go | 7 ++-- cli/flags.go | 101 ++++++++++++++++++++++++++------------------------- cli/prs.go | 2 - 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/cli/cmds.go b/cli/cmds.go index e84b15e..49a8bc2 100644 --- a/cli/cmds.go +++ b/cli/cmds.go @@ -36,11 +36,12 @@ func Make() (*cobra.Command, error) { It can also pull the tests to run for a PR on github Complete documentation is available at https://github.com/katbyte/tctest`, PersistentPreRunE: func(_ *cobra.Command, _ []string) error { - if viper.GetBool("silent") { + switch { + case viper.GetBool("silent"): cout.Level = cout.VerbositySilent - } else if viper.GetBool("json") { + case viper.GetBool("json"): cout.Level = cout.VerbosityJSON - } else if viper.GetBool("quiet") { + case viper.GetBool("quiet"): cout.Level = cout.VerbosityQuiet } diff --git a/cli/flags.go b/cli/flags.go index b710d2c..d69a79a 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "os" "time" @@ -17,7 +18,7 @@ func resolveBuildTypeID() error { newSet := viper.GetString("build-type-id") != "" if oldSet && newSet { - return fmt.Errorf("cannot use both --buildtypeid and --build-type-id; --buildtypeid is deprecated, use --build-type-id only") + return errors.New("cannot use both --buildtypeid and --build-type-id; --buildtypeid is deprecated, use --build-type-id only") } if oldSet && !newSet { @@ -27,7 +28,7 @@ func resolveBuildTypeID() error { } fmt.Fprintf(os.Stderr, "WARNING: --buildtypeid/-b is deprecated and will be removed in a future version.\n") fmt.Fprintf(os.Stderr, " Use --build-type-id instead. Note: --buildtypeid automatically appends _SERVICE\n") - fmt.Fprintf(os.Stderr, " to the build type ID. To keep this behavior, use --build-type-id-add-service-suffix.\n") + fmt.Fprintf(os.Stderr, " to the build type ID. To keep this behaviour, use --build-type-id-add-service-suffix.\n") } return nil @@ -72,17 +73,17 @@ type FlagsTeamCity struct { } type FlagsTeamCityBuild struct { - TypeID string - Parameters string - SkipQueue bool - Wait bool - Latest bool - Comment bool - ForceOldUI bool - AddServiceSuffix bool - QueueTimeout int - RunTimeout int - Tags []string + TypeID string + Parameters string + SkipQueue bool + Wait bool + Latest bool + Comment bool + ForceOldUI bool + AddServiceSuffix bool + QueueTimeout int + RunTimeout int + Tags []string } func configureFlags(root *cobra.Command) error { @@ -116,7 +117,7 @@ func configureFlags(root *cobra.Command) error { pflags.StringVar(&flags.TC.Pass, "password", "", "the TeamCity password to use (consider exporting pass to TCTEST_PASS instead)") pflags.StringVarP(&flags.TC.Build.TypeID, "buildtypeid", "b", "", "[DEPRECATED] use --build-type-id instead") pflags.StringVar(&flags.TC.Build.TypeID, "build-type-id", "", "the TeamCity BuildTypeId to trigger") - pflags.BoolVar(&flags.TC.Build.AddServiceSuffix, "build-type-id-add-service-suffix", false, "append _SERVICE to the build type ID (legacy behavior from --buildtypeid)") + pflags.BoolVar(&flags.TC.Build.AddServiceSuffix, "build-type-id-add-service-suffix", false, "append _SERVICE to the build type ID (legacy behaviour from --buildtypeid)") pflags.StringVarP(&flags.TC.Build.Parameters, "properties", "p", "", "the TeamCity build parameters to use in 'KEY1=VALUE1;KEY2=VALUE2' format") pflags.BoolVarP(&flags.TC.Build.SkipQueue, "skip-queue", "q", false, "Put the build to the queue top") pflags.BoolVarP(&flags.TC.Build.Wait, "wait", "w", false, "Wait for the build to complete before tctest exits") @@ -129,40 +130,40 @@ func configureFlags(root *cobra.Command) error { // binding map for viper/pflag -> env m := map[string]string{ - "server": "TCTEST_SERVER", - "buildtypeid": "TCTEST_BUILDTYPEID", - "build-type-id": "TCTEST_BUILD_TYPE_ID", + "server": "TCTEST_SERVER", + "buildtypeid": "TCTEST_BUILDTYPEID", + "build-type-id": "TCTEST_BUILD_TYPE_ID", "build-type-id-add-service-suffix": "", - "token-tc": "TCTEST_TOKEN_TC", - "token-gh": "GITHUB_TOKEN", - "username": "TCTEST_USER", - "password": "TCTEST_PASS", - "properties": "TCTEST_PROPERTIES", - "repo": "TCTEST_REPO", - "fileregex": "TCTEST_FILEREGEX", - "splitteston": "TCTEST_SPLIT_TESTS_ON", - "wait": "TCTEST_WAIT", - "all": "", - "service": "", - "quiet": "TCTEST_OUTPUT_QUIET", - "json": "TCTEST_OUTPUT_JSON", - "silent": "TCTEST_OUTPUT_SILENT", - "queue-timeout": "", - "run-timeout": "", - "f-authors": "", - "f-milestone": "", - "f-labels-all": "", - "f-labels-any": "", - "f-created-time": "", - "f-updated-time": "", - "f-title-regex": "", - "f-drafts": "", - "latest": "TCTEST_LATESTBUILD", - "skip-queue": "TCTEST_SKIP_QUEUE", - "open": "TCTEST_OPEN_BROWSER", - "comment": "TCTEST_COMMENT", - "build-link-force-old-ui": "TCTEST_FORCE_OLD_UI", - "tag": "TCTEST_BUILD_TAGS", + "token-tc": "TCTEST_TOKEN_TC", + "token-gh": "GITHUB_TOKEN", + "username": "TCTEST_USER", + "password": "TCTEST_PASS", + "properties": "TCTEST_PROPERTIES", + "repo": "TCTEST_REPO", + "fileregex": "TCTEST_FILEREGEX", + "splitteston": "TCTEST_SPLIT_TESTS_ON", + "wait": "TCTEST_WAIT", + "all": "", + "service": "", + "quiet": "TCTEST_OUTPUT_QUIET", + "json": "TCTEST_OUTPUT_JSON", + "silent": "TCTEST_OUTPUT_SILENT", + "queue-timeout": "", + "run-timeout": "", + "f-authors": "", + "f-milestone": "", + "f-labels-all": "", + "f-labels-any": "", + "f-created-time": "", + "f-updated-time": "", + "f-title-regex": "", + "f-drafts": "", + "latest": "TCTEST_LATESTBUILD", + "skip-queue": "TCTEST_SKIP_QUEUE", + "open": "TCTEST_OPEN_BROWSER", + "comment": "TCTEST_COMMENT", + "build-link-force-old-ui": "TCTEST_FORCE_OLD_UI", + "tag": "TCTEST_BUILD_TAGS", } for name, env := range m { @@ -229,9 +230,9 @@ func GetFlags() FlagData { Comment: viper.GetBool("comment"), ForceOldUI: viper.GetBool("build-link-force-old-ui"), AddServiceSuffix: viper.GetBool("build-type-id-add-service-suffix"), - QueueTimeout: viper.GetInt("queue-timeout"), - RunTimeout: viper.GetInt("run-timeout"), - Tags: viper.GetStringSlice("tag"), + QueueTimeout: viper.GetInt("queue-timeout"), + RunTimeout: viper.GetInt("run-timeout"), + Tags: viper.GetStringSlice("tag"), }, }, } diff --git a/cli/prs.go b/cli/prs.go index 4180da2..f071a0f 100644 --- a/cli/prs.go +++ b/cli/prs.go @@ -55,8 +55,6 @@ func (f FlagData) GetAndRunPrsTests(prs map[int]string, testRegExParam string) e continue } - - // trigger a build for each service for s, tests := range *serviceTests { // if --service is set, skip services not in the filter From 558e7de9f3b2793f11839c68009a07bffa005859 Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 13:39:08 -0700 Subject: [PATCH 7/9] update comment --- cli/flags.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/flags.go b/cli/flags.go index d69a79a..a465110 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -11,7 +11,8 @@ import ( ) // resolveBuildTypeID handles the legacy --buildtypeid to --build-type-id migration. -// It errors if both are set, and copies the old value to the new key when only the old one is used. +// It errors if both are set. When only the old flag is used, it copies the value to +// build-type-id and enables build-type-id-add-service-suffix to maintain the old behaviour. // Called from PersistentPreRunE before ValidateParams so the resolved value is available for validation. func resolveBuildTypeID() error { oldSet := viper.GetString("buildtypeid") != "" From 22518948b9f7c3ed041a1828bcdfe0685cc2b849 Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 14:02:20 -0700 Subject: [PATCH 8/9] fix build type id legacy --- cli/flags.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/flags.go b/cli/flags.go index a465110..e5c63fd 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -75,6 +75,7 @@ type FlagsTeamCity struct { type FlagsTeamCityBuild struct { TypeID string + LegacyTypeID string // deprecated --buildtypeid, resolved in resolveBuildTypeID() Parameters string SkipQueue bool Wait bool @@ -116,7 +117,7 @@ func configureFlags(root *cobra.Command) error { pflags.StringVarP(&flags.TC.Token, "token-tc", "t", "", "the TeamCity token to use (consider exporting token to TCTEST_TOKEN_TC instead)") pflags.StringVar(&flags.TC.User, "username", "", "the TeamCity user to use") pflags.StringVar(&flags.TC.Pass, "password", "", "the TeamCity password to use (consider exporting pass to TCTEST_PASS instead)") - pflags.StringVarP(&flags.TC.Build.TypeID, "buildtypeid", "b", "", "[DEPRECATED] use --build-type-id instead") + pflags.StringVarP(&flags.TC.Build.LegacyTypeID, "buildtypeid", "b", "", "[DEPRECATED] use --build-type-id instead") pflags.StringVar(&flags.TC.Build.TypeID, "build-type-id", "", "the TeamCity BuildTypeId to trigger") pflags.BoolVar(&flags.TC.Build.AddServiceSuffix, "build-type-id-add-service-suffix", false, "append _SERVICE to the build type ID (legacy behaviour from --buildtypeid)") pflags.StringVarP(&flags.TC.Build.Parameters, "properties", "p", "", "the TeamCity build parameters to use in 'KEY1=VALUE1;KEY2=VALUE2' format") From 547d0834969e2c5c9af69229931ba42e21178e29 Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 1 May 2026 14:07:11 -0700 Subject: [PATCH 9/9] actually fix bug --- cli/cmds.go | 4 ++-- cli/flags.go | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/cli/cmds.go b/cli/cmds.go index 49a8bc2..2e077f0 100644 --- a/cli/cmds.go +++ b/cli/cmds.go @@ -35,7 +35,7 @@ func Make() (*cobra.Command, error) { Long: `A small utility to trigger acceptance tests on teamcity. It can also pull the tests to run for a PR on github Complete documentation is available at https://github.com/katbyte/tctest`, - PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { switch { case viper.GetBool("silent"): cout.Level = cout.VerbositySilent @@ -46,7 +46,7 @@ Complete documentation is available at https://github.com/katbyte/tctest`, } // TODO: remove once --buildtypeid is removed - return resolveBuildTypeID() + return resolveBuildTypeID(cmd) }, RunE: func(_ *cobra.Command, _ []string) error { fmt.Printf("Run \"tctest help\" for more information about available tctest commands.\n") diff --git a/cli/flags.go b/cli/flags.go index e5c63fd..3878e78 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -14,15 +14,17 @@ import ( // It errors if both are set. When only the old flag is used, it copies the value to // build-type-id and enables build-type-id-add-service-suffix to maintain the old behaviour. // Called from PersistentPreRunE before ValidateParams so the resolved value is available for validation. -func resolveBuildTypeID() error { - oldSet := viper.GetString("buildtypeid") != "" - newSet := viper.GetString("build-type-id") != "" +func resolveBuildTypeID(cmd *cobra.Command) error { + oldFlagSet := cmd.Flags().Changed("buildtypeid") + newFlagSet := cmd.Flags().Changed("build-type-id") - if oldSet && newSet { + // error only when both CLI flags are explicitly provided + if oldFlagSet && newFlagSet { return errors.New("cannot use both --buildtypeid and --build-type-id; --buildtypeid is deprecated, use --build-type-id only") } - if oldSet && !newSet { + // explicit --buildtypeid CLI flag: copy to build-type-id and enable service suffix + if oldFlagSet && !newFlagSet { viper.Set("build-type-id", viper.GetString("buildtypeid")) if !viper.GetBool("build-type-id-add-service-suffix") { viper.Set("build-type-id-add-service-suffix", true) @@ -30,6 +32,15 @@ func resolveBuildTypeID() error { fmt.Fprintf(os.Stderr, "WARNING: --buildtypeid/-b is deprecated and will be removed in a future version.\n") fmt.Fprintf(os.Stderr, " Use --build-type-id instead. Note: --buildtypeid automatically appends _SERVICE\n") fmt.Fprintf(os.Stderr, " to the build type ID. To keep this behaviour, use --build-type-id-add-service-suffix.\n") + return nil + } + + // no explicit CLI flags: fall back to env vars + if viper.GetString("build-type-id") == "" && viper.GetString("buildtypeid") != "" { + viper.Set("build-type-id", viper.GetString("buildtypeid")) + if !viper.GetBool("build-type-id-add-service-suffix") { + viper.Set("build-type-id-add-service-suffix", true) + } } return nil