diff --git a/docs/adr/59697-add-ignore-workflow-runs-to-logs.md b/docs/adr/59697-add-ignore-workflow-runs-to-logs.md new file mode 100644 index 00000000000..16303c9cf6c --- /dev/null +++ b/docs/adr/59697-add-ignore-workflow-runs-to-logs.md @@ -0,0 +1,50 @@ +# ADR-59697: Add ignore-workflow-runs to logs + +**Date**: 2026-09-09 +**Status**: Draft +**Deciders**: gh-aw maintainers + +--- + +### Context + +The `gh aw logs` command collects workflow runs and currently lets users constrain results with workflow, date, ref, and run ID filters, but it cannot explicitly skip known unwanted runs while still returning the requested count of useful results. This PR adds a new exclusion input that accepts workflow run IDs in plain numeric form or qualified `slug/ID` form, applies the exclusion before artifact processing, and preserves it in continuation state for resumed downloads. The implementation evidence shows a need to omit specific runs without shrinking the effective result set or forcing users to narrow broader filters. The repository needs an explicit decision on whether run exclusion should be treated as a first-class logs query parameter across CLI parsing, pagination, orchestration, and reporting. + +### Decision + +We will add an `--ignore-workflow-runs` option to `gh aw logs` that accepts positive workflow run database IDs, including qualified `slug/ID` inputs, normalizes them to unique numeric IDs, and excludes matching runs from collection. We will apply the ignore list before downstream artifact processing and persist it through orchestration and continuation payloads so resumed or paginated log downloads keep honoring the same exclusion set. We chose this approach because it gives users precise control over unwanted runs without changing the requested result count or overloading existing date, ref, or before/after run filters. + +### Alternatives Considered + +#### Alternative 1: Require users to refine existing filters such as `--ref`, `--before-run-id`, or `--after-run-id` + +The project could continue relying on broader query filters and ask users to manually narrow the candidate run set. This was considered because it avoids adding a new option and reuses existing filtering semantics. It was not chosen because the PR evidence explicitly addresses the need to skip specific known runs while still preserving the overall count and broader search scope. + +#### Alternative 2: Support exclusion only for raw numeric run IDs + +Another option would be to accept only integer run IDs and reject qualified `slug/ID` inputs. This was considered because it would simplify parsing and validation. It was not chosen because the PR explicitly supports both numeric IDs and qualified values, which improves usability when users copy workflow run references from repository-qualified contexts. + +#### Alternative 3: Exclude runs only in the final output after artifacts are processed + +The implementation could defer exclusion until after run data is downloaded and assembled. This was considered because it would minimize changes to earlier pagination and orchestration code paths. It was not chosen because the PR description and code both indicate exclusions should happen before artifact processing so ignored runs do not consume result slots or processing work. + +### Consequences + +#### Positive +- Users can omit known irrelevant or problematic workflow runs without reducing the requested number of collected results. +- The same exclusion behavior is preserved across pagination and continuation data, making resumed downloads more predictable. +- Supporting both numeric and `slug/ID` inputs reduces friction when specifying runs from copied GitHub references. + +#### Negative +- The logs query path becomes more complex because ignore-list parsing, deduplication, filtering, and continuation serialization must all stay in sync. +- Invalid or non-positive run identifiers now introduce an additional user-facing validation failure mode. +- Exclusion behavior must be covered in multiple test layers to avoid regressions across CLI and orchestration flows. + +#### Neutral +- `LogsDownloadOptions`, pagination options, and continuation payloads gain an `IgnoreWorkflowRuns` field. +- Run filtering semantics now include explicit exclusions in addition to inclusive workflow, date, ref, and run ID bounds. +- The implementation uses run database IDs as the stable identifier for exclusion across query and resume boundaries. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/logs_command.go b/pkg/cli/logs_command.go index 5768646b078..1678ea41fa6 100644 --- a/pkg/cli/logs_command.go +++ b/pkg/cli/logs_command.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "time" @@ -429,6 +430,10 @@ func loadCommonLogsOptions(cmd *cobra.Command) (LogsDownloadOptions, error) { ArtifactSets: getStringSliceFlag(cmd, "artifacts"), CachedJSON: getStringFlag(cmd, "cached-json"), } + options.IgnoreWorkflowRuns, err = parseIgnoredWorkflowRunIDs(getStringSliceFlag(cmd, "ignore-workflow-runs")) + if err != nil { + return LogsDownloadOptions{}, err + } if err := validateLogsOptions(options); err != nil { return LogsDownloadOptions{}, err } @@ -439,6 +444,25 @@ func loadCommonLogsOptions(cmd *cobra.Command) (LogsDownloadOptions, error) { return options, nil } +func parseIgnoredWorkflowRunIDs(values []string) ([]int64, error) { + runIDs := make([]int64, 0, len(values)) + for _, value := range values { + originalValue := value + value = strings.TrimSpace(value) + if index := strings.LastIndexByte(value, '/'); index >= 0 { + value = value[index+1:] + } + runID, err := strconv.ParseInt(value, 10, 64) + if err != nil || runID <= 0 { + return nil, fmt.Errorf("invalid workflow run %q: expected a positive run ID or slug/ID (for example, 123 or github/gh-aw/123)", originalValue) + } + if !slices.Contains(runIDs, runID) { + runIDs = append(runIDs, runID) + } + } + return runIDs, nil +} + func resolveLogsDateRange(startDate, endDate string, now time.Time) (string, string, error) { resolve := func(label, value string) (string, error) { if value == "" { @@ -545,6 +569,7 @@ func addLogsCommandFlags(logsCmd *cobra.Command, validArtifactSets string) { logsCmd.Flags().String("ref", "", "Filter runs by branch or tag name (e.g., main, v1.0.0)") logsCmd.Flags().Int64("before-run-id", 0, "Filter runs with database ID before this value (exclusive)") logsCmd.Flags().Int64("after-run-id", 0, "Filter runs with database ID after this value (exclusive)") + logsCmd.Flags().StringSlice("ignore-workflow-runs", nil, "Workflow run IDs or slug/ID values to exclude (slug is informational; matching uses the numeric ID)") addRepoFlag(logsCmd) logsCmd.Flags().Bool("tool-graph", false, "Generate Mermaid tool sequence graph from agent logs") logsCmd.Flags().Bool("exclude-staged", false, "Exclude workflow runs that executed in staged mode (safe outputs previewed but not applied)") diff --git a/pkg/cli/logs_command_test.go b/pkg/cli/logs_command_test.go index ab87493ca7d..c344b531bd3 100644 --- a/pkg/cli/logs_command_test.go +++ b/pkg/cli/logs_command_test.go @@ -64,6 +64,8 @@ func TestNewLogsCommand(t *testing.T) { assert.NotNil(t, afterRunIDFlag, "Should have 'after-run-id' flag") beforeRunIDFlag := flags.Lookup("before-run-id") assert.NotNil(t, beforeRunIDFlag, "Should have 'before-run-id' flag") + ignoreWorkflowRunsFlag := flags.Lookup("ignore-workflow-runs") + assert.NotNil(t, ignoreWorkflowRunsFlag, "Should have 'ignore-workflow-runs' flag") lastFlag := flags.Lookup("last") assert.NotNil(t, lastFlag, "Should have 'last' flag") assert.Contains(t, lastFlag.Usage, "--count/-c", "--last usage should mention the canonical --count/-c flag") @@ -331,6 +333,28 @@ func TestLogsCommandRunIDFilters(t *testing.T) { } } +func TestLogsCommandIgnoreWorkflowRuns(t *testing.T) { + cmd := NewLogsCommand() + require.NoError(t, cmd.Flags().Set("ignore-workflow-runs", "123,github/gh-aw/456,123")) + + opts, err := loadCommonLogsOptions(cmd) + + require.NoError(t, err) + assert.Equal(t, []int64{123, 456}, opts.IgnoreWorkflowRuns) +} + +func TestLogsCommandRejectsInvalidIgnoredWorkflowRun(t *testing.T) { + cmd := NewLogsCommand() + require.NoError(t, cmd.Flags().Set("ignore-workflow-runs", "github/gh-aw/not-a-run")) + + _, err := loadCommonLogsOptions(cmd) + + require.Error(t, err) + assert.Contains(t, err.Error(), "github/gh-aw/not-a-run") + assert.Contains(t, err.Error(), "expected a positive run ID or slug/ID") + assert.Contains(t, err.Error(), "123 or github/gh-aw/123") +} + func TestLogsCommandOutputFlag(t *testing.T) { t.Parallel() cmd := NewLogsCommand() diff --git a/pkg/cli/logs_github_api.go b/pkg/cli/logs_github_api.go index 2b52f973c76..0b50f9485ea 100644 --- a/pkg/cli/logs_github_api.go +++ b/pkg/cli/logs_github_api.go @@ -365,17 +365,18 @@ func fetchJobStatuses(ctx context.Context, runID int64, verbose bool) (int, erro // ListWorkflowRunsOptions holds the options for listWorkflowRunsWithPagination type ListWorkflowRunsOptions struct { - Context context.Context - WorkflowName string // filter by specific workflow (if empty, fetches all agentic workflows) - Status string // filter by run status/conclusion (for example: completed, success, failure) - Limit int // maximum number of runs to fetch in this API call (batch size) - StartDate string // filter by creation date (>=); combined with EndDate/BeforeDate into a single --created range - EndDate string // filter by creation date (<=); combined with StartDate into a single --created range - BeforeDate string // exclusive upper bound used for pagination (<); combined with StartDate into a single --created range - Ref string // filter by branch or tag name - BeforeRunID int64 // filter by run database ID (< this ID) - AfterRunID int64 // filter by run database ID (> this ID) - RepoOverride string // fetch from a specific repository instead of current + Context context.Context + WorkflowName string // filter by specific workflow (if empty, fetches all agentic workflows) + Status string // filter by run status/conclusion (for example: completed, success, failure) + Limit int // maximum number of runs to fetch in this API call (batch size) + StartDate string // filter by creation date (>=); combined with EndDate/BeforeDate into a single --created range + EndDate string // filter by creation date (<=); combined with StartDate into a single --created range + BeforeDate string // exclusive upper bound used for pagination (<); combined with StartDate into a single --created range + Ref string // filter by branch or tag name + BeforeRunID int64 // filter by run database ID (< this ID) + AfterRunID int64 // filter by run database ID (> this ID) + IgnoreWorkflowRuns []int64 // exclude these workflow run database IDs + RepoOverride string // fetch from a specific repository instead of current // OldestFetchedCreatedAt, when set, is populated with the oldest run creation // timestamp returned by GitHub in this batch before any workflow/conclusion filtering. OldestFetchedCreatedAt *time.Time @@ -569,6 +570,7 @@ func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun if opts.BeforeRunID > 0 && run.DatabaseID >= opts.BeforeRunID { continue } + // Apply after-run-id filter (exclusive) if opts.AfterRunID > 0 && run.DatabaseID <= opts.AfterRunID { continue @@ -578,6 +580,8 @@ func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun agenticRuns = filteredRuns } + agenticRuns = filterIgnoredWorkflowRuns(agenticRuns, opts.IgnoreWorkflowRuns) + // Filter out runs that never dispatched an agentic job — skipped and // action_required runs carry no useful agentic data — along with cancelled // runs. None of them should count toward the requested run count. @@ -595,6 +599,19 @@ func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun return agenticRuns, totalFetched, nil } +func filterIgnoredWorkflowRuns(runs []WorkflowRun, ignoredRunIDs []int64) []WorkflowRun { + if len(ignoredRunIDs) == 0 { + return runs + } + filtered := make([]WorkflowRun, 0, len(runs)) + for _, run := range runs { + if !slices.Contains(ignoredRunIDs, run.DatabaseID) { + filtered = append(filtered, run) + } + } + return filtered +} + func applyWorkflowRunListRepository(runs []WorkflowRun, repoOverride string) { if len(runs) == 0 { return diff --git a/pkg/cli/logs_github_api_test.go b/pkg/cli/logs_github_api_test.go index 2150c87ef45..0b0a6b11732 100644 --- a/pkg/cli/logs_github_api_test.go +++ b/pkg/cli/logs_github_api_test.go @@ -31,8 +31,7 @@ func TestWorkflowRunUnmarshal(t *testing.T) { "startedAt": "2026-01-01T00:00:01Z", "updatedAt": "2026-01-01T00:01:00Z", "attempt": 2 -} -]` +}]` var runs []WorkflowRun require.NoError(t, json.Unmarshal([]byte(rawJSON), &runs), "unmarshal should succeed") @@ -44,6 +43,16 @@ func TestWorkflowRunUnmarshal(t *testing.T) { assert.Equal(t, 2, runs[0].Attempt, "Attempt should be populated") } +func TestFilterIgnoredWorkflowRuns(t *testing.T) { + runs := []WorkflowRun{{DatabaseID: 100}, {DatabaseID: 200}, {DatabaseID: 300}} + + filtered := filterIgnoredWorkflowRuns(runs, []int64{100, 300}) + + require.Len(t, filtered, 1) + assert.Equal(t, int64(200), filtered[0].DatabaseID) + assert.Equal(t, []WorkflowRun{{DatabaseID: 100}, {DatabaseID: 200}, {DatabaseID: 300}}, runs) +} + func TestApplyWorkflowRunListRepository(t *testing.T) { runs := []WorkflowRun{ {DatabaseID: 1, Repository: ""}, diff --git a/pkg/cli/logs_orchestrator.go b/pkg/cli/logs_orchestrator.go index b3dafb12139..5bbb3fd410d 100644 --- a/pkg/cli/logs_orchestrator.go +++ b/pkg/cli/logs_orchestrator.go @@ -287,6 +287,7 @@ func buildContinuationIfNeeded( Branch: opts.branch, AfterRunID: opts.afterRunID, BeforeRunID: oldestRunID, + IgnoreWorkflowRuns: opts.ignoreWorkflowRuns, Timeout: opts.timeoutMinutes, MaxGitHubAPIRateLimit: opts.maxGitHubAPIRateLimit, MaxStorageMB: opts.maxStorageMB, @@ -350,6 +351,7 @@ func collectWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) (workflo engine: opts.Engine, branch: opts.Ref, afterRunID: opts.AfterRunID, + ignoreWorkflowRuns: opts.IgnoreWorkflowRuns, count: opts.Count, timeoutMinutes: opts.TimeoutMinutes, maxGitHubAPIRateLimit: opts.MaxGitHubAPIRateLimit, diff --git a/pkg/cli/logs_orchestrator_download.go b/pkg/cli/logs_orchestrator_download.go index 88df8c003aa..c8c4b22a9b1 100644 --- a/pkg/cli/logs_orchestrator_download.go +++ b/pkg/cli/logs_orchestrator_download.go @@ -408,6 +408,7 @@ func fetchWorkflowRunBatch(ctx context.Context, opts LogsDownloadOptions, before Ref: opts.Ref, BeforeRunID: opts.BeforeRunID, AfterRunID: opts.AfterRunID, + IgnoreWorkflowRuns: opts.IgnoreWorkflowRuns, RepoOverride: opts.RepoOverride, OldestFetchedCreatedAt: &oldestFetchedCreatedAt, ProcessedCount: processedCount, diff --git a/pkg/cli/logs_orchestrator_types.go b/pkg/cli/logs_orchestrator_types.go index f248ec5e7d7..844b0a5c13b 100644 --- a/pkg/cli/logs_orchestrator_types.go +++ b/pkg/cli/logs_orchestrator_types.go @@ -6,26 +6,27 @@ package cli // LogsDownloadOptions holds parameters for DownloadWorkflowLogs. type LogsDownloadOptions struct { - WorkflowName string - Count int - StartDate string - EndDate string - OutputDir string - Engine string - Runtime string - Ref string - BeforeRunID int64 - AfterRunID int64 - RepoOverride string - Verbose bool - ToolGraph bool - NoStaged bool - FirewallOnly bool - NoFirewall bool - Parse bool - JSONOutput bool - TimeoutMinutes int - TimeoutSeconds int + WorkflowName string + Count int + StartDate string + EndDate string + OutputDir string + Engine string + Runtime string + Ref string + BeforeRunID int64 + AfterRunID int64 + IgnoreWorkflowRuns []int64 + RepoOverride string + Verbose bool + ToolGraph bool + NoStaged bool + FirewallOnly bool + NoFirewall bool + Parse bool + JSONOutput bool + TimeoutMinutes int + TimeoutSeconds int // MaxGitHubAPIRateLimit is the maximum number of core API requests that may // be used in the current window before downloads wait for the reset. Negative // values reserve that many requests from the API-reported limit. @@ -111,6 +112,7 @@ type continuationOptions struct { engine string branch string afterRunID int64 + ignoreWorkflowRuns []int64 count int timeoutMinutes int maxGitHubAPIRateLimit int diff --git a/pkg/cli/logs_report.go b/pkg/cli/logs_report.go index 175962068f9..03cfa5b7544 100644 --- a/pkg/cli/logs_report.go +++ b/pkg/cli/logs_report.go @@ -48,19 +48,20 @@ type LogsData struct { // ContinuationData provides parameters to continue an incomplete logs query. type ContinuationData struct { - Message string `json:"message"` - WorkflowName string `json:"workflow_name,omitempty"` - Count int `json:"count,omitempty"` - StartDate string `json:"start_date,omitempty"` - EndDate string `json:"end_date,omitempty"` - Engine string `json:"engine,omitempty"` - Branch string `json:"branch,omitempty"` - AfterRunID int64 `json:"after_run_id,omitempty"` - BeforeRunID int64 `json:"before_run_id,omitempty"` - Timeout int `json:"timeout,omitempty"` - MaxGitHubAPIRateLimit int `json:"max_github_api_rate_limit,omitempty"` - MaxStorageMB int `json:"max_storage,omitempty"` - PruneOlderRuns bool `json:"prune_older_runs,omitempty"` + Message string `json:"message"` + WorkflowName string `json:"workflow_name,omitempty"` + Count int `json:"count,omitempty"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` + Engine string `json:"engine,omitempty"` + Branch string `json:"branch,omitempty"` + AfterRunID int64 `json:"after_run_id,omitempty"` + BeforeRunID int64 `json:"before_run_id,omitempty"` + IgnoreWorkflowRuns []int64 `json:"ignore_workflow_runs,omitempty"` + Timeout int `json:"timeout,omitempty"` + MaxGitHubAPIRateLimit int `json:"max_github_api_rate_limit,omitempty"` + MaxStorageMB int `json:"max_storage,omitempty"` + PruneOlderRuns bool `json:"prune_older_runs,omitempty"` } // WorkflowContinuation identifies a per-target cursor in a combined diff --git a/pkg/cli/logs_timeout_test.go b/pkg/cli/logs_timeout_test.go index 9fe46d5afbb..5a0df05261c 100644 --- a/pkg/cli/logs_timeout_test.go +++ b/pkg/cli/logs_timeout_test.go @@ -16,6 +16,7 @@ func TestBuildLogsCommandArgsIncludesResourceBudgets(t *testing.T) { MaxGitHubAPIRateLimit: -2000, MaxStorageMB: 10240, PruneOlderRuns: true, + IgnoreWorkflowRuns: []int64{123, 456}, }) command := strings.Join(cmdArgs, " ") @@ -28,6 +29,9 @@ func TestBuildLogsCommandArgsIncludesResourceBudgets(t *testing.T) { if !strings.Contains(command, "--prune-older-runs") { t.Fatalf("command args do not include older-run pruning mode: %s", command) } + if !strings.Contains(command, "--ignore-workflow-runs 123,456") { + t.Fatalf("command args do not include ignored workflow runs: %s", command) + } } // TestTimeoutFlagParsing tests that the timeout flag is properly parsed diff --git a/pkg/cli/mcp_tools_privileged.go b/pkg/cli/mcp_tools_privileged.go index 1f15670f269..d3d39ebbb55 100644 --- a/pkg/cli/mcp_tools_privileged.go +++ b/pkg/cli/mcp_tools_privileged.go @@ -110,6 +110,7 @@ type logsArgs struct { Branch string `json:"branch,omitempty" jsonschema:"Filter runs by branch name"` AfterRunID int64 `json:"after_run_id,omitempty" jsonschema:"Filter runs with database ID after this value (exclusive)"` BeforeRunID int64 `json:"before_run_id,omitempty" jsonschema:"Filter runs with database ID before this value (exclusive)"` + IgnoreWorkflowRuns []int64 `json:"ignore_workflow_runs,omitempty" jsonschema:"Workflow run IDs to exclude"` Timeout int `json:"timeout,omitempty" jsonschema:"Maximum time in minutes to spend downloading logs (default: auto-scales with count in the MCP server, rounded up in 40-run increments; e.g. 1 minute up to 40, 2 minutes for 41-80, 3 minutes for 81-120, and so on)"` MaxGitHubAPIRateLimit int `json:"max_github_api_rate_limit,omitempty" jsonschema:"Maximum used GitHub core API requests before waiting for reset. Positive values are absolute; negative values reserve requests from the API-reported limit (for example, 12000 or -2000)."` MaxStorageMB int `json:"max_storage,omitempty" jsonschema:"Maximum logs storage in MB after pruning non-essential cache data (0 means unlimited)."` @@ -365,6 +366,13 @@ func appendLogsFilterArgs(cmdArgs []string, args logsArgs) []string { if args.BeforeRunID > 0 { cmdArgs = append(cmdArgs, "--before-run-id", strconv.FormatInt(args.BeforeRunID, 10)) } + if len(args.IgnoreWorkflowRuns) > 0 { + runIDs := make([]string, len(args.IgnoreWorkflowRuns)) + for i, runID := range args.IgnoreWorkflowRuns { + runIDs[i] = strconv.FormatInt(runID, 10) + } + cmdArgs = append(cmdArgs, "--ignore-workflow-runs", strings.Join(runIDs, ",")) + } if args.MaxGitHubAPIRateLimit != 0 { cmdArgs = append(cmdArgs, "--max-github-api-rate-limit", strconv.Itoa(args.MaxGitHubAPIRateLimit)) }