From cfecebe1bef553f158457e8b7457812087788b22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:31:30 +0000 Subject: [PATCH 1/3] Initial plan From 633fa6a27abf39f5238ac2999b96056321fff58a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:39:07 +0000 Subject: [PATCH 2/3] chore: outline plan for logs pagination fix Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/grumpy-reviewer.lock.yml | 2 +- .github/workflows/mattpocock-skills-reviewer.lock.yml | 2 +- .github/workflows/pr-code-quality-reviewer.lock.yml | 2 +- .github/workflows/pr-nitpick-reviewer.lock.yml | 2 +- .github/workflows/pr-triage-agent.lock.yml | 2 +- .github/workflows/refiner.lock.yml | 2 +- .github/workflows/security-review.lock.yml | 2 +- .github/workflows/smoke-claude.lock.yml | 2 +- .github/workflows/smoke-copilot-aoai-apikey.lock.yml | 2 +- .github/workflows/smoke-copilot-aoai-entra.lock.yml | 2 +- .github/workflows/smoke-copilot-arm.lock.yml | 2 +- .github/workflows/smoke-copilot.lock.yml | 2 +- .github/workflows/test-quality-sentinel.lock.yml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index f0767b939c0..235620f694d 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -742,7 +742,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/mattpocock-skills-reviewer.lock.yml b/.github/workflows/mattpocock-skills-reviewer.lock.yml index 630293ffdf3..700ecf36ac1 100644 --- a/.github/workflows/mattpocock-skills-reviewer.lock.yml +++ b/.github/workflows/mattpocock-skills-reviewer.lock.yml @@ -772,7 +772,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/pr-code-quality-reviewer.lock.yml b/.github/workflows/pr-code-quality-reviewer.lock.yml index c54b67124fe..d45a2313870 100644 --- a/.github/workflows/pr-code-quality-reviewer.lock.yml +++ b/.github/workflows/pr-code-quality-reviewer.lock.yml @@ -736,7 +736,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index 8c2646e811d..6130f10bf6c 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -771,7 +771,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/pr-triage-agent.lock.yml b/.github/workflows/pr-triage-agent.lock.yml index a5f1b83fdef..36618b1e0ce 100644 --- a/.github/workflows/pr-triage-agent.lock.yml +++ b/.github/workflows/pr-triage-agent.lock.yml @@ -790,7 +790,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/refiner.lock.yml b/.github/workflows/refiner.lock.yml index e316b50c6a2..b9c4c472934 100644 --- a/.github/workflows/refiner.lock.yml +++ b/.github/workflows/refiner.lock.yml @@ -784,7 +784,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/security-review.lock.yml b/.github/workflows/security-review.lock.yml index cc0487ee1b3..813428b8085 100644 --- a/.github/workflows/security-review.lock.yml +++ b/.github/workflows/security-review.lock.yml @@ -802,7 +802,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 09e2304f27e..0b49593d1c6 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -1252,7 +1252,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml index 05fca4ab840..90d951b7862 100644 --- a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml @@ -1168,7 +1168,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-copilot-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-aoai-entra.lock.yml index b956d54065f..2dd7ce15d8b 100644 --- a/.github/workflows/smoke-copilot-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-entra.lock.yml @@ -1169,7 +1169,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-copilot-arm.lock.yml b/.github/workflows/smoke-copilot-arm.lock.yml index a84512ac19f..838dc591aa4 100644 --- a/.github/workflows/smoke-copilot-arm.lock.yml +++ b/.github/workflows/smoke-copilot-arm.lock.yml @@ -1037,7 +1037,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 6ec9501f68a..568e58f5714 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1174,7 +1174,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", diff --git a/.github/workflows/test-quality-sentinel.lock.yml b/.github/workflows/test-quality-sentinel.lock.yml index 416ffad3543..c87faeb4013 100644 --- a/.github/workflows/test-quality-sentinel.lock.yml +++ b/.github/workflows/test-quality-sentinel.lock.yml @@ -731,7 +731,7 @@ jobs: ] }, "pull_request_number": { - "optionalPositiveInteger": true + "issueOrPRNumber": true }, "repo": { "type": "string", From 66e3754058484bc6d68122cb9d7ac197c981051c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:47:34 +0000 Subject: [PATCH 3/3] fix: continue logs pagination across filtered-empty full batches Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/logs_github_api.go | 38 ++++++---- pkg/cli/logs_orchestrator.go | 72 +++++++++++++----- pkg/cli/logs_orchestrator_pagination_test.go | 77 ++++++++++++++++++++ 3 files changed, 154 insertions(+), 33 deletions(-) create mode 100644 pkg/cli/logs_orchestrator_pagination_test.go diff --git a/pkg/cli/logs_github_api.go b/pkg/cli/logs_github_api.go index b89f076f96d..679478a785d 100644 --- a/pkg/cli/logs_github_api.go +++ b/pkg/cli/logs_github_api.go @@ -167,20 +167,23 @@ func fetchJobDetails(runID int64, verbose bool) ([]JobInfoWithDuration, error) { // 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 - ProcessedCount int // number of runs already processed (for progress display) - TargetCount int // target number of runs to fetch (for progress display) - Verbose bool // enable verbose logging + 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 + // 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 + ProcessedCount int // number of runs already processed (for progress display) + TargetCount int // target number of runs to fetch (for progress display) + Verbose bool // enable verbose logging } // listWorkflowRunsWithPagination fetches workflow runs from GitHub Actions using the GitHub CLI. @@ -319,6 +322,13 @@ func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun // Store the total count fetched from API before filtering totalFetched := len(runs) + if opts.OldestFetchedCreatedAt != nil { + var oldest time.Time + if totalFetched > 0 { + oldest = runs[totalFetched-1].CreatedAt + } + *opts.OldestFetchedCreatedAt = oldest + } // Filter only agentic workflow runs when no specific workflow is specified // If a workflow name was specified, we already filtered by it in the API call diff --git a/pkg/cli/logs_orchestrator.go b/pkg/cli/logs_orchestrator.go index 16811997855..639cb00eb5a 100644 --- a/pkg/cli/logs_orchestrator.go +++ b/pkg/cli/logs_orchestrator.go @@ -63,6 +63,20 @@ type LogsDownloadOptions struct { After string } +func shouldStopPagination(totalFetched, batchSize int) bool { + return totalFetched < batchSize +} + +func selectPaginationCursorDate(filteredRuns []WorkflowRun, oldestFetchedCreatedAt time.Time) (string, bool) { + if !oldestFetchedCreatedAt.IsZero() { + return oldestFetchedCreatedAt.Format(time.RFC3339), true + } + if len(filteredRuns) == 0 { + return "", false + } + return filteredRuns[len(filteredRuns)-1].CreatedAt.Format(time.RFC3339), true +} + // DownloadWorkflowLogs downloads and analyzes workflow logs with metrics func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error { workflowName := opts.WorkflowName @@ -239,29 +253,47 @@ func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error { } } + var oldestFetchedCreatedAt time.Time runs, totalFetched, err := listWorkflowRunsWithPagination(ListWorkflowRunsOptions{ - WorkflowName: workflowName, - Limit: batchSize, - StartDate: startDate, - EndDate: endDate, - BeforeDate: beforeDate, - Ref: ref, - BeforeRunID: beforeRunID, - AfterRunID: afterRunID, - RepoOverride: repoOverride, - ProcessedCount: len(processedRuns), - TargetCount: count, - Verbose: verbose, + WorkflowName: workflowName, + Limit: batchSize, + StartDate: startDate, + EndDate: endDate, + BeforeDate: beforeDate, + Ref: ref, + BeforeRunID: beforeRunID, + AfterRunID: afterRunID, + RepoOverride: repoOverride, + OldestFetchedCreatedAt: &oldestFetchedCreatedAt, + ProcessedCount: len(processedRuns), + TargetCount: count, + Verbose: verbose, }) if err != nil { return err } if len(runs) == 0 { + if shouldStopPagination(totalFetched, batchSize) { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No more workflow runs found, stopping iteration")) + } + break + } + + cursor, ok := selectPaginationCursorDate(nil, oldestFetchedCreatedAt) + if !ok { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Workflow batch filtered to zero runs but no pagination cursor was found, stopping iteration")) + } + break + } + + beforeDate = cursor if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No more workflow runs found, stopping iteration")) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Batch filtered to zero runs; advancing pagination cursor and continuing")) } - break + continue } if verbose { @@ -515,10 +547,12 @@ func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error { } } - // Prepare for next iteration: set beforeDate to the oldest processed run from this batch - if len(runs) > 0 && len(runsRemaining) == 0 { - oldestRun := runs[len(runs)-1] // runs are typically ordered by creation date descending - beforeDate = oldestRun.CreatedAt.Format(time.RFC3339) + // Prepare for next iteration: set beforeDate to the oldest run from the raw API batch. + // This guarantees pagination moves forward even when filtered runs are sparse. + if len(runsRemaining) == 0 { + if cursor, ok := selectPaginationCursorDate(runs, oldestFetchedCreatedAt); ok { + beforeDate = cursor + } } // If we got fewer runs than requested in this batch, we've likely hit the end @@ -528,7 +562,7 @@ func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error { // Example: API returns 250 total runs, but only 5 are agentic workflows after filtering. // Old buggy logic: len(runs)=5 < batchSize=250, stop iteration (WRONG - misses more agentic workflows!) // Fixed logic: totalFetched=250 < batchSize=250 is false, continue iteration (CORRECT) - if totalFetched < batchSize { + if shouldStopPagination(totalFetched, batchSize) { if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Received fewer runs than requested, likely reached end of available runs")) } diff --git a/pkg/cli/logs_orchestrator_pagination_test.go b/pkg/cli/logs_orchestrator_pagination_test.go new file mode 100644 index 00000000000..140b463b31a --- /dev/null +++ b/pkg/cli/logs_orchestrator_pagination_test.go @@ -0,0 +1,77 @@ +//go:build !integration + +package cli + +import ( + "testing" + "time" +) + +func TestShouldStopPagination(t *testing.T) { + tests := []struct { + name string + totalFetched int + batchSize int + want bool + }{ + { + name: "stop when raw batch is smaller than requested", + totalFetched: 249, + batchSize: 250, + want: true, + }, + { + name: "continue when raw batch is full", + totalFetched: 250, + batchSize: 250, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldStopPagination(tt.totalFetched, tt.batchSize); got != tt.want { + t.Fatalf("shouldStopPagination(%d, %d) = %v, want %v", tt.totalFetched, tt.batchSize, got, tt.want) + } + }) + } +} + +func TestSelectPaginationCursorDate(t *testing.T) { + oldestFetched := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) + filteredRuns := []WorkflowRun{ + {CreatedAt: time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC)}, + {CreatedAt: time.Date(2026, 6, 16, 11, 0, 0, 0, time.UTC)}, + } + + cursor, ok := selectPaginationCursorDate(filteredRuns, oldestFetched) + if !ok { + t.Fatal("expected cursor to be set when raw oldest fetched run is available") + } + if cursor != oldestFetched.Format(time.RFC3339) { + t.Fatalf("expected cursor %s, got %s", oldestFetched.Format(time.RFC3339), cursor) + } +} + +func TestSelectPaginationCursorDateFallsBackToFilteredRuns(t *testing.T) { + filteredOldest := time.Date(2026, 6, 15, 18, 30, 0, 0, time.UTC) + filteredRuns := []WorkflowRun{ + {CreatedAt: time.Date(2026, 6, 15, 19, 0, 0, 0, time.UTC)}, + {CreatedAt: filteredOldest}, + } + + cursor, ok := selectPaginationCursorDate(filteredRuns, time.Time{}) + if !ok { + t.Fatal("expected cursor to be set from filtered runs") + } + if cursor != filteredOldest.Format(time.RFC3339) { + t.Fatalf("expected fallback cursor %s, got %s", filteredOldest.Format(time.RFC3339), cursor) + } +} + +func TestSelectPaginationCursorDateNoCursor(t *testing.T) { + cursor, ok := selectPaginationCursorDate(nil, time.Time{}) + if ok { + t.Fatalf("expected no cursor, got %s", cursor) + } +}