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
46 changes: 46 additions & 0 deletions docs/src/content/docs/reference/schedule-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
```

Expand Down
50 changes: 49 additions & 1 deletion pkg/parser/schedule_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 <day> -> rejected (use cron directly)
// monthly on <day> at HH:MM -> rejected (use cron directly)
Expand All @@ -801,7 +849,7 @@ func (p *ScheduleParser) parseBase() (string, error) {
return "", fmt.Errorf("'monthly on <day>' 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
Expand Down
184 changes: 184 additions & 0 deletions pkg/parser/schedule_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,34 @@ func TestParseSchedule(t *testing.T) {
errorSubstring: "'monthly on <day>' 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",
Expand Down Expand Up @@ -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])
}
}