diff --git a/docs/src/content/docs/reference/schedule-syntax.md b/docs/src/content/docs/reference/schedule-syntax.md index 61caa85f3a..5a273bfb56 100644 --- a/docs/src/content/docs/reference/schedule-syntax.md +++ b/docs/src/content/docs/reference/schedule-syntax.md @@ -30,6 +30,8 @@ GitHub Agentic Workflows supports human-friendly schedule expressions that are a | **Weekly** | `weekly` | Scattered day/time | Fuzzy | | | `weekly on monday` | Monday, scattered time | Fuzzy | | | `weekly on friday around 5pm` | Friday 4pm-6pm | Fuzzy | +| **Bi-weekly** | `bi-weekly` | Scattered across 2 weeks | Fuzzy | +| **Tri-weekly** | `tri-weekly` | Scattered across 3 weeks | Fuzzy | | **Intervals** | `every 10 minutes` | Every 10 minutes | Fixed | | | `every 2 days` | Every 2 days | Fixed | | **Cron** | `0 9 * * 1` | Standard cron | Fixed | @@ -198,6 +200,48 @@ on: Scatters Friday 4pm-6pm +### Bi-weekly Schedules + +Run once every two weeks at scattered day and time: + +```yaml +on: + schedule: bi-weekly +``` + +**Output**: Scattered across 2 weeks like `43 5 */14 * *` (every 14 days at scattered time) + +**Use cases**: +- Bi-weekly reports +- Fortnightly maintenance +- Regular check-ins on a two-week cadence + +**How it works**: +- Uses fuzzy scattering across 14 days (2 weeks) +- Each workflow gets a deterministic time that repeats every 14 days +- Time is scattered across the full 2-week period to distribute load + +### Tri-weekly Schedules + +Run once every three weeks at scattered day and time: + +```yaml +on: + schedule: tri-weekly +``` + +**Output**: Scattered across 3 weeks like `18 14 */21 * *` (every 21 days at scattered time) + +**Use cases**: +- Three-week cycle reports +- Periodic maintenance on 21-day intervals +- Regular reviews on a three-week cadence + +**How it works**: +- Uses fuzzy scattering across 21 days (3 weeks) +- Each workflow gets a deterministic time that repeats every 21 days +- Time is scattered across the full 3-week period to distribute load + ## UTC Offset Support All time specifications support UTC offset notation to convert times to UTC: @@ -503,6 +547,8 @@ This shorthand adds `workflow_dispatch` for manual triggering alongside the sche on: daily on: hourly on: weekly on monday +on: bi-weekly +on: tri-weekly on: every 2h ``` diff --git a/pkg/parser/schedule_parser.go b/pkg/parser/schedule_parser.go index 31d0cc6308..5991e90c11 100644 --- a/pkg/parser/schedule_parser.go +++ b/pkg/parser/schedule_parser.go @@ -434,6 +434,38 @@ func ScatterSchedule(fuzzyCron, workflowIdentifier string) (string, error) { return fmt.Sprintf("%d %d * * %d", minute, hour, weekday), nil } + // For FUZZY:BI_WEEKLY * * *, we scatter across 2 weeks (14 days) + if strings.HasPrefix(fuzzyCron, "FUZZY:BI_WEEKLY") { + // Use a stable hash of the workflow identifier to get a deterministic day and time + // Total possibilities: 14 days * 1440 minutes = 20160 minutes in 2 weeks + hash := stableHash(workflowIdentifier, 20160) + + // Extract time within a day (scatter across 2 weeks) + minutesInDay := hash % 1440 // Which minute of that day (0-1439) + hour := minutesInDay / 60 + minute := minutesInDay % 60 + + // Convert to cron: We use day-of-month pattern with 14-day interval + // Schedule every 14 days at the scattered time + return fmt.Sprintf("%d %d */%d * *", minute, hour, 14), nil + } + + // For FUZZY:TRI_WEEKLY * * *, we scatter across 3 weeks (21 days) + if strings.HasPrefix(fuzzyCron, "FUZZY:TRI_WEEKLY") { + // Use a stable hash of the workflow identifier to get a deterministic day and time + // Total possibilities: 21 days * 1440 minutes = 30240 minutes in 3 weeks + hash := stableHash(workflowIdentifier, 30240) + + // Extract time within a day (scatter across 3 weeks) + minutesInDay := hash % 1440 // Which minute of that day (0-1439) + hour := minutesInDay / 60 + minute := minutesInDay % 60 + + // Convert to cron: We use day-of-month pattern with 21-day interval + // Schedule every 21 days at the scattered time + return fmt.Sprintf("%d %d */%d * *", minute, hour, 21), nil + } + return "", fmt.Errorf("unsupported fuzzy schedule type: %s", fuzzyCron) } @@ -779,6 +811,22 @@ func (p *ScheduleParser) parseBase() (string, error) { return fmt.Sprintf("FUZZY:WEEKLY:%s * * *", weekday), nil } + case "bi-weekly": + // bi-weekly -> FUZZY:BI_WEEKLY (fuzzy schedule, scattered across 2 weeks) + if len(p.tokens) == 1 { + // Just "bi-weekly" with no additional parameters - scatter across 2 weeks + return "FUZZY:BI_WEEKLY * * *", nil + } + return "", fmt.Errorf("bi-weekly schedule does not support additional parameters, use 'bi-weekly' alone for fuzzy schedule") + + case "tri-weekly": + // tri-weekly -> FUZZY:TRI_WEEKLY (fuzzy schedule, scattered across 3 weeks) + if len(p.tokens) == 1 { + // Just "tri-weekly" with no additional parameters - scatter across 3 weeks + return "FUZZY:TRI_WEEKLY * * *", nil + } + return "", fmt.Errorf("tri-weekly schedule does not support additional parameters, use 'tri-weekly' alone for fuzzy schedule") + case "monthly": // monthly on -> rejected (use cron directly) // monthly on at HH:MM -> rejected (use cron directly) @@ -801,7 +849,7 @@ func (p *ScheduleParser) parseBase() (string, error) { return "", fmt.Errorf("'monthly on ' syntax is not supported. Use standard cron syntax for monthly schedules (e.g., '0 0 %s * *' for the %sth at midnight)", day, day) default: - return "", fmt.Errorf("unsupported schedule type '%s', use 'daily', 'weekly', or 'monthly'", baseType) + return "", fmt.Errorf("unsupported schedule type '%s', use 'daily', 'weekly', 'bi-weekly', 'tri-weekly', or 'monthly'", baseType) } // Build cron expression: MIN HOUR DOM MONTH DOW diff --git a/pkg/parser/schedule_parser_test.go b/pkg/parser/schedule_parser_test.go index 28a0bb81e1..c0e340c585 100644 --- a/pkg/parser/schedule_parser_test.go +++ b/pkg/parser/schedule_parser_test.go @@ -599,6 +599,34 @@ func TestParseSchedule(t *testing.T) { errorSubstring: "'monthly on ' syntax is not supported", }, + // Bi-weekly schedules (fuzzy) + { + name: "bi-weekly fuzzy", + input: "bi-weekly", + expectedCron: "FUZZY:BI_WEEKLY * * *", + expectedOrig: "bi-weekly", + }, + { + name: "bi-weekly with parameters", + input: "bi-weekly on monday", + shouldError: true, + errorSubstring: "bi-weekly schedule does not support additional parameters", + }, + + // Tri-weekly schedules (fuzzy) + { + name: "tri-weekly fuzzy", + input: "tri-weekly", + expectedCron: "FUZZY:TRI_WEEKLY * * *", + expectedOrig: "tri-weekly", + }, + { + name: "tri-weekly with parameters", + input: "tri-weekly on friday", + shouldError: true, + errorSubstring: "tri-weekly schedule does not support additional parameters", + }, + // Interval schedules { name: "every 10 minutes", @@ -1845,3 +1873,159 @@ func TestScatterScheduleWeeklyAroundDeterministic(t *testing.T) { t.Errorf("ScatterSchedule produced identical results for all workflows: %s", results[0]) } } + +func TestScatterScheduleBiWeekly(t *testing.T) { + tests := []struct { + name string + fuzzyCron string + workflowIdentifier string + expectError bool + errorMsg string + }{ + { + name: "bi-weekly fuzzy", + fuzzyCron: "FUZZY:BI_WEEKLY * * *", + workflowIdentifier: "test-workflow", + expectError: false, + }, + { + name: "bi-weekly with different workflow", + fuzzyCron: "FUZZY:BI_WEEKLY * * *", + workflowIdentifier: "another-workflow", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ScatterSchedule(tt.fuzzyCron, tt.workflowIdentifier) + + if tt.expectError { + if err == nil { + t.Errorf("expected error containing '%s', got nil", tt.errorMsg) + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error containing '%s', got: %s", tt.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + // Verify it's a valid cron expression + fields := strings.Fields(result) + if len(fields) != 5 { + t.Errorf("expected 5 fields in cron, got %d: %s", len(fields), result) + } + + // Verify it uses 14-day interval pattern (bi-weekly = every 14 days) + if fields[2] != "*/14" { + t.Errorf("expected day-of-month pattern '*/14' for bi-weekly, got: %s", fields[2]) + } + }) + } +} + +func TestScatterScheduleBiWeeklyDeterministic(t *testing.T) { + // Test that scattering is deterministic - same input produces same output + workflows := []string{"workflow-a", "workflow-b", "workflow-c", "workflow-a"} + + results := make([]string, len(workflows)) + for i, wf := range workflows { + result, err := ScatterSchedule("FUZZY:BI_WEEKLY * * *", wf) + if err != nil { + t.Fatalf("ScatterSchedule failed for workflow %s: %s", wf, err) + } + results[i] = result + } + + // First and last results should be identical (same workflow) + if results[0] != results[3] { + t.Errorf("ScatterSchedule not deterministic: workflow-a produced %s and %s", results[0], results[3]) + } + + // Different workflows should produce different results (with high probability) + if results[0] == results[1] && results[1] == results[2] { + t.Logf("Warning: All different workflows got the same schedule (unlikely but possible): %s", results[0]) + } +} + +func TestScatterScheduleTriWeekly(t *testing.T) { + tests := []struct { + name string + fuzzyCron string + workflowIdentifier string + expectError bool + errorMsg string + }{ + { + name: "tri-weekly fuzzy", + fuzzyCron: "FUZZY:TRI_WEEKLY * * *", + workflowIdentifier: "test-workflow", + expectError: false, + }, + { + name: "tri-weekly with different workflow", + fuzzyCron: "FUZZY:TRI_WEEKLY * * *", + workflowIdentifier: "another-workflow", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ScatterSchedule(tt.fuzzyCron, tt.workflowIdentifier) + + if tt.expectError { + if err == nil { + t.Errorf("expected error containing '%s', got nil", tt.errorMsg) + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error containing '%s', got: %s", tt.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + // Verify it's a valid cron expression + fields := strings.Fields(result) + if len(fields) != 5 { + t.Errorf("expected 5 fields in cron, got %d: %s", len(fields), result) + } + + // Verify it uses 21-day interval pattern (tri-weekly = every 21 days) + if fields[2] != "*/21" { + t.Errorf("expected day-of-month pattern '*/21' for tri-weekly, got: %s", fields[2]) + } + }) + } +} + +func TestScatterScheduleTriWeeklyDeterministic(t *testing.T) { + // Test that scattering is deterministic - same input produces same output + workflows := []string{"workflow-a", "workflow-b", "workflow-c", "workflow-a"} + + results := make([]string, len(workflows)) + for i, wf := range workflows { + result, err := ScatterSchedule("FUZZY:TRI_WEEKLY * * *", wf) + if err != nil { + t.Fatalf("ScatterSchedule failed for workflow %s: %s", wf, err) + } + results[i] = result + } + + // First and last results should be identical (same workflow) + if results[0] != results[3] { + t.Errorf("ScatterSchedule not deterministic: workflow-a produced %s and %s", results[0], results[3]) + } + + // Different workflows should produce different results (with high probability) + if results[0] == results[1] && results[1] == results[2] { + t.Logf("Warning: All different workflows got the same schedule (unlikely but possible): %s", results[0]) + } +}