feat: add schedule heatmap calendar to --stats output#29587
Conversation
When --stats is enabled (and JSON output is not), display a 7×24 calendar-style heatmap showing how many workflows fire at each hour/day-of-week (UTC). Intensity is shown using block characters (·░▒▓█) and colour-coded in terminal mode. - WorkflowStats gains a Schedules []string field, populated from on.schedule[*].cron in the compiled lock YAML - compile_schedule_calendar.go: cron field parser, schedule grid builder, and displayScheduleCalendar renderer - outputResults: calls displayScheduleCalendar after displayStatsTable - compileAllFilesInDirectory: forwards discovered mdFiles into config.MarkdownFiles before outputResults so --stats works when no explicit files are given - 19 unit tests in compile_schedule_calendar_test.go Agent-Logs-Url: https://github.com/github/gh-aw/sessions/8289092c-4b49-4824-bdfd-9f128955c5a2 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/8289092c-4b49-4824-bdfd-9f128955c5a2 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a 7×24 (UTC) schedule heatmap to --stats output so users can quickly see when workflows are scheduled to run and identify hourly/day-of-week hotspots.
Changes:
- Extend
WorkflowStatsto collecton.schedule[*].cronexpressions from lock YAML. - Add cron parsing + heatmap aggregation/rendering for schedule visualization in
--stats. - Ensure the “compile all workflows” path populates
MarkdownFilesso stats/heatmap works consistently.
Show a summary per file
| File | Description |
|---|---|
| pkg/cli/compile_stats.go | Extracts cron strings from on.schedule[*].cron into WorkflowStats.Schedules. |
| pkg/cli/compile_schedule_calendar.go | Implements cron field parsing, grid aggregation, and rendering of the schedule heatmap to stderr. |
| pkg/cli/compile_schedule_calendar_test.go | Adds unit/integration-style tests for cron parsing and calendar rendering. |
| pkg/cli/compile_pipeline.go | Wires the heatmap display into --stats output and ensures stats have access to file lists in the directory compile path. |
| docs/src/content/docs/agent-factory-status.mdx | Updates a documented workflow schedule time. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 5/5 changed files
- Comments generated: 2
| // normalised to 0). | ||
| func parseCronSchedule(cron string) (hours []int, daysOfWeek []int, err error) { | ||
| fields := strings.Fields(cron) | ||
| if len(fields) != 5 { | ||
| return nil, nil, fmt.Errorf("cron expression must have 5 fields, got %d: %q", len(fields), cron) | ||
| } | ||
|
|
||
| // Field index 1 is the hour field (0-23). | ||
| hours, err = parseCronField(fields[1], 0, 23) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("invalid hour field %q in cron %q: %w", fields[1], cron, err) | ||
| } | ||
|
|
There was a problem hiding this comment.
parseCronSchedule currently only considers the hour (field 2) and day-of-week (field 5). This makes the heatmap inaccurate for real cron expressions in this repo that use non-wildcard minutes (e.g. "*/30 * * * *" should contribute 2 triggers/hour, not 1) and/or a non-wildcard day-of-month/month (e.g. "15 9 1 * " is monthly, but will be counted as daily). Consider parsing the minute field and adding its multiplicity into the per-hour bucket, and skipping (or explicitly warning + skipping) schedules where day-of-month or month aren’t "" since they can’t be represented correctly in a 7×24 grid.
| // normalised to 0). | |
| func parseCronSchedule(cron string) (hours []int, daysOfWeek []int, err error) { | |
| fields := strings.Fields(cron) | |
| if len(fields) != 5 { | |
| return nil, nil, fmt.Errorf("cron expression must have 5 fields, got %d: %q", len(fields), cron) | |
| } | |
| // Field index 1 is the hour field (0-23). | |
| hours, err = parseCronField(fields[1], 0, 23) | |
| if err != nil { | |
| return nil, nil, fmt.Errorf("invalid hour field %q in cron %q: %w", fields[1], cron, err) | |
| } | |
| // normalised to 0). Minute multiplicity is preserved by repeating each hour | |
| // once per matching minute. Schedules with non-wildcard day-of-month or month | |
| // fields are rejected because they cannot be represented accurately in a 7×24 | |
| // weekly heatmap. | |
| func parseCronSchedule(cron string) (hours []int, daysOfWeek []int, err error) { | |
| fields := strings.Fields(cron) | |
| if len(fields) != 5 { | |
| return nil, nil, fmt.Errorf("cron expression must have 5 fields, got %d: %q", len(fields), cron) | |
| } | |
| // The heatmap represents a weekly 7×24 grid, so only schedules that recur | |
| // independent of day-of-month and month can be shown correctly. | |
| if fields[2] != "*" { | |
| return nil, nil, fmt.Errorf("day-of-month field %q in cron %q is not supported in weekly schedule heatmap", fields[2], cron) | |
| } | |
| if fields[3] != "*" { | |
| return nil, nil, fmt.Errorf("month field %q in cron %q is not supported in weekly schedule heatmap", fields[3], cron) | |
| } | |
| // Field index 0 is the minute field (0-59). Its cardinality determines how | |
| // many times each matching hour should contribute to the heatmap. | |
| minutes, err := parseCronField(fields[0], 0, 59) | |
| if err != nil { | |
| return nil, nil, fmt.Errorf("invalid minute field %q in cron %q: %w", fields[0], cron, err) | |
| } | |
| // Field index 1 is the hour field (0-23). | |
| parsedHours, err := parseCronField(fields[1], 0, 23) | |
| if err != nil { | |
| return nil, nil, fmt.Errorf("invalid hour field %q in cron %q: %w", fields[1], cron, err) | |
| } | |
| // Preserve minute multiplicity without changing the function signature by | |
| // repeating each hour once per matching minute. | |
| hours = make([]int, 0, len(parsedHours)*len(minutes)) | |
| for _, h := range parsedHours { | |
| for range minutes { | |
| hours = append(hours, h) | |
| } | |
| } |
| func TestParseCronSchedule_Weekdays(t *testing.T) { | ||
| hours, days, err := parseCronSchedule("33 14 * * 1-5") | ||
| require.NoError(t, err, "weekday cron should parse without error") | ||
| assert.Equal(t, []int{14}, hours, "hour should be 14") | ||
| assert.Equal(t, []int{1, 2, 3, 4, 5}, days, "day-of-week 1-5 = Mon through Fri") |
There was a problem hiding this comment.
parseCronSchedule tests here only exercise hour + day-of-week parsing. Since the heatmap shows trigger counts, please add cases for minute multiplicity (e.g. "*/30 * * * *" should add 2 triggers/hour) and for non-wildcard day-of-month/month (e.g. "15 9 1 * *"), so behavior stays correct once those fields are handled/skipped.
🧪 Test Quality Sentinel ReportTest Quality Score: 78/100
Test Classification DetailsView all 26 test classifications
Flagged Tests — Requires Review
|
--statscurrently shows a file-size table but gives no visibility into when workflows run. This adds a 7×24 UTC heatmap (days × hours) so users can immediately spot scheduling hotspots.Changes
compile_schedule_calendar.go— new file with:*, ranges, steps, and comma listsbuildScheduleGrid— aggregateson.schedule[*].cronfrom all workflows into a[7][24]inttrigger-count griddisplayScheduleCalendar— renders the heatmap to stderr using block characters (·░▒▓█), colour-coded in TTY mode; no-ops when no scheduled workflows existWorkflowStats— addsSchedules []string, populated by extractingon.schedule[*].cronfrom the parsed lock YAMLoutputResults— callsdisplayScheduleCalendarafterdisplayStatsTable; skipped in JSON output modecompileAllFilesInDirectory— when--statsis set and no explicit files were given, forwards the discovered file list intoconfig.MarkdownFilesbeforeoutputResults, so the heatmap works in the "compile everything" path (previously stats were silently empty in this case)Example output