diff --git a/cmd/job/log.go b/cmd/job/log.go index db702492..b1dd3cca 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) - $ bk job log 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 + # Get a job's full log + $ 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 + + # 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 logs -p my-pipeline -b 123 --step "test-suite" + + # Interactive job picker (omit job ID) + $ bk logs -p my-pipeline -b 123 + + # Show last 50 lines + $ bk logs JOB_ID -b 123 -n 50 + + # Follow a running job's log output + $ bk logs JOB_ID -b 123 -f + + # Follow and search for errors (pipe to grep) + $ bk logs JOB_ID -b 123 -f | grep -i "error\|panic" + + # Search with context (pipe to grep) + $ bk logs JOB_ID -b 123 | grep -C 3 "error\|failed" - # If inside a git repository with a configured pipeline - $ bk job log 0190046e-e199-453b-a302-a21a4d649d31 -b 123 + # Show logs from the last 10 minutes + $ bk logs 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 logs JOB_ID -b 123 --since 2024-01-15T10:00:00Z --until 2024-01-15T10:05:00Z + + # Show human-readable timestamps + $ bk logs JOB_ID -b 123 -t + + # Filter to a specific group/section + $ bk logs JOB_ID -b 123 -G "Running tests" + + # Output as JSON lines (for piping to jq) + $ bk logs JOB_ID -b 123 --json | jq '.content' + + # Paginated read (rows 100-200) + $ bk logs JOB_ID -b 123 --seek 100 --limit 100 + + # Add line numbers (pipe to nl or cat -n) + $ bk logs 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,640 @@ 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.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"), + "--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() }() } - if c.NoTimestamps { - logContent = stripTimestamps(logContent) + 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 + } } - writer, cleanup := bkIO.Pager(f.NoPager) - defer func() { _ = cleanup() }() + if count == 0 { + fmt.Fprintln(os.Stderr, "No log output for this job.") + } - fmt.Fprint(writer, logContent) 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) + } + + 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, 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) +} + +// 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..b12ec426 --- /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: "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}, + 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 logs") { + 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: "\x1b[31merror: something failed\x1b[0m", + 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) + } + 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) { + 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() + + 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) + + 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") + } + }) +} diff --git a/go.mod b/go.mod index a3cd3c87..f15caa8a 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 68b78a71..c74c68c0 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.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q= github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= 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/humble-yawning-bear.md b/humble-yawning-bear.md new file mode 100644 index 00000000..cdfa0b33 --- /dev/null +++ b/humble-yawning-bear.md @@ -0,0 +1,301 @@ +# PRD: Enhanced `bk job log` Command + +## Context + +The Buildkite MCP server provides three powerful log tools (`read_logs`, `search_logs`, `tail_logs`) backed by the `buildkite-logs` Go library, which downloads raw logs from the REST API, converts them to Parquet format for efficient columnar querying, and caches them locally. The current CLI's `bk job log` is minimal — it fetches the entire log via REST API and pipes it to a pager with no search, pagination, tailing, or follow capabilities. This PRD designs the enhancement of `bk job log` to replicate MCP server capabilities in a CLI-native way. + +**Why now:** CI/CD debugging is the #1 CLI use case. Users currently copy-paste job UUIDs, wait for full log downloads, and manually grep through output. The `buildkite-logs` library already solves the hard problems (Parquet conversion, caching, efficient search). The CLI just needs to wire it up. + +--- + +## Goals + +1. **Parity with MCP server log tools** — read with seek/limit, regex search with context, tail last N lines +2. **Live follow mode** — `tail -f` equivalent for running jobs, with 2-second polling +3. **Interactive job picker** — eliminate the need to copy-paste job UUIDs +4. **Industry-standard UX** — grep-familiar flags (-g, -C, -A, -B, -v, -i), matches kubectl/Railway/Heroku conventions +5. **Backward compatible** — existing `bk job log JOB_ID -b 123` works identically + +--- + +## Command Interface + +``` +bk job log [JOB_ID] [-b BUILD] [-p PIPELINE] [flags] +``` + +JOB_ID becomes **optional** — when omitted, an interactive job picker is presented. + +### Reading Flags +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--seek` | | int | -1 | Start reading from row N (0-based) | +| `--limit` | | int | 0 | Maximum number of lines to output | +| `--tail` | `-n` | int | 0 | Show last N lines | +| `--follow` | `-f` | bool | false | Follow log output for running jobs (2s poll) | + +### Search Flags +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--grep` | `-g` | string | "" | Regex pattern to search for | +| `--context` | `-C` | int | 0 | Lines of context around each match | +| `--after-context` | `-A` | int | 0 | Lines after each match | +| `--before-context` | `-B` | int | 0 | Lines before each match | +| `--ignore-case` | `-i` | bool | true | Case-insensitive search (negate with `--no-ignore-case`) | +| `--invert-match` | `-v` | bool | false | Show non-matching lines | + +### Display Flags +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--no-timestamps` | | bool | false | Strip `bk;t=\d+` timestamp markers (existing) | +| `--no-color` | | bool | false | Strip ANSI codes from log output | +| `--line-numbers` | | bool | false | Prefix each line with its row number | + +### Flag Constraints +- `--tail` and `--seek` are mutually exclusive +- `--follow` cannot combine with `--grep` or `--seek` +- `--grep` is required for `-C`, `-A`, `-B`, `-v` flags + +### Usage Examples +```bash +# Full log (existing behavior, now powered by buildkite-logs) +bk job log 019004-e199-453b -p my-pipeline -b 123 + +# Interactive job picker (omit job ID) +bk job log -p my-pipeline -b 123 + +# Last 50 lines +bk job log JOB_ID -b 123 -n 50 + +# Follow a running job's output +bk job log JOB_ID -b 123 -f + +# Search for errors with 3 lines of context +bk job log JOB_ID -b 123 -g "error|failed|panic" -C 3 + +# Case-sensitive search, inverted +bk job log JOB_ID -b 123 -g "SUCCESS" --no-ignore-case -v + +# Paginated read (rows 100-200) +bk job log JOB_ID -b 123 --seek 100 --limit 100 + +# With line numbers, no color (for piping) +bk job log JOB_ID -b 123 --line-numbers --no-color +``` + +--- + +## Architecture + +### Dependency: `buildkite-logs` v0.8.0 + +The same library the MCP server uses. It provides: +- **`Client`** — downloads raw logs via REST API, converts to Parquet, caches in blob storage (`~/.bklog`) +- **`ParquetReader`** — efficient columnar queries: `ReadEntriesIter()`, `SeekToRow()`, `SearchEntriesIter()`, `GetFileInfo()` +- **`ParquetLogEntry`** — `RowNumber`, `Timestamp`, `Content`, `Group`, `Flags`, `CleanContent(stripANSI)` +- **`SearchResult`** — `Match`, `BeforeContext`, `AfterContext` +- **`SearchOptions`** — `Pattern`, `CaseSensitive`, `InvertMatch`, `Context`, `BeforeContext`, `AfterContext` + +The library accepts `*buildkite.Client` (from `go-buildkite/v4`) which the CLI already creates via Factory. Integration is trivial. + +### Client Initialization + +**New file: `internal/logs/client.go`** + +```go +func NewLogsClient(ctx context.Context, restClient *buildkite.Client) (*buildkitelogs.Client, error) { + storageURL := os.Getenv("BKLOG_CACHE_URL") // Same env var as MCP server + return buildkitelogs.NewClient(ctx, restClient, storageURL) +} +``` + +- Default storage: `~/.bklog` (local filesystem, auto-created) +- Override via `BKLOG_CACHE_URL` env var (supports `file://`, `s3://`, `gcs://`) +- Client created per command invocation, closed via `defer` +- NOT added to Factory — it's command-specific + +### Mode Dispatch in Run() + +``` +Run() +├── 1. Factory + validation (existing) +├── 2. Pipeline/build resolution (existing AggregateResolver) +├── 3. Job resolution (new: interactive picker if JobID empty) +├── 4. Flag validation (mutual exclusivity checks) +├── 5. Create buildkite-logs client +├── 6. Mode dispatch: +│ ├── --grep set → searchMode() +│ ├── --tail > 0 → tailMode() +│ ├── --follow → followMode() +│ └── default → readMode() (handles --seek, --limit, or full read) +└── 7. Output to pager (or stdout for --follow) +``` + +### Mode Implementations + +#### readMode +1. Create `ParquetReader` via `client.NewReader(org, pipeline, build, job, 30s TTL, false)` +2. If `--seek >= 0`: use `reader.SeekToRow(seek)`, else `reader.ReadEntriesIter()` +3. Iterate entries, apply `--limit` if set +4. Write each entry via `writeEntry()` to pager + +#### tailMode +1. Create `ParquetReader` +2. `GetFileInfo()` for total row count +3. Calculate `startRow = max(totalRows - tail, 0)` +4. `SeekToRow(startRow)`, iterate to end +5. Write each entry to pager + +#### searchMode +1. Validate regex pattern early with `regexp.Compile()` +2. Create `ParquetReader` +3. Build `SearchOptions` from flags +4. Iterate `SearchEntriesIter(opts)`, apply `--limit` if set +5. Write each `SearchResult` via `writeSearchResult()` to pager +6. Search results formatted grep-style: context lines, match line, `--` separator between groups + +#### followMode +1. **No pager** — writes directly to stdout +2. Track `lastSeenRow` (starts at 0, or at `totalRows - tail` if `--tail` also provided) +3. Poll loop every 2 seconds: + a. Create reader with `TTL: 0, forceRefresh: true` to bypass cache + b. `GetFileInfo()` to check current row count + c. If new rows: `SeekToRow(lastSeenRow)`, write new entries, update `lastSeenRow` + d. Close reader (cleanup temp files) + e. Check job state via REST API — exit if terminal (passed, failed, canceled, etc.) + f. Wait 2s or exit on Ctrl-C / context cancellation +4. Clean exit with signal handling (`SIGINT`, `SIGTERM`) + +### Output Formatting + +#### writeEntry(writer, entry) +``` +1. Get content from entry.Content (preserves ANSI by default) +2. If --no-color OR piped (not TTY): strip ANSI via library's CleanContent(true) +3. If --no-timestamps: strip bk;t=\d+\x07 patterns (existing regex) +4. TrimRight trailing newlines +5. If --line-numbers: prefix with row number (right-aligned, 6 chars) +6. Write to writer with trailing newline +``` + +#### writeSearchResult(writer, result) +``` +1. Write before-context entries (dimmed if color enabled) +2. Write match entry (normal/highlighted) +3. Write after-context entries (dimmed if color enabled) +4. Write "--" separator between match groups (grep convention) +``` + +#### ANSI Auto-detection +- **TTY output or pager**: Pass ANSI codes through. Pager is `less -R` which handles raw ANSI. +- **Piped/redirected**: Strip ANSI codes automatically. +- **`--no-color` flag**: Force stripping regardless. +- **`NO_COLOR` env var**: Already handled by existing `output.ColorEnabled()`. + +### Interactive Job Picker + +When `JobID` is empty and terminal is interactive (`!NoInput && isTTY`): + +1. Fetch build via REST API: `Builds.Get(org, pipeline, buildNumber)` +2. Filter to `type == "script"` jobs (skip wait/trigger/block steps) +3. If only 1 job: auto-select it +4. If multiple: present numbered list via existing `io.PromptForOne()`: + ``` + Select a job: + 1. Run tests (passed) - 0190046e-e199-453b-a302-a21a4d649d31 + 2. Deploy staging (failed) - 0190046e-e199-453b-a302-a21a4d649d32 + 3. Integration tests (running) - 0190046e-e199-453b-a302-a21a4d649d33 + ``` +5. Extract job UUID from selection + +--- + +## Files to Modify + +| File | Action | Description | +|------|--------|-------------| +| `go.mod` | Modify | Add `github.com/buildkite/buildkite-logs v0.8.0` | +| `go.sum` | Auto | `go mod tidy` | +| `internal/logs/client.go` | **New** | `NewLogsClient()` helper | +| `cmd/job/log.go` | **Rewrite** | Enhanced LogCmd struct, Run() flow, all mode functions, output formatting | +| `cmd/job/log_test.go` | **New** | Unit tests for flags, formatting, validation | +| `main.go` | No change | Already references `job.LogCmd` | + +### Key Reference Files +- `buildkite-mcp-server/pkg/buildkite/joblogs.go` — Reference implementation for all three modes +- `buildkite-logs@v0.8.0/client.go` — `NewClient()`, `NewReader()` API surface +- `buildkite-logs@v0.8.0/query.go` — `ParquetReader` methods +- `pkg/cmd/factory/factory.go` — How `RestAPIClient` is created (line 176) +- `internal/io/pager.go` — Pager creation pattern +- `internal/io/prompt.go` — Interactive picker pattern +- `pkg/output/color.go` — `ColorEnabled()` for TTY/NO_COLOR detection +- `cmd/build/watch.go` + `internal/build/watch/watch.go` — Polling pattern reference for follow mode + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Missing API token | Factory fails at creation (existing) | +| Can't create `~/.bklog` cache dir | Clear error suggesting `BKLOG_CACHE_URL` override | +| API 404 (wrong job ID) | "Job not found. Verify the job UUID and build number." | +| API 401 (expired token) | "Authentication failed. Run `bk auth login` to re-authenticate." | +| `ErrLogTooLarge` (>10MB) | "Log exceeds 10MB. Use `--tail N` or `--seek/--limit` to read a portion." | +| Invalid regex in `--grep` | Validate early before creating reader. "Invalid regex pattern: ..." | +| Empty log (0 rows) | "No log output for this job." (exit 0) | +| `--follow` on terminal job | Print existing log, then "Job already finished (state: passed)." (exit 0) | +| Seek beyond end of file | "Row N is beyond log end (total: M rows). Use --tail or a smaller --seek value." | +| Network error during follow | Retry silently up to 10 consecutive failures (matches `bk build watch` pattern), then error | + +--- + +## Edge Cases + +1. **Very large logs**: `ErrLogTooLarge` caught and user directed to `--tail`/`--seek` +2. **Running jobs with no output yet**: RowCount=0. Follow mode keeps polling. Other modes: "No log output yet." +3. **Retried jobs**: Library handles `RetrySource` internally +4. **Cancelled jobs mid-follow**: Terminal state detected, follow exits cleanly +5. **Non-TTY + --follow**: Works fine, streams to stdout. No spinner. +6. **WSL cross-filesystem**: Default blob storage in `~/.bklog` avoids cross-device temp file issues +7. **Job ID format**: Validate UUID format early. GraphQL-style IDs should be rejected with guidance. + +--- + +## Testing Strategy + +### Unit Tests (`cmd/job/log_test.go`) +1. **Flag validation**: `--tail` + `--seek` conflict, `--follow` + `--grep` conflict, `--grep` required for context flags +2. **Output formatting**: `writeEntry` with line numbers, ANSI stripping, timestamp stripping +3. **Search result formatting**: Context lines, separators +4. **Job picker filtering**: Only `type == "script"` jobs, single-job auto-select +5. **stripTimestamps**: Existing regex behavior preserved + +### Manual Integration Testing +```bash +# Against a real pipeline: +bk job log -b LATEST_BUILD # test job picker +bk job log JOB -b BUILD -n 20 # test tail +bk job log JOB -b BUILD -g "error" -C 2 # test search +bk job log JOB -b BUILD --seek 0 --limit 10 # test paginated read +bk job log JOB -b BUILD -f # test follow on running job +bk job log JOB -b BUILD | head -5 # test piped (no pager, no color) +bk job log JOB -b BUILD --line-numbers # test line numbers +``` + +--- + +## Implementation Sequence + +1. `go.mod` — add `buildkite-logs v0.8.0`, run `go mod tidy` +2. `internal/logs/client.go` — `NewLogsClient()` helper +3. `cmd/job/log.go` — rewrite in stages: + a. Update `LogCmd` struct with all flags, update `Help()` + b. Refactor `Run()` with job picker + mode dispatch skeleton + c. Implement `readMode()` first (closest to existing, validates library integration) + d. Implement `tailMode()` + e. Implement `searchMode()` with `writeSearchResult()` + f. Implement `followMode()` (most complex, do last) + g. Implement `writeEntry()` with ANSI auto-detection +4. `cmd/job/log_test.go` — unit tests +5. Manual integration testing diff --git a/internal/io/pager.go b/internal/io/pager.go index 5be7e534..4ca0c3a8 100644 --- a/internal/io/pager.go +++ b/internal/io/pager.go @@ -18,7 +18,7 @@ import ( func Pager(noPager bool, pagerCmd ...string) (w io.Writer, cleanup func() error) { cleanup = func() error { return nil } - if noPager || !isTTY() { + if noPager || !IsTTY() { return os.Stdout, cleanup } @@ -84,7 +84,8 @@ func Pager(noPager bool, pagerCmd ...string) (w io.Writer, cleanup func() error) return stdin, cleanup } -func isTTY() bool { +// IsTTY reports whether stdout is connected to a terminal. +func IsTTY() bool { if isatty.IsTerminal(os.Stdout.Fd()) { return true } 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...) +}