Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/grumpy-reviewer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/mattpocock-skills-reviewer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/pr-code-quality-reviewer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/pr-nitpick-reviewer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/pr-triage-agent.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/refiner.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/security-review.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-claude.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-aoai-apikey.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-aoai-entra.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-arm.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/test-quality-sentinel.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 24 additions & 14 deletions pkg/cli/logs_github_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] OldestFetchedCreatedAt is an output field on an options struct, mixing input and output concerns in ListWorkflowRunsOptions. Future callers reading the struct type won't immediately know this field is populated by the function, not consumed by it.

💡 Alternative

Return it as an additional named return value alongside totalFetched:

func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun, int, time.Time, error)
//                                                                              ^^^^^^^^^^^ oldest fetched

This keeps the options struct as pure input and makes the out-param visible at every call site.

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.
Expand Down Expand Up @@ -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
}
Comment thread
pelikhan marked this conversation as resolved.

// 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
Expand Down
72 changes: 53 additions & 19 deletions pkg/cli/logs_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] selectPaginationCursorDate silently assumes filteredRuns is sorted descending (newest first) so the last element is the oldest. This matches GitHub's default run ordering, but the assumption isn't documented or enforced — a future change that reorders runs before calling this function would silently pick the wrong cursor.

💡 Suggestion

Add a brief comment to document the contract:

// filteredRuns must be ordered newest-first (GitHub API default);
// the last element is treated as the oldest.

The same assumption applies in logs_github_api.go where runs[totalFetched-1].CreatedAt is used for OldestFetchedCreatedAt.

}
Comment thread
pelikhan marked this conversation as resolved.

// DownloadWorkflowLogs downloads and analyzes workflow logs with metrics
func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error {
workflowName := opts.WorkflowName
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] No guard against cursor stagnation: if GitHub returns a full batch where all runs share the same CreatedAt timestamp as the previous batch's oldest, beforeDate is set to the same value and the next API call returns an identical page. The loop then spins until MaxIterations, silently truncating results.

💡 Suggested safeguard

Track the previous cursor and break (or log a warning) if it hasn't advanced:

if cursor == beforeDate {
    if verbose {
        fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Pagination cursor did not advance; stopping to prevent infinite loop"))
    }
    break
}

This is a degenerate case, but in high-volume repos with many same-second runs it is reachable.

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 {
Expand Down Expand Up @@ -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
Expand All @@ -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"))
}
Expand Down
77 changes: 77 additions & 0 deletions pkg/cli/logs_orchestrator_pagination_test.go
Original file line number Diff line number Diff line change
@@ -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,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestShouldStopPagination is missing the totalFetched == 0 case. It correctly returns true (stop) via the formula, but an explicit test documents the intent and would catch a future refactor that accidentally changes the semantics for empty batches.

💡 Suggested addition
{
    name:         "stop when no runs fetched",
    totalFetched: 0,
    batchSize:    250,
    want:         true,
},

}

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)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] The helpers are tested in isolation, but the orchestrator loop's continue path — the core of this bug fix — has no regression test. If someone swaps continue for break in the if len(runs) == 0 block, all these tests still pass.

💡 Suggested approach

Add a test that exercises the orchestrator with a mock listWorkflowRunsWithPagination that returns:

  1. A full batch (totalFetched == batchSize) with zero filtered runs
  2. A partial batch that terminates pagination

Assert the API is called at least twice and beforeDate correctly advances. This directly documents and protects the fix.

Loading