From d7c3a8991484f0174532ec5e5343bb5643bc39bd Mon Sep 17 00:00:00 2001 From: Ken Thompson Date: Wed, 25 Mar 2026 23:27:05 +1100 Subject: [PATCH 1/3] Add internal/logs client wrapper for buildkite-logs library Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 62 +++++++++++- go.sum | 202 +++++++++++++++++++++++++++++++++++++--- internal/logs/client.go | 16 ++++ 3 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 internal/logs/client.go diff --git a/go.mod b/go.mod index 3d95934c..2551efc0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/alecthomas/kong v1.14.0 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be + github.com/buildkite/buildkite-logs v0.8.0 github.com/buildkite/go-buildkite/v4 v4.16.0 github.com/buildkite/roko v1.4.0 github.com/go-git/go-git/v5 v5.17.0 @@ -22,15 +23,66 @@ 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/andybalholm/brotli v1.2.0 // indirect + github.com/apache/arrow-go/v18 v18.5.2 // indirect + github.com/apache/thrift v0.22.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.2 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.2 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect + github.com/google/wire v0.7.0 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/klauspost/asmfmt v1.3.2 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect + github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + gocloud.dev v0.45.0 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.256.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) @@ -57,12 +109,12 @@ require ( 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.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 2490e184..607c4294 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,27 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4= +cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= 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= @@ -21,39 +43,99 @@ github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+W 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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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/apache/arrow-go/v18 v18.5.2 h1:3uoHjoaEie5eVsxx/Bt64hKwZx4STb+beAkqKOlq/lY= +github.com/apache/arrow-go/v18 v18.5.2/go.mod h1:yNoizNTT4peTciJ7V01d2EgOkE1d0fQ1vZcFOsVtFsw= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= 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/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk= +github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 h1:Zy6Tme1AA13kX8x3CnkHx5cqdGWGaj/anwOiWGnA0Xo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12/go.mod h1:ql4uXYKoTM9WUAUSmthY4AtPVrlTBZOvnBJTiCUdPxI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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/buildkite-logs v0.8.0 h1:Zp+lIZDD4Ny3/fnGjAkR3gjDNG68sSqQKv/DnTKzCrw= +github.com/buildkite/buildkite-logs v0.8.0/go.mod h1:32+BbDpjJAzL3yH/qkr8OTkMtlXPUFWlkp34NcM43dM= github.com/buildkite/go-buildkite/v4 v4.16.0 h1:uRZmOg6zfZOCpak1tizzlv9pq8Syt7WmeEb0Ov7r1NE= github.com/buildkite/go-buildkite/v4 v4.16.0/go.mod h1:8+7GiWBKwEPAWoZnRU/kpNCt46j1iVH8kFMMbD4YDfc= github.com/buildkite/roko v1.4.0 h1:DxixoCdpNqxu4/1lXrXbfsKbJSd7r1qoxtef/TT2J80= github.com/buildkite/roko v1.4.0/go.mod h1:0vbODqUFEcVf4v2xVXRfZZRsqJVsCCHTG/TBRByGK4E= 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.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/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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= @@ -64,6 +146,13 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj 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.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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= @@ -72,13 +161,33 @@ 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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 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/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= +github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= +github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= +github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= 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= @@ -89,6 +198,12 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl 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/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +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= @@ -100,16 +215,25 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posthog/posthog-go v1.11.1 h1:P0MHlerMW9rNpjW+1szNsJ5HbdYJUv/9lF2DWZCHztE= github.com/posthog/posthog-go v1.11.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -121,6 +245,8 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB 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/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= 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= @@ -141,18 +267,46 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo 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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY= +gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -165,15 +319,35 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= +google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= +google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846 h1:dDbsTLIK7EzwUq36kCSAsk0slouq/S0tWHeeGi97cD8= +google.golang.org/genproto v0.0.0-20251124214823-79d6a2a48846/go.mod h1:PP0g88Dz3C7hRAfbQCQggeWAXjuqGsNPLE4s7jh0RGU= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= diff --git a/internal/logs/client.go b/internal/logs/client.go new file mode 100644 index 00000000..e9c3e1bb --- /dev/null +++ b/internal/logs/client.go @@ -0,0 +1,16 @@ +package logs + +import ( + "context" + "os" + + buildkitelogs "github.com/buildkite/buildkite-logs" + buildkite "github.com/buildkite/go-buildkite/v4" +) + +// NewClient creates a buildkite-logs client using the provided REST API client. +// Cache storage defaults to ~/.bklog; override with the BKLOG_CACHE_URL env var. +func NewClient(ctx context.Context, restClient *buildkite.Client, opts ...buildkitelogs.ClientOption) (*buildkitelogs.Client, error) { + storageURL := os.Getenv("BKLOG_CACHE_URL") + return buildkitelogs.NewClient(ctx, restClient, storageURL, opts...) +} From 1e4ce5743e4668bc2ef0c71d71d6f544c5655749 Mon Sep 17 00:00:00 2001 From: Ken Thompson Date: Wed, 25 Mar 2026 23:27:18 +1100 Subject: [PATCH 2/3] Rewrite bk job log with Parquet-backed reads, follow mode, URL input, and typed errors The old `bk job log` fetched the entire log via REST and dumped it through a pager. Fine for small jobs, useless for a 50,000-line test suite failure at 2am. This rewrites the command on top of the buildkite-logs library (same backend as the MCP server), which downloads logs once, converts to Parquet, and caches locally for fast columnar reads. This brings feature parity between the CLI and the MCP server for log access -- increasingly important as LLM-based tools bias toward CLI commands when MCP isn't explicitly configured. This will also be a dependency for official Buildkite agentic skills shipping shortly. What changed: - Read/tail/follow modes: full log with pager, --tail N for last N lines, --follow polls every 2s for running jobs and exits when the job finishes. Auto-follow when TTY + running job + no explicit flags. - Buildkite URL input: paste a URL from the web UI or Slack and it extracts org/pipeline/build/job. Handles Slack wrapping. Build-only URLs (no #fragment) fall through to the job picker. - Step key resolution with parallel matrix support: --step test picks the job by pipeline.yml key. When multiple parallel jobs match the same key, shows the interactive picker instead of silently returning the first. - Time filtering: --since 5m, --until 2026-01-15T10:00:00Z, or both. Works with tail, read, and follow modes. Duration values pin to invocation time so filtering is deterministic across the log. - JSON output: --json emits one JSON object per line (JSONL) with row_number, timestamp, content, and group. Replaces the old OutputFlags embed that exposed --yaml/--text/--output flags which silently did nothing. - Typed errors: all user-facing errors now use the CLI's error type system. Flag conflicts exit 2 (validation), missing resources exit 4 (not found), API failures exit 3 with status-code-specific messages and suggestions. - Group filtering: --group "Running tests" shows only log lines within a Buildkite --- group section. - Pager integration: full-log reads go through less -R (respects PAGER env, --no-pager, and config). Tail, follow, and JSON skip the pager. Non-TTY disables pager, color, auto-follow, and the spinner. Bug fix: follow mode with --tail on a job with 0 log rows crashed because SeekToRow(0) failed on an empty Parquet file. Added a row count guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/job/log.go | 748 ++++++++++++++++++++++++++++++++++-- cmd/job/log_test.go | 907 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1627 insertions(+), 28 deletions(-) create mode 100644 cmd/job/log_test.go diff --git a/cmd/job/log.go b/cmd/job/log.go index db702492..be87a85c 100644 --- a/cmd/job/log.go +++ b/cmd/job/log.go @@ -2,41 +2,144 @@ package job import ( "context" + "encoding/json" + "errors" "fmt" + "io" + "os" + "os/signal" "regexp" + "strings" + "syscall" + "time" "github.com/alecthomas/kong" + buildkitelogs "github.com/buildkite/buildkite-logs" 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" + "github.com/buildkite/cli/v3/internal/logs" 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" ) 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"` + // Positional arguments + JobID string `arg:"" optional:"" help:"Job UUID or Buildkite URL (interactive picker if omitted)"` + + // Pipeline/build/job resolution + 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"` + Step string `help:"Step key from pipeline.yml to get logs for" short:"s"` + + // Reading flags + Seek int `help:"Start reading from row N (0-based)" default:"-1"` + Limit int `help:"Maximum number of lines to output" default:"0"` + Tail int `help:"Show last N lines" short:"n" default:"0"` + Follow bool `help:"Follow log output for running jobs (poll every 2s)" short:"f"` + Since string `help:"Show logs after this time (e.g. 5m, 2h, or RFC3339 timestamp)" short:"S"` + Until string `help:"Show logs before this time (e.g. 5m, 2h, or RFC3339 timestamp)" short:"U"` + + // Filter flags + Group string `help:"Filter logs to entries in a specific group/section" short:"G"` + + // Display flags + Timestamps bool `help:"Prefix each line with a human-readable timestamp" short:"t"` + NoTimestamps bool `help:"Strip timestamp prefixes from log output" name:"no-timestamps"` + + // Output format + JSON bool `help:"Output as JSON (one object per line)" name:"json"` + + // Cached parsed time values (set once in Run, used per-row in entryInTimeRange) + sinceTime time.Time `kong:"-"` + untilTime time.Time `kong:"-"` } func (c *LogCmd) Help() string { return ` Examples: - # Get a job's logs by UUID (requires --pipeline and --build) + # Get a job's full log $ 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 + # Get logs from a Buildkite URL (copy-paste from web UI or Slack) + $ bk logs https://buildkite.com/my-org/my-pipeline/builds/123#0190046e-e199-453b-a302-a21a4d649d31 + + # Build URL without job fragment (opens job picker) + $ bk logs https://buildkite.com/my-org/my-pipeline/builds/123 + + # Get logs by step key (from pipeline.yml) + $ bk job log -p my-pipeline -b 123 --step "test-suite" + + # Interactive job picker (omit job ID) + $ bk job log -p my-pipeline -b 123 + + # Show last 50 lines + $ bk job log JOB_ID -b 123 -n 50 + + # Follow a running job's log output + $ bk job log JOB_ID -b 123 -f + + # Follow and search for errors (pipe to grep) + $ bk job log JOB_ID -b 123 -f | grep -i "error\|panic" + + # Search with context (pipe to grep) + $ bk job log JOB_ID -b 123 | grep -C 3 "error\|failed" + + # Show logs from the last 10 minutes + $ bk job log JOB_ID -b 123 --since 10m - # Strip timestamp prefixes from output - $ bk job log 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 --no-timestamps + # Show logs between two timestamps + $ bk job log JOB_ID -b 123 --since 2024-01-15T10:00:00Z --until 2024-01-15T10:05:00Z + + # Show human-readable timestamps + $ bk job log JOB_ID -b 123 -t + + # Filter to a specific group/section + $ bk job log JOB_ID -b 123 -G "Running tests" + + # Output as JSON lines (for piping to jq) + $ bk job log JOB_ID -b 123 --json | jq '.content' + + # Paginated read (rows 100-200) + $ bk job log JOB_ID -b 123 --seek 100 --limit 100 + + # Add line numbers (pipe to nl or cat -n) + $ bk job log JOB_ID -b 123 | cat -n ` } func (c *LogCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + // If the positional arg is a Buildkite URL, extract org/pipeline/build/job from it. + if parsed := parseJobURL(c.JobID); parsed != nil { + if c.Pipeline != "" || c.BuildNumber != "" { + return bkErrors.NewValidationError( + fmt.Errorf("cannot use --pipeline or --build with a Buildkite URL"), + "the URL already contains the pipeline and build number", + ) + } + c.Pipeline = parsed.org + "/" + parsed.pipeline + c.BuildNumber = parsed.buildNumber + c.JobID = parsed.jobID + } + + if err := c.validateFlags(); err != nil { + return err + } + + // Cache parsed time values once so entryInTimeRange doesn't re-parse per row. + // For duration-based values (e.g. "5m"), this pins time.Now() to invocation time, + // ensuring deterministic filtering across the entire log. + if c.Since != "" { + c.sinceTime, _ = parseTimeFlag(c.Since) + } + if c.Until != "" { + c.untilTime, _ = parseTimeFlag(c.Until) + } + f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { return err @@ -77,41 +180,630 @@ func (c *LogCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { return err } if bld == nil { - return fmt.Errorf("no build found") + return bkErrors.NewResourceNotFoundError(nil, "no build found", + "Check the build number and pipeline are correct", + "Run 'bk build list' to see recent builds", + ) + } + + // Resolve job: by step key, by positional job ID, or interactive picker + var jobLabel string + switch { + case c.Step != "": + picked, err := c.resolveJobByStepKey(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber)) + if err != nil { + return err + } + c.JobID = picked.id + jobLabel = picked.label + case c.JobID == "": + picked, err := c.pickJob(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber)) + if err != nil { + return err + } + c.JobID = picked.id + jobLabel = picked.label + } + + // Create buildkite-logs client + logsClient, err := logs.NewClient(ctx, f.RestAPIClient) + if err != nil { + return bkErrors.WrapAPIError(err, "creating logs client") + } + defer logsClient.Close() + + org := bld.Organization + pipeline := bld.Pipeline + build := fmt.Sprint(bld.BuildNumber) + + // Auto-follow when no explicit mode was requested and the job is still running. + if c.shouldAutoFollow() && bkIO.IsTTY() { + state, err := c.jobState(ctx, f, org, pipeline, build, c.JobID) + if err == nil && !buildkitelogs.IsTerminalState(buildkitelogs.JobState(state)) { + if jobLabel != "" { + fmt.Fprintf(os.Stderr, "Job '%s' is still running — following log output (Ctrl-C to stop)...\n", jobLabel) + } else { + fmt.Fprintln(os.Stderr, "Job is still running — following log output (Ctrl-C to stop)...") + } + c.Follow = true + } + } + + // Dispatch to the appropriate mode. + // Only unbounded full-log read uses the pager; tail and follow write directly to stdout. + switch { + case c.Follow: + return c.followMode(ctx, f, logsClient, org, pipeline, build, c.JobID) + case c.Tail > 0: + return c.tailMode(ctx, f, logsClient, org, pipeline, build, c.JobID) + default: + return c.readMode(ctx, f, logsClient, org, pipeline, build, c.JobID) + } +} + +func (c *LogCmd) validateFlags() error { + if c.Step != "" && c.JobID != "" { + return bkErrors.NewValidationError( + fmt.Errorf("--step and a positional job ID are mutually exclusive"), + "use either --step or a job ID, not both", + ) + } + if c.Tail > 0 && c.Seek >= 0 { + return bkErrors.NewValidationError( + fmt.Errorf("--tail and --seek are mutually exclusive"), + "use --tail to see the last N lines, or --seek to start from a specific row", + ) + } + if c.Follow && c.Seek >= 0 { + return bkErrors.NewValidationError( + fmt.Errorf("--follow and --seek cannot be used together"), + "use --follow to stream new output, or --seek to read from a specific offset", + ) + } + if c.Timestamps && c.NoTimestamps { + return bkErrors.NewValidationError( + fmt.Errorf("--timestamps and --no-timestamps are mutually exclusive"), + "use one or the other", + ) + } + if (c.Since != "" || c.Until != "") && c.Seek >= 0 { + return bkErrors.NewValidationError( + fmt.Errorf("--since/--until and --seek are mutually exclusive"), + "use time-based filtering or row-based seeking, not both", + ) + } + if c.Follow && c.Until != "" { + return bkErrors.NewValidationError( + fmt.Errorf("--follow and --until cannot be used together"), + "--follow streams indefinitely; --until sets an end time", + ) + } + if c.Since != "" { + if _, err := parseTimeFlag(c.Since); err != nil { + return bkErrors.NewValidationError( + fmt.Errorf("invalid --since value: %w", err), + "expected a duration (e.g. 5m, 2h) or RFC3339 timestamp", + ) + } + } + if c.Until != "" { + if _, err := parseTimeFlag(c.Until); err != nil { + return bkErrors.NewValidationError( + fmt.Errorf("invalid --until value: %w", err), + "expected a duration (e.g. 5m, 2h) or RFC3339 timestamp", + ) + } + } + return nil +} + +// shouldAutoFollow returns true when no explicit mode flags were set, +// meaning the command should check job state and auto-follow if running. +func (c *LogCmd) shouldAutoFollow() bool { + return !c.Follow && c.Tail <= 0 && c.Seek < 0 && c.Limit <= 0 && c.Since == "" && c.Until == "" +} + +// parseTimeFlag parses a time value that is either a Go duration string (relative to now) +// or an RFC3339 timestamp (absolute). +func parseTimeFlag(value string) (time.Time, error) { + // Try as a duration first (e.g. "5m", "2h", "30s") + if d, err := time.ParseDuration(value); err == nil { + return time.Now().Add(-d), nil + } + // Try as RFC3339 timestamp + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t, nil + } + return time.Time{}, fmt.Errorf("must be a duration (e.g. 5m, 2h) or RFC3339 timestamp (e.g. 2024-01-15T10:00:00Z)") +} + +// entryInTimeRange checks whether an entry's timestamp falls within the --since/--until range. +// Uses sinceTime/untilTime cached in Run() to ensure deterministic filtering. +func (c *LogCmd) entryInTimeRange(entry *buildkitelogs.ParquetLogEntry) bool { + if c.Since == "" && c.Until == "" { + return true + } + ts := entry.Timestamp // unix millis + if c.Since != "" && ts < c.sinceTime.UnixMilli() { + return false + } + if c.Until != "" && ts > c.untilTime.UnixMilli() { + return false + } + return true +} + +type cmdJob struct { + id string + label string + state string +} + +// buildJobLabels creates display labels for job picker options. +// Duplicate labels are disambiguated with a short job ID suffix. +func buildJobLabels(jobs []cmdJob) []string { + labels := make([]string, len(jobs)) + for i, j := range jobs { + labels[i] = fmt.Sprintf("%s (%s)", j.label, j.state) + } + seen := make(map[string]int) + for _, l := range labels { + seen[l]++ + } + for i, l := range labels { + if seen[l] > 1 { + shortID := jobs[i].id + if len(shortID) > 8 { + shortID = shortID[:8] + } + labels[i] = fmt.Sprintf("%s [%s]", l, shortID) + } + } + return labels +} + +func (c *LogCmd) pickJob(ctx context.Context, f *factory.Factory, org, pipeline, buildNumber string) (cmdJob, error) { + buildInfo, _, err := f.RestAPIClient.Builds.Get(ctx, org, pipeline, buildNumber, nil) + if err != nil { + return cmdJob{}, bkErrors.WrapAPIError(err, "fetching build to list jobs") + } + + // Filter to command jobs only + var commandJobs []cmdJob + for _, j := range buildInfo.Jobs { + if j.Type != "script" { + continue + } + label := j.Label + if label == "" { + label = j.Name + } + if label == "" { + label = j.Command + } + if len(label) > 60 { + label = label[:57] + "..." + } + commandJobs = append(commandJobs, cmdJob{id: j.ID, label: label, state: j.State}) + } + + if len(commandJobs) == 0 { + return cmdJob{}, bkErrors.NewResourceNotFoundError(nil, + fmt.Sprintf("no command jobs found in build %s", buildNumber), + "The build may only contain non-command steps (wait, block, trigger)", + ) + } + + // Auto-select if only one job + if len(commandJobs) == 1 { + return commandJobs[0], nil + } + + labels := buildJobLabels(commandJobs) + + chosen, err := bkIO.PromptForOne("job", labels, f.NoInput) + if err != nil { + return cmdJob{}, err + } + + // Find the matching job by label + for i, label := range labels { + if label == chosen { + return commandJobs[i], nil + } + } + + return cmdJob{}, fmt.Errorf("could not match job selection") +} + +func (c *LogCmd) resolveJobByStepKey(ctx context.Context, f *factory.Factory, org, pipeline, buildNumber string) (cmdJob, error) { + buildInfo, _, err := f.RestAPIClient.Builds.Get(ctx, org, pipeline, buildNumber, nil) + if err != nil { + return cmdJob{}, bkErrors.WrapAPIError(err, "fetching build to resolve step key") } - var logContent string - err = bkIO.SpinWhile(f, "Fetching job log", func() { - jobLog, _, apiErr := f.RestAPIClient.Jobs.GetJobLog( - ctx, - bld.Organization, - bld.Pipeline, - fmt.Sprint(bld.BuildNumber), - c.JobID, + var matches []cmdJob + for _, j := range buildInfo.Jobs { + if j.StepKey != c.Step { + continue + } + label := j.Label + if label == "" { + label = j.Name + } + if label == "" { + label = j.Command + } + // Append parallel index to label when present (e.g. "rspec #3") + if j.ParallelGroupIndex != nil { + label = fmt.Sprintf("%s #%d", label, *j.ParallelGroupIndex) + } + if len(label) > 60 { + label = label[:57] + "..." + } + matches = append(matches, cmdJob{id: j.ID, label: label, state: j.State}) + } + + if len(matches) == 0 { + return cmdJob{}, bkErrors.NewResourceNotFoundError(nil, + fmt.Sprintf("no job found with step key %q in build %s", c.Step, buildNumber), + "Check the step key matches your pipeline.yml", + "Run 'bk job list' to see available jobs in this build", ) - if apiErr != nil { - err = apiErr - return + } + + // Auto-select if only one match + if len(matches) == 1 { + return matches[0], nil + } + + // Multiple matches (parallel matrix) — use interactive picker + labels := buildJobLabels(matches) + chosen, err := bkIO.PromptForOne("job", labels, f.NoInput) + if err != nil { + return cmdJob{}, err + } + for i, label := range labels { + if label == chosen { + return matches[i], nil } - logContent = jobLog.Content + } + + return cmdJob{}, fmt.Errorf("could not match job selection") +} + +func (c *LogCmd) readMode(ctx context.Context, f *factory.Factory, logsClient *buildkitelogs.Client, org, pipeline, build, jobID string) error { + var reader *buildkitelogs.ParquetReader + var readerErr error + _ = bkIO.SpinWhile(f, "Fetching job log", func() { + reader, readerErr = logsClient.NewReader(ctx, org, pipeline, build, jobID, 30*time.Second, false) }) + err := readerErr if err != nil { - return err + return c.handleLogError(err) + } + defer reader.Close() + + var entryIter func(func(buildkitelogs.ParquetLogEntry, error) bool) + switch { + case c.Seek >= 0: + entryIter = reader.SeekToRow(int64(c.Seek)) + case c.Group != "": + entryIter = reader.FilterByGroupIter(c.Group) + default: + entryIter = reader.ReadEntriesIter() + } + + // Use pager only for unbounded full-log reads (not JSON output) + usePager := c.Limit <= 0 && c.Seek < 0 && !c.isJSONOutput() + var writer io.Writer = os.Stdout + var cleanup func() error + if usePager { + writer, cleanup = bkIO.Pager(f.NoPager, f.Config.Pager()) + defer func() { _ = cleanup() }() + } + + count := 0 + for entry, iterErr := range entryIter { + if iterErr != nil { + return fmt.Errorf("failed to read log entries: %w", iterErr) + } + if !c.entryInTimeRange(&entry) { + continue + } + c.writeEntry(writer, &entry) + count++ + if c.Limit > 0 && count >= c.Limit { + break + } } - if c.NoTimestamps { - logContent = stripTimestamps(logContent) + if count == 0 { + fmt.Fprintln(os.Stderr, "No log output for this job.") } - writer, cleanup := bkIO.Pager(f.NoPager) - defer func() { _ = cleanup() }() + return nil +} + +func (c *LogCmd) tailMode(ctx context.Context, f *factory.Factory, logsClient *buildkitelogs.Client, org, pipeline, build, jobID string) error { + var reader *buildkitelogs.ParquetReader + var readerErr error + _ = bkIO.SpinWhile(f, "Fetching job log", func() { + reader, readerErr = logsClient.NewReader(ctx, org, pipeline, build, jobID, 30*time.Second, false) + }) + err := readerErr + if err != nil { + return c.handleLogError(err) + } + defer reader.Close() + + fileInfo, err := reader.GetFileInfo() + if err != nil { + return fmt.Errorf("failed to get log info: %w", err) + } + + if fileInfo.RowCount == 0 { + fmt.Fprintln(os.Stderr, "No log output for this job.") + return nil + } + + // When time filtering is active, we need to scan all entries and take the last N that match. + // Without time filtering, we can efficiently seek to the right offset. + if c.Since != "" || c.Until != "" { + var matched []buildkitelogs.ParquetLogEntry + iter := reader.ReadEntriesIter() + if c.Group != "" { + iter = reader.FilterByGroupIter(c.Group) + } + for entry, iterErr := range iter { + if iterErr != nil { + return fmt.Errorf("failed to read tail entries: %w", iterErr) + } + if c.entryInTimeRange(&entry) { + matched = append(matched, entry) + } + } + start := max(len(matched)-c.Tail, 0) + for _, entry := range matched[start:] { + c.writeEntry(os.Stdout, &entry) + } + if len(matched) == 0 { + fmt.Fprintln(os.Stderr, "No log output matching time range.") + } + return nil + } + + startRow := max(fileInfo.RowCount-int64(c.Tail), 0) + + for entry, iterErr := range reader.SeekToRow(startRow) { + if iterErr != nil { + return fmt.Errorf("failed to read tail entries: %w", iterErr) + } + c.writeEntry(os.Stdout, &entry) + } - fmt.Fprint(writer, logContent) return nil } +func (c *LogCmd) followMode(ctx context.Context, f *factory.Factory, logsClient *buildkitelogs.Client, org, pipeline, build, jobID string) error { + // If --tail is set with --follow, show last N lines first then follow + lastSeenRow := int64(0) + + // Initial fetch to get current state + reader, err := logsClient.NewReader(ctx, org, pipeline, build, jobID, 30*time.Second, false) + if err != nil { + return c.handleLogError(err) + } + + fileInfo, err := reader.GetFileInfo() + if err != nil { + reader.Close() + return fmt.Errorf("failed to get log info: %w", err) + } + + // Show initial content if --tail is set + if c.Tail > 0 && fileInfo.RowCount > 0 { + startRow := max(fileInfo.RowCount-int64(c.Tail), 0) + for entry, iterErr := range reader.SeekToRow(startRow) { + if iterErr != nil { + reader.Close() + return fmt.Errorf("failed to read initial entries: %w", iterErr) + } + if c.entryInTimeRange(&entry) { + c.writeEntry(os.Stdout, &entry) + } + } + lastSeenRow = fileInfo.RowCount + } else { + // Show everything from the beginning (respecting --since if set) + for entry, iterErr := range reader.ReadEntriesIter() { + if iterErr != nil { + reader.Close() + return fmt.Errorf("failed to read entries: %w", iterErr) + } + if c.entryInTimeRange(&entry) { + c.writeEntry(os.Stdout, &entry) + } + } + lastSeenRow = fileInfo.RowCount + } + reader.Close() + + // Check if job is already finished + if c.isJobTerminal(ctx, f, org, pipeline, build, jobID) { + return nil + } + + // Set up signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + consecutiveErrors := 0 + const maxConsecutiveErrors = 10 + + for { + select { + case <-sigCh: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + reader, err := logsClient.NewReader(ctx, org, pipeline, build, jobID, 0, true) + if err != nil { + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return bkErrors.WrapAPIError(err, fmt.Sprintf("fetching logs (%d consecutive failures)", consecutiveErrors)) + } + continue + } + consecutiveErrors = 0 + + fileInfo, err := reader.GetFileInfo() + if err != nil { + reader.Close() + continue + } + + if fileInfo.RowCount > lastSeenRow { + processed := int64(0) + for entry, iterErr := range reader.SeekToRow(lastSeenRow) { + if iterErr != nil { + break + } + c.writeEntry(os.Stdout, &entry) + processed++ + } + lastSeenRow += processed + } + reader.Close() + + if c.isJobTerminal(ctx, f, org, pipeline, build, jobID) { + return nil + } + } + } +} + +// jobState returns the job's state string, or an error if the job/build can't be found. +func (c *LogCmd) jobState(ctx context.Context, f *factory.Factory, org, pipeline, build, jobID string) (string, error) { + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + buildInfo, _, err := f.RestAPIClient.Builds.Get(reqCtx, org, pipeline, build, nil) + if err != nil { + return "", err + } + for _, j := range buildInfo.Jobs { + if j.ID == jobID { + return j.State, nil + } + } + return "", fmt.Errorf("job %s not found in build %s", jobID, build) +} + +func (c *LogCmd) isJobTerminal(ctx context.Context, f *factory.Factory, org, pipeline, build, jobID string) bool { + state, err := c.jobState(ctx, f, org, pipeline, build, jobID) + if err != nil { + return false + } + return buildkitelogs.IsTerminalState(buildkitelogs.JobState(state)) +} + +func (c *LogCmd) writeEntry(w io.Writer, entry *buildkitelogs.ParquetLogEntry) { + if c.isJSONOutput() { + c.writeEntryJSON(w, entry) + return + } + + content := entry.CleanContent(!output.ColorEnabled()) + + // --timestamps: replace raw bk;t= markers with human-readable prefix + if c.Timestamps { + content = stripTimestamps(content) + ts := time.UnixMilli(entry.Timestamp).UTC().Format(time.RFC3339) + content = ts + " " + content + } else if c.NoTimestamps { + content = stripTimestamps(content) + } + + content = strings.TrimRight(content, "\n") + fmt.Fprintf(w, "%s\n", content) +} + +// logEntryJSON is the JSON representation of a log entry. +type logEntryJSON struct { + RowNumber int64 `json:"row_number"` + Timestamp string `json:"timestamp"` + Content string `json:"content"` + Group string `json:"group,omitempty"` +} + +func (c *LogCmd) writeEntryJSON(w io.Writer, entry *buildkitelogs.ParquetLogEntry) { + obj := logEntryJSON{ + RowNumber: entry.RowNumber, + Timestamp: time.UnixMilli(entry.Timestamp).UTC().Format(time.RFC3339), + Content: strings.TrimRight(entry.CleanContent(true), "\n"), + Group: entry.Group, + } + data, _ := json.Marshal(obj) + fmt.Fprintf(w, "%s\n", data) +} + +// isJSONOutput returns true if JSON output format is selected. +func (c *LogCmd) isJSONOutput() bool { + return c.JSON +} + +func (c *LogCmd) handleLogError(err error) error { + if errors.Is(err, buildkitelogs.ErrLogTooLarge) { + return bkErrors.NewValidationError(err, "log exceeds maximum size", + "Use --tail N to see the last N lines", + "Use --seek/--limit to read a specific portion", + ) + } + return bkErrors.WrapAPIError(err, "fetching job log") +} + var timestampRegex = regexp.MustCompile(`bk;t=\d+\x07`) func stripTimestamps(content string) string { return timestampRegex.ReplaceAllString(content, "") } + +// parsedJobURL holds the components extracted from a Buildkite job URL. +type parsedJobURL struct { + org string + pipeline string + buildNumber string + jobID string +} + +// buildkiteURLRegex matches Buildkite build URLs with an optional #job-uuid fragment: +// +// https://buildkite.com/org/pipeline/builds/123 +// https://buildkite.com/org/pipeline/builds/123#job-uuid +var buildkiteURLRegex = regexp.MustCompile(`^https?://buildkite\.com/([^/]+)/([^/]+)/builds/(\d+)(?:#([0-9a-fA-F-]+))?$`) + +// parseJobURL extracts org, pipeline, build number, and optionally job ID from a Buildkite URL. +// Returns nil if the input is not a recognized Buildkite build/job URL. +// Handles common copy-paste artifacts like Slack's angle-bracket wrapping (). +func parseJobURL(input string) *parsedJobURL { + input = strings.TrimSpace(input) + // Strip Slack-style angle brackets: + input = strings.TrimPrefix(input, "<") + input = strings.TrimSuffix(input, ">") + m := buildkiteURLRegex.FindStringSubmatch(input) + if m == nil { + return nil + } + return &parsedJobURL{ + org: m[1], + pipeline: m[2], + buildNumber: m[3], + jobID: m[4], // empty string if no fragment + } +} diff --git a/cmd/job/log_test.go b/cmd/job/log_test.go new file mode 100644 index 00000000..74bb5384 --- /dev/null +++ b/cmd/job/log_test.go @@ -0,0 +1,907 @@ +package job + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + buildkitelogs "github.com/buildkite/buildkite-logs" +) + +func TestLogCmdValidateFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmd LogCmd + wantErr string + }{ + { + name: "step and job ID conflict", + cmd: LogCmd{Step: "test", JobID: "abc-123", Seek: -1}, + wantErr: "--step and a positional job ID are mutually exclusive", + }, + { + name: "valid flags - step only", + cmd: LogCmd{Step: "test", Seek: -1}, + }, + { + name: "tail and seek conflict", + cmd: LogCmd{Tail: 50, Seek: 10}, + wantErr: "--tail and --seek are mutually exclusive", + }, + { + name: "follow and seek conflict", + cmd: LogCmd{Follow: true, Seek: 100}, + wantErr: "--follow and --seek cannot be used together", + }, + { + name: "valid flags - tail only", + cmd: LogCmd{Tail: 50, Seek: -1}, + }, + { + name: "valid flags - follow only", + cmd: LogCmd{Follow: true, Seek: -1}, + }, + { + name: "valid flags - seek and limit", + cmd: LogCmd{Seek: 100, Limit: 50}, + }, + { + name: "valid flags - defaults", + cmd: LogCmd{Seek: -1}, + }, + // --timestamps / --no-timestamps + { + name: "timestamps and no-timestamps conflict", + cmd: LogCmd{Timestamps: true, NoTimestamps: true, Seek: -1}, + wantErr: "--timestamps and --no-timestamps are mutually exclusive", + }, + { + name: "valid flags - timestamps", + cmd: LogCmd{Timestamps: true, Seek: -1}, + }, + // --since / --until + { + name: "since and seek conflict", + cmd: LogCmd{Since: "5m", Seek: 100}, + wantErr: "--since/--until and --seek are mutually exclusive", + }, + { + name: "until and seek conflict", + cmd: LogCmd{Until: "5m", Seek: 100}, + wantErr: "--since/--until and --seek are mutually exclusive", + }, + { + name: "follow and until conflict", + cmd: LogCmd{Follow: true, Until: "5m", Seek: -1}, + wantErr: "--follow and --until cannot be used together", + }, + { + name: "invalid since value", + cmd: LogCmd{Since: "not-a-time", Seek: -1}, + wantErr: "invalid --since value", + }, + { + name: "invalid until value", + cmd: LogCmd{Until: "not-a-time", Seek: -1}, + wantErr: "invalid --until value", + }, + { + name: "valid flags - since duration", + cmd: LogCmd{Since: "10m", Seek: -1}, + }, + { + name: "valid flags - since RFC3339", + cmd: LogCmd{Since: "2024-01-15T10:00:00Z", Seek: -1}, + }, + { + name: "valid flags - follow with since", + cmd: LogCmd{Follow: true, Since: "5m", Seek: -1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.cmd.validateFlags() + if tt.wantErr != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestWriteEntry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmd LogCmd + entry buildkitelogs.ParquetLogEntry + expected string + }{ + { + name: "plain entry", + cmd: LogCmd{}, + entry: buildkitelogs.ParquetLogEntry{Content: "hello world", RowNumber: 0}, + expected: "hello world\n", + }, + { + name: "entry with timestamp stripping", + cmd: LogCmd{NoTimestamps: true}, + entry: buildkitelogs.ParquetLogEntry{Content: "bk;t=1234567890\x07some output", RowNumber: 0}, + expected: "some output\n", + }, + { + name: "entry with trailing newlines trimmed", + cmd: LogCmd{}, + entry: buildkitelogs.ParquetLogEntry{Content: "line with newlines\n\n", RowNumber: 0}, + expected: "line with newlines\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + tt.cmd.writeEntry(&buf, &tt.entry) + if got := buf.String(); got != tt.expected { + t.Errorf("writeEntry() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestStripTimestamps(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {"bk;t=1234567890\x07hello", "hello"}, + {"no timestamps here", "no timestamps here"}, + {"bk;t=0\x07start bk;t=999\x07end", "start end"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + if got := stripTimestamps(tt.input); got != tt.expected { + t.Errorf("stripTimestamps(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestBuildJobLabels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + jobs []cmdJob + expected []string + }{ + { + name: "all unique labels", + jobs: []cmdJob{ + {id: "aaa-111", label: "rspec", state: "passed"}, + {id: "bbb-222", label: "lint", state: "passed"}, + }, + expected: []string{"rspec (passed)", "lint (passed)"}, + }, + { + name: "duplicate labels get ID suffix", + jobs: []cmdJob{ + {id: "aaa11111-long-id", label: "rspec", state: "running"}, + {id: "bbb22222-long-id", label: "rspec", state: "running"}, + }, + expected: []string{"rspec (running) [aaa11111]", "rspec (running) [bbb22222]"}, + }, + { + name: "mix of duplicates and unique", + jobs: []cmdJob{ + {id: "aaa11111-long-id", label: "rspec", state: "running"}, + {id: "bbb22222-long-id", label: "lint", state: "passed"}, + {id: "ccc33333-long-id", label: "rspec", state: "running"}, + }, + expected: []string{"rspec (running) [aaa11111]", "lint (passed)", "rspec (running) [ccc33333]"}, + }, + { + name: "short ID used as-is", + jobs: []cmdJob{ + {id: "short", label: "rspec", state: "running"}, + {id: "other", label: "rspec", state: "running"}, + }, + expected: []string{"rspec (running) [short]", "rspec (running) [other]"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := buildJobLabels(tt.jobs) + if len(got) != len(tt.expected) { + t.Fatalf("buildJobLabels() returned %d labels, want %d", len(got), len(tt.expected)) + } + for i := range got { + if got[i] != tt.expected[i] { + t.Errorf("buildJobLabels()[%d] = %q, want %q", i, got[i], tt.expected[i]) + } + } + }) + } +} + +func TestLogCmdHelp(t *testing.T) { + t.Parallel() + cmd := &LogCmd{} + help := cmd.Help() + if !strings.Contains(help, "bk job log") { + t.Error("help text should contain usage examples") + } + if !strings.Contains(help, "-f") { + t.Error("help text should mention follow flag") + } + if !strings.Contains(help, "--since") { + t.Error("help text should mention since flag") + } + if !strings.Contains(help, "--json") { + t.Error("help text should mention json flag") + } +} + +func TestParseTimeFlag(t *testing.T) { + t.Parallel() + + t.Run("duration string", func(t *testing.T) { + t.Parallel() + before := time.Now() + result, err := parseTimeFlag("5m") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := before.Add(-5 * time.Minute) + // Allow 1 second tolerance + if result.Before(expected.Add(-time.Second)) || result.After(expected.Add(time.Second)) { + t.Errorf("parseTimeFlag(\"5m\") = %v, want ~%v", result, expected) + } + }) + + t.Run("RFC3339 timestamp", func(t *testing.T) { + t.Parallel() + result, err := parseTimeFlag("2024-01-15T10:30:00Z") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + if !result.Equal(expected) { + t.Errorf("parseTimeFlag(\"2024-01-15T10:30:00Z\") = %v, want %v", result, expected) + } + }) + + t.Run("invalid value", func(t *testing.T) { + t.Parallel() + _, err := parseTimeFlag("not-a-time") + if err == nil { + t.Error("expected error for invalid time value") + } + }) +} + +func TestWriteEntryWithTimestamps(t *testing.T) { + t.Parallel() + cmd := LogCmd{Timestamps: true} + entry := buildkitelogs.ParquetLogEntry{ + Content: "bk;t=1705314600000\x07hello world", + Timestamp: 1705314600000, // 2024-01-15T10:30:00Z + RowNumber: 0, + } + + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + got := buf.String() + + if !strings.HasPrefix(got, "2024-01-15T10:30:00Z") { + t.Errorf("expected timestamp prefix, got %q", got) + } + if !strings.Contains(got, "hello world") { + t.Error("expected content in output") + } + // Raw bk;t= marker should be stripped + if strings.Contains(got, "bk;t=") { + t.Error("raw bk;t= marker should be stripped when --timestamps is used") + } +} + +func TestWriteEntryJSON(t *testing.T) { + t.Parallel() + cmd := LogCmd{JSON: true} + entry := buildkitelogs.ParquetLogEntry{ + Content: "hello world", + Timestamp: 1705314600000, + RowNumber: 42, + Group: "test-group", + } + + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + + var result logEntryJSON + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal JSON output: %v", err) + } + if result.RowNumber != 42 { + t.Errorf("row_number = %d, want 42", result.RowNumber) + } + if result.Content != "hello world" { + t.Errorf("content = %q, want %q", result.Content, "hello world") + } + if result.Group != "test-group" { + t.Errorf("group = %q, want %q", result.Group, "test-group") + } + if result.Timestamp != "2024-01-15T10:30:00Z" { + t.Errorf("timestamp = %q, want %q", result.Timestamp, "2024-01-15T10:30:00Z") + } +} + +func TestEntryInTimeRange(t *testing.T) { + t.Parallel() + + t.Run("no time filters", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: 1000} + if !cmd.entryInTimeRange(entry) { + t.Error("should pass with no time filters") + } + }) + + t.Run("since filter includes entry", func(t *testing.T) { + t.Parallel() + sinceTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + cmd := LogCmd{Since: "2024-01-15T10:00:00Z", sinceTime: sinceTime} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC).UnixMilli()} + if !cmd.entryInTimeRange(entry) { + t.Error("entry after --since should be included") + } + }) + + t.Run("since filter excludes entry", func(t *testing.T) { + t.Parallel() + sinceTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + cmd := LogCmd{Since: "2024-01-15T10:00:00Z", sinceTime: sinceTime} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC).UnixMilli()} + if cmd.entryInTimeRange(entry) { + t.Error("entry before --since should be excluded") + } + }) + + t.Run("until filter includes entry", func(t *testing.T) { + t.Parallel() + untilTime := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + cmd := LogCmd{Until: "2024-01-15T12:00:00Z", untilTime: untilTime} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC).UnixMilli()} + if !cmd.entryInTimeRange(entry) { + t.Error("entry before --until should be included") + } + }) + + t.Run("until filter excludes entry", func(t *testing.T) { + t.Parallel() + untilTime := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + cmd := LogCmd{Until: "2024-01-15T12:00:00Z", untilTime: untilTime} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: time.Date(2024, 1, 15, 13, 0, 0, 0, time.UTC).UnixMilli()} + if cmd.entryInTimeRange(entry) { + t.Error("entry after --until should be excluded") + } + }) +} + +func TestShouldAutoFollow(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmd LogCmd + want bool + }{ + { + name: "default flags - should auto-follow", + cmd: LogCmd{Seek: -1}, + want: true, + }, + { + name: "explicit follow set - no auto-follow needed", + cmd: LogCmd{Follow: true, Seek: -1}, + want: false, + }, + { + name: "tail set - should not auto-follow", + cmd: LogCmd{Tail: 50, Seek: -1}, + want: false, + }, + { + name: "seek set - should not auto-follow", + cmd: LogCmd{Seek: 100}, + want: false, + }, + { + name: "limit set - should not auto-follow", + cmd: LogCmd{Limit: 10, Seek: -1}, + want: false, + }, + { + name: "since set - should not auto-follow", + cmd: LogCmd{Since: "5m", Seek: -1}, + want: false, + }, + { + name: "until set - should not auto-follow", + cmd: LogCmd{Until: "5m", Seek: -1}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.cmd.shouldAutoFollow() + if got != tt.want { + t.Errorf("shouldAutoFollow() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseJobURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantNil bool + wantOrg string + wantPipe string + wantBuild string + wantJobID string + }{ + { + name: "full job URL with fragment", + input: "https://buildkite.com/my-org/my-pipeline/builds/456#0190046e-e199-453b-a302-a21a4d649d31", + wantOrg: "my-org", + wantPipe: "my-pipeline", + wantBuild: "456", + wantJobID: "0190046e-e199-453b-a302-a21a4d649d31", + }, + { + name: "build URL without job fragment", + input: "https://buildkite.com/my-org/my-pipeline/builds/789", + wantOrg: "my-org", + wantPipe: "my-pipeline", + wantBuild: "789", + wantJobID: "", + }, + { + name: "URL with trailing whitespace", + input: " https://buildkite.com/org/pipe/builds/1#abc-def ", + wantOrg: "org", + wantPipe: "pipe", + wantBuild: "1", + wantJobID: "abc-def", + }, + { + name: "plain job UUID", + input: "0190046e-e199-453b-a302-a21a4d649d31", + wantNil: true, + }, + { + name: "empty string", + input: "", + wantNil: true, + }, + { + name: "non-buildkite URL", + input: "https://example.com/org/pipe/builds/123#job-id", + wantNil: true, + }, + { + name: "buildkite URL with wrong path", + input: "https://buildkite.com/org/pipe/jobs/123", + wantNil: true, + }, + { + name: "http URL (not https)", + input: "http://buildkite.com/org/pipe/builds/99#aaa-bbb", + wantOrg: "org", + wantPipe: "pipe", + wantBuild: "99", + wantJobID: "aaa-bbb", + }, + { + name: "uppercase UUID in fragment", + input: "https://buildkite.com/org/pipe/builds/1#0190046E-E199-453B-A302-A21A4D649D31", + wantOrg: "org", + wantPipe: "pipe", + wantBuild: "1", + wantJobID: "0190046E-E199-453B-A302-A21A4D649D31", + }, + { + name: "URL with query params before fragment", + input: "https://buildkite.com/org/pipe/builds/123?utm_source=slack#job-id", + wantNil: true, + }, + { + name: "URL with trailing slash", + input: "https://buildkite.com/org/pipe/builds/123/", + wantNil: true, + }, + { + name: "URL with extra path segments", + input: "https://buildkite.com/org/pipe/builds/123/extra", + wantNil: true, + }, + { + name: "fragment with non-hex characters", + input: "https://buildkite.com/org/pipe/builds/123#not-a-valid-uuid!", + wantNil: true, + }, + { + name: "mixed case UUID", + input: "https://buildkite.com/org/pipe/builds/5#aBcDeF-1234", + wantOrg: "org", + wantPipe: "pipe", + wantBuild: "5", + wantJobID: "aBcDeF-1234", + }, + { + name: "empty fragment", + input: "https://buildkite.com/org/pipe/builds/123#", + wantNil: true, + }, + { + name: "Slack angle-bracket wrapped URL", + input: "", + wantOrg: "org", + wantPipe: "pipe", + wantBuild: "55", + wantJobID: "abc-def", + }, + { + name: "Slack angle-bracket wrapped build-only URL", + input: "", + wantOrg: "org", + wantPipe: "pipe", + wantBuild: "55", + wantJobID: "", + }, + { + name: "markdown link is not parsed", + input: "[Build 123](https://buildkite.com/org/pipe/builds/123#job-id)", + wantNil: true, + }, + { + name: "double-pasted URL", + input: "https://buildkite.com/org/pipe/builds/123#abc-defhttps://buildkite.com", + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := parseJobURL(tt.input) + if tt.wantNil { + if result != nil { + t.Errorf("parseJobURL(%q) = %+v, want nil", tt.input, result) + } + return + } + if result == nil { + t.Fatalf("parseJobURL(%q) = nil, want non-nil", tt.input) + } + if result.org != tt.wantOrg { + t.Errorf("org = %q, want %q", result.org, tt.wantOrg) + } + if result.pipeline != tt.wantPipe { + t.Errorf("pipeline = %q, want %q", result.pipeline, tt.wantPipe) + } + if result.buildNumber != tt.wantBuild { + t.Errorf("buildNumber = %q, want %q", result.buildNumber, tt.wantBuild) + } + if result.jobID != tt.wantJobID { + t.Errorf("jobID = %q, want %q", result.jobID, tt.wantJobID) + } + }) + } +} + +func TestURLOverridesFields(t *testing.T) { + t.Parallel() + + t.Run("URL populates pipeline, build, and jobID", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{ + JobID: "https://buildkite.com/acme/deploy/builds/42#aaa-bbb-ccc", + Seek: -1, + } + parsed := parseJobURL(cmd.JobID) + if parsed == nil { + t.Fatal("expected URL to parse") + } + cmd.Pipeline = parsed.org + "/" + parsed.pipeline + cmd.BuildNumber = parsed.buildNumber + cmd.JobID = parsed.jobID + + if cmd.Pipeline != "acme/deploy" { + t.Errorf("Pipeline = %q, want %q", cmd.Pipeline, "acme/deploy") + } + if cmd.BuildNumber != "42" { + t.Errorf("BuildNumber = %q, want %q", cmd.BuildNumber, "42") + } + if cmd.JobID != "aaa-bbb-ccc" { + t.Errorf("JobID = %q, want %q", cmd.JobID, "aaa-bbb-ccc") + } + }) + + t.Run("build-only URL leaves JobID empty", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{ + JobID: "https://buildkite.com/acme/deploy/builds/42", + Seek: -1, + } + parsed := parseJobURL(cmd.JobID) + if parsed == nil { + t.Fatal("expected URL to parse") + } + cmd.Pipeline = parsed.org + "/" + parsed.pipeline + cmd.BuildNumber = parsed.buildNumber + cmd.JobID = parsed.jobID + + if cmd.JobID != "" { + t.Errorf("JobID = %q, want empty for build-only URL", cmd.JobID) + } + if cmd.Pipeline != "acme/deploy" { + t.Errorf("Pipeline = %q, want %q", cmd.Pipeline, "acme/deploy") + } + }) + + t.Run("build-only URL with --step is valid", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{ + JobID: "", // after URL parsing, jobID is empty for build-only URL + Step: "test", + Seek: -1, + } + if err := cmd.validateFlags(); err != nil { + t.Errorf("expected no error for build-only URL + --step, got: %v", err) + } + }) + + t.Run("full URL with --step conflicts", func(t *testing.T) { + t.Parallel() + // After URL parsing, JobID is set, so --step should conflict + cmd := LogCmd{ + JobID: "aaa-bbb-ccc", // simulates post-URL-parse state + Step: "test", + Seek: -1, + } + err := cmd.validateFlags() + if err == nil { + t.Error("expected error for URL with job fragment + --step") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected mutually exclusive error, got: %v", err) + } + }) +} + +func TestWriteEntryEdgeCases(t *testing.T) { + t.Parallel() + + t.Run("JSON output always includes timestamp regardless of --timestamps flag", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{JSON: true, Timestamps: true} + entry := buildkitelogs.ParquetLogEntry{ + Content: "hello", + Timestamp: 1705314600000, + RowNumber: 0, + } + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + + var result logEntryJSON + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + if result.Timestamp != "2024-01-15T10:30:00Z" { + t.Errorf("timestamp = %q, want %q", result.Timestamp, "2024-01-15T10:30:00Z") + } + }) + + t.Run("JSON output strips ANSI from content", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{JSON: true} + entry := buildkitelogs.ParquetLogEntry{ + Content: "hello", + Timestamp: 1000, + RowNumber: 0, + } + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + + var result logEntryJSON + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + // CleanContent(true) strips ANSI, so no escape codes should remain + if strings.Contains(result.Content, "\x1b") { + t.Error("JSON content should not contain ANSI escape codes") + } + }) + + t.Run("empty content produces valid output", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{} + entry := buildkitelogs.ParquetLogEntry{Content: "", RowNumber: 0} + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + if buf.String() != "\n" { + t.Errorf("expected single newline for empty content, got %q", buf.String()) + } + }) + + t.Run("empty content JSON produces valid JSONL", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{JSON: true} + entry := buildkitelogs.ParquetLogEntry{Content: "", Timestamp: 1000, RowNumber: 0} + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + + var result logEntryJSON + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("empty content should produce valid JSON, got error: %v", err) + } + if result.Content != "" { + t.Errorf("content = %q, want empty", result.Content) + } + }) + + t.Run("multiple bk;t= markers stripped", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{NoTimestamps: true} + entry := buildkitelogs.ParquetLogEntry{ + Content: "bk;t=111\x07first bk;t=222\x07second", + RowNumber: 0, + } + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + if strings.Contains(buf.String(), "bk;t=") { + t.Errorf("all bk;t= markers should be stripped, got %q", buf.String()) + } + if !strings.Contains(buf.String(), "first") || !strings.Contains(buf.String(), "second") { + t.Errorf("content around markers should be preserved, got %q", buf.String()) + } + }) + + t.Run("JSON group field omitted when empty", func(t *testing.T) { + t.Parallel() + cmd := LogCmd{JSON: true} + entry := buildkitelogs.ParquetLogEntry{Content: "hi", Timestamp: 1000, RowNumber: 0, Group: ""} + var buf bytes.Buffer + cmd.writeEntry(&buf, &entry) + if strings.Contains(buf.String(), `"group"`) { + t.Error("group field should be omitted when empty (omitempty)") + } + }) +} + +func TestBuildJobLabelsParallelIndex(t *testing.T) { + t.Parallel() + + idx0, idx1, idx2 := 0, 1, 2 + jobs := []cmdJob{ + {id: "aaa11111-long", label: "rspec #0", state: "failed"}, + {id: "bbb22222-long", label: "rspec #1", state: "passed"}, + {id: "ccc33333-long", label: "rspec #2", state: "passed"}, + } + labels := buildJobLabels(jobs) + + // All have the same base "rspec #N (state)" pattern but different labels, so they shouldn't + // need disambiguation UNLESS the full label+state string matches + _ = idx0 + _ = idx1 + _ = idx2 + + if len(labels) != 3 { + t.Fatalf("expected 3 labels, got %d", len(labels)) + } + // With different parallel indices, labels should be unique (no ID suffix needed) + for _, l := range labels { + if strings.Contains(l, "[") { + t.Errorf("unique parallel labels shouldn't need ID suffix, got %q", l) + } + } +} + +func TestEntryInTimeRangeBoundary(t *testing.T) { + t.Parallel() + + t.Run("entry exactly at since boundary is included", func(t *testing.T) { + t.Parallel() + boundary := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + cmd := LogCmd{Since: "2024-01-15T10:00:00Z", sinceTime: boundary} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: boundary.UnixMilli()} + if !cmd.entryInTimeRange(entry) { + t.Error("entry exactly at --since boundary should be included") + } + }) + + t.Run("entry exactly at until boundary is included", func(t *testing.T) { + t.Parallel() + boundary := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + cmd := LogCmd{Until: "2024-01-15T12:00:00Z", untilTime: boundary} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: boundary.UnixMilli()} + if !cmd.entryInTimeRange(entry) { + t.Error("entry exactly at --until boundary should be included") + } + }) + + t.Run("entry 1ms before since is excluded", func(t *testing.T) { + t.Parallel() + boundary := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + cmd := LogCmd{Since: "2024-01-15T10:00:00Z", sinceTime: boundary} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: boundary.UnixMilli() - 1} + if cmd.entryInTimeRange(entry) { + t.Error("entry 1ms before --since should be excluded") + } + }) + + t.Run("entry 1ms after until is excluded", func(t *testing.T) { + t.Parallel() + boundary := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + cmd := LogCmd{Until: "2024-01-15T12:00:00Z", untilTime: boundary} + entry := &buildkitelogs.ParquetLogEntry{Timestamp: boundary.UnixMilli() + 1} + if cmd.entryInTimeRange(entry) { + t.Error("entry 1ms after --until should be excluded") + } + }) + + t.Run("since and until together - entry in range", func(t *testing.T) { + t.Parallel() + since := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + until := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + cmd := LogCmd{ + Since: "2024-01-15T10:00:00Z", sinceTime: since, + Until: "2024-01-15T12:00:00Z", untilTime: until, + } + entry := &buildkitelogs.ParquetLogEntry{Timestamp: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC).UnixMilli()} + if !cmd.entryInTimeRange(entry) { + t.Error("entry within since/until range should be included") + } + }) + + t.Run("since and until together - entry outside range", func(t *testing.T) { + t.Parallel() + since := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + until := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + cmd := LogCmd{ + Since: "2024-01-15T10:00:00Z", sinceTime: since, + Until: "2024-01-15T12:00:00Z", untilTime: until, + } + entry := &buildkitelogs.ParquetLogEntry{Timestamp: time.Date(2024, 1, 15, 13, 0, 0, 0, time.UTC).UnixMilli()} + if cmd.entryInTimeRange(entry) { + t.Error("entry after until should be excluded") + } + }) +} From 960fa497c80c9eb04a4b5f816a0518f4e6b35baa Mon Sep 17 00:00:00 2001 From: Ken Thompson Date: Sat, 28 Mar 2026 09:36:11 +1100 Subject: [PATCH 3/3] Address review feedback: promote bk logs, validate --group/--seek, fix tests - Use `bk logs` consistently in help examples (first-class command, `bk job log` kept for compatibility) - Add --seek/--group mutual exclusivity check to validateFlags() - Fix ANSI strip test to include actual escape codes in input - Handle json.Marshal error with stderr warning instead of swallowing - Remove unused idx0/idx1/idx2 variables from parallel index test Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/job/log.go | 40 +++++++++++++++++++++++++--------------- cmd/job/log_test.go | 20 ++++++++++---------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/cmd/job/log.go b/cmd/job/log.go index be87a85c..b1dd3cca 100644 --- a/cmd/job/log.go +++ b/cmd/job/log.go @@ -63,7 +63,7 @@ func (c *LogCmd) Help() string { return ` Examples: # Get a job's full log - $ bk job log 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 + $ bk logs 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 # Get logs from a Buildkite URL (copy-paste from web UI or Slack) $ bk logs https://buildkite.com/my-org/my-pipeline/builds/123#0190046e-e199-453b-a302-a21a4d649d31 @@ -72,43 +72,43 @@ Examples: $ bk logs https://buildkite.com/my-org/my-pipeline/builds/123 # Get logs by step key (from pipeline.yml) - $ bk job log -p my-pipeline -b 123 --step "test-suite" + $ bk logs -p my-pipeline -b 123 --step "test-suite" # Interactive job picker (omit job ID) - $ bk job log -p my-pipeline -b 123 + $ bk logs -p my-pipeline -b 123 # Show last 50 lines - $ bk job log JOB_ID -b 123 -n 50 + $ bk logs JOB_ID -b 123 -n 50 # Follow a running job's log output - $ bk job log JOB_ID -b 123 -f + $ bk logs JOB_ID -b 123 -f # Follow and search for errors (pipe to grep) - $ bk job log JOB_ID -b 123 -f | grep -i "error\|panic" + $ bk logs JOB_ID -b 123 -f | grep -i "error\|panic" # Search with context (pipe to grep) - $ bk job log JOB_ID -b 123 | grep -C 3 "error\|failed" + $ bk logs JOB_ID -b 123 | grep -C 3 "error\|failed" # Show logs from the last 10 minutes - $ bk job log JOB_ID -b 123 --since 10m + $ bk logs JOB_ID -b 123 --since 10m # Show logs between two timestamps - $ bk job log JOB_ID -b 123 --since 2024-01-15T10:00:00Z --until 2024-01-15T10:05:00Z + $ bk logs JOB_ID -b 123 --since 2024-01-15T10:00:00Z --until 2024-01-15T10:05:00Z # Show human-readable timestamps - $ bk job log JOB_ID -b 123 -t + $ bk logs JOB_ID -b 123 -t # Filter to a specific group/section - $ bk job log JOB_ID -b 123 -G "Running tests" + $ bk logs JOB_ID -b 123 -G "Running tests" # Output as JSON lines (for piping to jq) - $ bk job log JOB_ID -b 123 --json | jq '.content' + $ bk logs JOB_ID -b 123 --json | jq '.content' # Paginated read (rows 100-200) - $ bk job log JOB_ID -b 123 --seek 100 --limit 100 + $ bk logs JOB_ID -b 123 --seek 100 --limit 100 # Add line numbers (pipe to nl or cat -n) - $ bk job log JOB_ID -b 123 | cat -n + $ bk logs JOB_ID -b 123 | cat -n ` } @@ -272,6 +272,12 @@ func (c *LogCmd) validateFlags() error { "use time-based filtering or row-based seeking, not both", ) } + if c.Seek >= 0 && c.Group != "" { + return bkErrors.NewValidationError( + fmt.Errorf("--seek and --group are mutually exclusive"), + "use --seek for row-based reading, or --group for section filtering", + ) + } if c.Follow && c.Until != "" { return bkErrors.NewValidationError( fmt.Errorf("--follow and --until cannot be used together"), @@ -749,7 +755,11 @@ func (c *LogCmd) writeEntryJSON(w io.Writer, entry *buildkitelogs.ParquetLogEntr Content: strings.TrimRight(entry.CleanContent(true), "\n"), Group: entry.Group, } - data, _ := json.Marshal(obj) + data, err := json.Marshal(obj) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to marshal log entry: %v\n", err) + return + } fmt.Fprintf(w, "%s\n", data) } diff --git a/cmd/job/log_test.go b/cmd/job/log_test.go index 74bb5384..b12ec426 100644 --- a/cmd/job/log_test.go +++ b/cmd/job/log_test.go @@ -74,6 +74,11 @@ func TestLogCmdValidateFlags(t *testing.T) { cmd: LogCmd{Until: "5m", Seek: 100}, wantErr: "--since/--until and --seek are mutually exclusive", }, + { + name: "seek and group conflict", + cmd: LogCmd{Seek: 100, Group: "tests"}, + wantErr: "--seek and --group are mutually exclusive", + }, { name: "follow and until conflict", cmd: LogCmd{Follow: true, Until: "5m", Seek: -1}, @@ -247,7 +252,7 @@ func TestLogCmdHelp(t *testing.T) { t.Parallel() cmd := &LogCmd{} help := cmd.Help() - if !strings.Contains(help, "bk job log") { + if !strings.Contains(help, "bk logs") { t.Error("help text should contain usage examples") } if !strings.Contains(help, "-f") { @@ -733,7 +738,7 @@ func TestWriteEntryEdgeCases(t *testing.T) { t.Parallel() cmd := LogCmd{JSON: true} entry := buildkitelogs.ParquetLogEntry{ - Content: "hello", + Content: "\x1b[31merror: something failed\x1b[0m", Timestamp: 1000, RowNumber: 0, } @@ -744,10 +749,12 @@ func TestWriteEntryEdgeCases(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &result); err != nil { t.Fatalf("failed to unmarshal JSON: %v", err) } - // CleanContent(true) strips ANSI, so no escape codes should remain if strings.Contains(result.Content, "\x1b") { t.Error("JSON content should not contain ANSI escape codes") } + if !strings.Contains(result.Content, "error: something failed") { + t.Errorf("content should preserve text after stripping ANSI, got %q", result.Content) + } }) t.Run("empty content produces valid output", func(t *testing.T) { @@ -809,7 +816,6 @@ func TestWriteEntryEdgeCases(t *testing.T) { func TestBuildJobLabelsParallelIndex(t *testing.T) { t.Parallel() - idx0, idx1, idx2 := 0, 1, 2 jobs := []cmdJob{ {id: "aaa11111-long", label: "rspec #0", state: "failed"}, {id: "bbb22222-long", label: "rspec #1", state: "passed"}, @@ -817,12 +823,6 @@ func TestBuildJobLabelsParallelIndex(t *testing.T) { } labels := buildJobLabels(jobs) - // All have the same base "rspec #N (state)" pattern but different labels, so they shouldn't - // need disambiguation UNLESS the full label+state string matches - _ = idx0 - _ = idx1 - _ = idx2 - if len(labels) != 3 { t.Fatalf("expected 3 labels, got %d", len(labels)) }