diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs index 67773b9186..48f4885322 100644 --- a/actions/setup/js/check_rate_limit.cjs +++ b/actions/setup/js/check_rate_limit.cjs @@ -35,11 +35,42 @@ async function main() { const maxRuns = parseInt(process.env.GH_AW_RATE_LIMIT_MAX || "5", 10); const windowMinutes = parseInt(process.env.GH_AW_RATE_LIMIT_WINDOW || "60", 10); const eventsList = process.env.GH_AW_RATE_LIMIT_EVENTS || ""; + // Default: admin, maintain, and write roles are exempt from rate limiting + const ignoredRolesList = process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES || "admin,maintain,write"; core.info(`🔍 Checking rate limit for user '${actor}' on workflow '${workflowId}'`); core.info(` Configuration: max=${maxRuns} runs per ${windowMinutes} minutes`); core.info(` Current event: ${eventName}`); + // Check if user has an ignored role (exempt from rate limiting) + const ignoredRoles = ignoredRolesList.split(",").map(r => r.trim()); + core.info(` Ignored roles: ${ignoredRoles.join(", ")}`); + + try { + // Check user's permission level in the repository + const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: actor, + }); + + const userPermission = permissionData.permission; + core.info(` User '${actor}' has permission level: ${userPermission}`); + + // Map GitHub permission levels to role names + // GitHub uses: admin, maintain, write, triage, read + if (ignoredRoles.includes(userPermission)) { + core.info(`✅ User '${actor}' has ignored role '${userPermission}'; skipping rate limit check`); + core.setOutput("rate_limit_ok", "true"); + return; + } + } catch (error) { + // If we can't check permissions, continue with rate limiting (fail-secure) + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`⚠️ Could not check user permissions: ${errorMsg}`); + core.warning(` Continuing with rate limit check for user '${actor}'`); + } + // Parse events to apply rate limiting to const limitedEvents = eventsList ? eventsList.split(",").map(e => e.trim()) : []; diff --git a/actions/setup/js/check_rate_limit.test.cjs b/actions/setup/js/check_rate_limit.test.cjs index 765b763ba7..87d1584656 100644 --- a/actions/setup/js/check_rate_limit.test.cjs +++ b/actions/setup/js/check_rate_limit.test.cjs @@ -48,6 +48,10 @@ describe("check_rate_limit", () => { delete process.env.GH_AW_RATE_LIMIT_MAX; delete process.env.GH_AW_RATE_LIMIT_WINDOW; delete process.env.GH_AW_RATE_LIMIT_EVENTS; + delete process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES; + + // Reset repos mock + mockGithub.rest.repos = undefined; // Reload the module to get fresh instance vi.resetModules(); @@ -558,4 +562,161 @@ describe("check_rate_limit", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using workflow name: test-workflow (fallback")); }); + + it("should use default ignored roles (admin, maintain, write) when not specified", async () => { + // Don't set GH_AW_RATE_LIMIT_IGNORED_ROLES, so it uses default + + // Mock the permission check to return write + mockGithub.rest.repos = { + getCollaboratorPermissionLevel: vi.fn().mockResolvedValue({ + data: { + permission: "write", + }, + }), + }; + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Ignored roles: admin, maintain, write")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has permission level: write")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has ignored role 'write'; skipping rate limit check")); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockGithub.rest.actions.listWorkflowRuns).not.toHaveBeenCalled(); + }); + + it("should apply rate limiting to triage users by default", async () => { + // Don't set GH_AW_RATE_LIMIT_IGNORED_ROLES, so it uses default (admin, maintain, write) + + mockGithub.rest.repos = { + getCollaboratorPermissionLevel: vi.fn().mockResolvedValue({ + data: { + permission: "triage", + }, + }), + }; + + mockGithub.rest.actions = { + listWorkflowRuns: vi.fn().mockResolvedValue({ + data: { + workflow_runs: [], + }, + }), + cancelWorkflowRun: vi.fn(), + }; + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Ignored roles: admin, maintain, write")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has permission level: triage")); + expect(mockGithub.rest.actions.listWorkflowRuns).toHaveBeenCalled(); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + }); + + it("should skip rate limiting for users with ignored roles", async () => { + process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES = "admin,maintain"; + + // Mock the permission check to return admin + mockGithub.rest.repos = { + getCollaboratorPermissionLevel: vi.fn().mockResolvedValue({ + data: { + permission: "admin", + }, + }), + }; + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Ignored roles: admin, maintain")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has permission level: admin")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has ignored role 'admin'; skipping rate limit check")); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockGithub.rest.actions.listWorkflowRuns).not.toHaveBeenCalled(); + }); + + it("should skip rate limiting for users with maintain permission when in ignored roles", async () => { + process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES = "admin,maintain"; + + mockGithub.rest.repos = { + getCollaboratorPermissionLevel: vi.fn().mockResolvedValue({ + data: { + permission: "maintain", + }, + }), + }; + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has ignored role 'maintain'; skipping rate limit check")); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockGithub.rest.actions.listWorkflowRuns).not.toHaveBeenCalled(); + }); + + it("should apply rate limiting for users without ignored roles", async () => { + process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES = "admin,maintain"; + + mockGithub.rest.repos = { + getCollaboratorPermissionLevel: vi.fn().mockResolvedValue({ + data: { + permission: "write", + }, + }), + }; + + mockGithub.rest.actions = { + listWorkflowRuns: vi.fn().mockResolvedValue({ + data: { + workflow_runs: [], + }, + }), + cancelWorkflowRun: vi.fn(), + }; + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has permission level: write")); + expect(mockGithub.rest.actions.listWorkflowRuns).toHaveBeenCalled(); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + }); + + it("should continue with rate limiting if permission check fails", async () => { + process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES = "admin"; + + mockGithub.rest.repos = { + getCollaboratorPermissionLevel: vi.fn().mockRejectedValue(new Error("API error")), + }; + + mockGithub.rest.actions = { + listWorkflowRuns: vi.fn().mockResolvedValue({ + data: { + workflow_runs: [], + }, + }), + cancelWorkflowRun: vi.fn(), + }; + + await checkRateLimit.main(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not check user permissions")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Continuing with rate limit check")); + expect(mockGithub.rest.actions.listWorkflowRuns).toHaveBeenCalled(); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + }); + + it("should handle single ignored role as string", async () => { + process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES = "admin"; + + mockGithub.rest.repos = { + getCollaboratorPermissionLevel: vi.fn().mockResolvedValue({ + data: { + permission: "admin", + }, + }), + }; + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Ignored roles: admin")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("User 'test-user' has ignored role 'admin'; skipping rate limit check")); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + }); }); diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md deleted file mode 100644 index f386312084..0000000000 --- a/docs/RATE_LIMITING.md +++ /dev/null @@ -1,312 +0,0 @@ -# Rate Limiting for Agentic Workflows - -## Overview - -The rate limiting feature prevents users from triggering workflows too frequently, helping to: -- Prevent abuse and resource exhaustion -- Control costs from programmatic workflow triggers -- Protect against accidental infinite loops -- Ensure fair resource allocation across users - -## Configuration - -Rate limiting is configured in the workflow frontmatter using the `rate-limit` field: - -```yaml ---- -name: My Workflow -engine: copilot -on: - workflow_dispatch: - issue_comment: - types: [created] -rate-limit: - max: 5 # Required: 1-10 runs - window: 60 # Optional: minutes (default 60, max 180) - # events field is optional - automatically inferred from 'on:' triggers ---- -``` - -## Parameters - -### `max` (integer, **required**) -- Maximum number of workflow runs allowed per user within the time window -- Range: 1-10 -- Example: `max: 5` allows 5 runs per window - -### `window` (integer, optional) -- Time window in minutes for rate limiting -- Default: 60 (1 hour) -- Range: 1-180 (up to 3 hours) -- Example: `window: 30` creates a 30-minute window - -### `events` (array, optional) -- Specific event types to apply rate limiting to -- **If not specified, automatically inferred from the workflow's `on:` triggers** -- Only programmatic trigger types are included in the inference -- Can be explicitly set to override the inference -- Supported events: - - `workflow_dispatch` - - `issue_comment` - - `pull_request_review` - - `pull_request_review_comment` - - `issues` - - `pull_request` - - `discussion_comment` - - `discussion` - -## How It Works - -1. **Pre-Activation Check**: Rate limiting is enforced in the pre-activation job, before the main workflow runs -2. **Per-User Per-Workflow**: Limits are applied individually for each user and workflow -3. **Recent Runs Query**: The system queries recent workflow runs from the GitHub API -4. **Filtering**: Runs are filtered by: - - Actor (user who triggered the workflow) - - Time window (only runs within the configured window) - - Event type (if `events` is configured) - - Excludes the current run from the count - - Excludes cancelled runs (cancelled runs don't count toward the limit) - - Excludes runs that completed in less than 15 seconds (treated as failed fast/cancelled) -5. **Progressive Aggregation**: Uses pagination with short-circuit logic for efficiency -6. **Automatic Cancellation**: If the limit is exceeded, the current run is automatically cancelled - -## Examples - -### Automatic Event Inference (Recommended) -```yaml -on: - issues: - types: [opened] - issue_comment: - types: [created] -rate-limit: - max: 5 - window: 60 - # Events automatically inferred: [issues, issue_comment] -``` -Events are automatically inferred from the workflow's triggers. Simplest configuration. - -### Basic Rate Limiting (Default Window) -```yaml -rate-limit: - max: 5 - window: 60 -``` -Allows 5 runs per hour. Events inferred from `on:` section. - -### Explicit Event Filtering -```yaml -rate-limit: - max: 3 - window: 30 - events: [workflow_dispatch, issue_comment] -``` -Explicitly specify events to override inference. Allows only 3 runs per 30 minutes for the specified events. - -### Generous Rate Limiting -```yaml -rate-limit: - max: 10 - window: 120 -``` -Allows 10 runs per 2 hours. Events inferred from triggers. - -## Behavior Details - -### When Rate Limit is Exceeded -- The workflow run is automatically cancelled -- A warning message is logged with details: - - Current run count - - Maximum allowed - - Time window -- The activation output is set to false, preventing the main job from running - -### Logging -The rate limit check provides extensive logging: -``` -🔍 Checking rate limit for user 'username' on workflow 'workflow-name' - Configuration: max=5 runs per 60 minutes - Current event: workflow_dispatch - Time window: runs created after 2026-02-11T11:24:33.098Z -📊 Querying workflow runs for 'workflow-name'... - Fetching page 1 (up to 100 runs per page)... - Retrieved 10 runs from page 1 - Skipping run 123457 - cancelled (status: cancelled) - ✓ Run #5 (123456) by username - event: workflow_dispatch, created: 2026-02-11T11:15:00.000Z, status: completed -📈 Rate limit summary for user 'username': - Total recent runs in last 60 minutes: 3 - Maximum allowed: 5 - Breakdown by event type: - - workflow_dispatch: 2 runs - - issue_comment: 1 runs -✅ Rate limit check passed - User 'username' has 3 runs in the last 60 minutes - Remaining quota: 2 runs -``` - -### Error Handling -- **Fail-Open**: If the rate limit check fails due to API errors, the workflow is allowed to proceed -- This ensures that temporary API issues don't block legitimate workflow runs -- Errors are logged with details for troubleshooting - -### Performance Optimization -- **Short-Circuit Logic**: Stops querying additional pages once the limit is reached -- **Progressive Filtering**: Filters by actor and time window progressively -- **Pagination**: Efficiently handles workflows with many runs - -## Integration with Pre-Activation Job - -The rate limit check is automatically added to the pre-activation job when configured: - -```yaml -jobs: - pre-activation: - runs-on: ubuntu-latest - outputs: - activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} - steps: - - name: Check team membership - # ... membership check ... - - - name: Check user rate limit - id: check_rate_limit - uses: actions/github-script@v8 - env: - GH_AW_RATE_LIMIT_MAX: "5" - GH_AW_RATE_LIMIT_WINDOW: "60" - GH_AW_RATE_LIMIT_EVENTS: workflow_dispatch,issue_comment - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { main } = require('/opt/gh-aw/actions/check_rate_limit.cjs'); - await main(); -``` - -The activation output combines all pre-activation checks using AND logic, so the workflow only proceeds if all checks pass. - -## Use Cases - -### Preventing Abuse -```yaml -rate-limit: - max: 3 - window: 60 - events: [workflow_dispatch] -``` -Limits manual workflow triggers to prevent spam or abuse. - -### Cost Control -```yaml -rate-limit: - max: 10 - window: 120 -``` -Controls costs by limiting how often expensive workflows can be triggered. - -### Fair Resource Allocation -```yaml -rate-limit: - max: 5 - window: 30 -``` -Ensures fair access to shared resources across multiple users. - -### Development vs Production -Development workflows might have stricter limits: -```yaml -# Development -rate-limit: - max: 3 - window: 30 - -# Production -rate-limit: - max: 20 - window: 60 -``` - -## Testing - -A test workflow is provided at `.github/workflows/test-rate-limit.md`: - -```yaml ---- -name: Test Rate Limiting -engine: copilot -on: - workflow_dispatch: - issue_comment: - types: [created] -rate-limit: - max: 3 - window: 30 - events: [workflow_dispatch, issue_comment] ---- - -Test workflow to demonstrate rate limiting functionality. -This workflow limits each user to 3 runs within a 30-minute window. -``` - -To test: -1. Trigger the workflow manually 4 times in quick succession -2. The 4th run should be automatically cancelled with a rate limit warning -3. Wait 30 minutes for the window to reset -4. Trigger again to confirm the limit resets - -## Troubleshooting - -### Rate Limit Not Working -- Check that `rate-limit` is in the workflow frontmatter -- Verify the schema is valid (run `gh aw compile`) -- Check pre-activation job logs for rate limit check output - -### Unexpected Cancellations -- Review the rate limit configuration (`max` and `window`) -- Check if other users are triggering the same workflow -- Verify event filters are configured correctly - -### API Errors -- Rate limit checks fail-open on API errors -- Check GitHub API status if issues persist -- Review workflow run logs for detailed error messages - -## Schema Definition - -The rate-limit field is validated against this JSON schema: - -```json -{ - "type": "object", - "required": ["max"], - "properties": { - "max": { - "type": "integer", - "minimum": 1, - "maximum": 10 - }, - "window": { - "type": "integer", - "minimum": 1, - "maximum": 180, - "default": 60 - }, - "events": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "workflow_dispatch", - "issue_comment", - "pull_request_review", - "pull_request_review_comment", - "issues", - "pull_request", - "discussion_comment", - "discussion" - ] - }, - "minItems": 1 - } - } -} -``` diff --git a/docs/src/content/docs/agent-factory-status.mdx b/docs/src/content/docs/agent-factory-status.mdx index 398f7ce702..96d79fc6b3 100644 --- a/docs/src/content/docs/agent-factory-status.mdx +++ b/docs/src/content/docs/agent-factory-status.mdx @@ -144,6 +144,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn, | [Test Create PR Error Handling](https://github.com/github/gh-aw/blob/main/.github/workflows/test-create-pr-error-handling.md) | claude | [![Test Create PR Error Handling](https://github.com/github/gh-aw/actions/workflows/test-create-pr-error-handling.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-create-pr-error-handling.lock.yml) | - | - | | [Test Dispatcher Workflow](https://github.com/github/gh-aw/blob/main/.github/workflows/test-dispatcher.md) | copilot | [![Test Dispatcher Workflow](https://github.com/github/gh-aw/actions/workflows/test-dispatcher.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-dispatcher.lock.yml) | - | - | | [Test Project URL Explicit Requirement](https://github.com/github/gh-aw/blob/main/.github/workflows/test-project-url-default.md) | copilot | [![Test Project URL Explicit Requirement](https://github.com/github/gh-aw/actions/workflows/test-project-url-default.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-project-url-default.lock.yml) | - | - | +| [Test Rate Limiting with Ignored Roles](https://github.com/github/gh-aw/blob/main/.github/workflows/test-rate-limit-ignored-roles.md) | copilot | [![Test Rate Limiting with Ignored Roles](https://github.com/github/gh-aw/actions/workflows/test-rate-limit-ignored-roles.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-rate-limit-ignored-roles.lock.yml) | - | - | | [Test Workflow](https://github.com/github/gh-aw/blob/main/.github/workflows/test-workflow.md) | copilot | [![Test Workflow](https://github.com/github/gh-aw/actions/workflows/test-workflow.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-workflow.lock.yml) | - | - | | [The Daily Repository Chronicle](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-repo-chronicle.md) | copilot | [![The Daily Repository Chronicle](https://github.com/github/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml) | `0 16 * * 1-5` | - | | [The Great Escapi](https://github.com/github/gh-aw/blob/main/.github/workflows/firewall-escape.md) | copilot | [![The Great Escapi](https://github.com/github/gh-aw/actions/workflows/firewall-escape.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/firewall-escape.lock.yml) | - | - | diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 9d0d3c5851..896e0ec95c 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -3528,6 +3528,13 @@ rate-limit: events: [] # Array of strings + # Optional list of roles that are exempt from rate limiting. Users with any of + # these roles will not be subject to rate limiting checks. Common roles: 'admin', + # 'maintain', 'write', 'triage', 'read'. + # (optional) + ignored-roles: [] + # Array of strings + # Enable strict mode validation for enhanced security and compliance. Strict mode # enforces: (1) Write Permissions - refuses contents:write, issues:write, # pull-requests:write; requires safe-outputs instead, (2) Network Configuration - diff --git a/pkg/cli/workflows/test-rate-limit-defaults.md b/pkg/cli/workflows/test-rate-limit-defaults.md new file mode 100644 index 0000000000..b2cbbf605b --- /dev/null +++ b/pkg/cli/workflows/test-rate-limit-defaults.md @@ -0,0 +1,18 @@ +--- +name: Test Rate Limiting with Default Ignored Roles +engine: copilot +on: + workflow_dispatch: + issue_comment: + types: [created] +rate-limit: + max: 5 + window: 60 +--- + +Test workflow to demonstrate default ignored roles behavior. + +By default, admin, maintain, and write users are exempt from rate limiting. +Only triage and read users will be subject to rate limiting. + +Hello! This is a test workflow. diff --git a/pkg/cli/workflows/test-rate-limit-ignored-roles.md b/pkg/cli/workflows/test-rate-limit-ignored-roles.md new file mode 100644 index 0000000000..8c417b8ea8 --- /dev/null +++ b/pkg/cli/workflows/test-rate-limit-ignored-roles.md @@ -0,0 +1,55 @@ +--- +name: Test Rate Limiting with Ignored Roles +engine: copilot +on: + workflow_dispatch: + issue_comment: + types: [created] +rate-limit: + max: 3 + window: 30 + ignored-roles: + - admin + - maintain + events: [workflow_dispatch, issue_comment] +--- + +Test workflow to demonstrate rate limiting with ignored roles. + +This workflow: +- Limits non-admin/non-maintainer users to 3 runs within a 30-minute window +- Exempts users with "admin" or "maintain" roles from rate limiting +- Applies to workflow_dispatch and issue_comment events + +## Testing + +### For Admin/Maintainer Users: +1. Trigger the workflow multiple times in quick succession (>3 times) +2. All runs should succeed without rate limiting + +### For Other Users (write, triage, read): +1. Trigger the workflow 4 times in quick succession +2. The 4th run should be automatically cancelled with a rate limit warning +3. Wait 30 minutes for the window to reset +4. Trigger again to confirm the limit resets + +## Expected Behavior + +**Admin/Maintain users:** +``` +🔍 Checking rate limit for user 'admin-user' on workflow 'test-rate-limit-ignored-roles.lock.yml' + Configuration: max=3 runs per 30 minutes + Current event: workflow_dispatch + Ignored roles: admin, maintain + User 'admin-user' has permission level: admin +✅ User 'admin-user' has ignored role 'admin'; skipping rate limit check +``` + +**Other users (after 3 runs):** +``` +⚠️ Rate limit exceeded for user 'contributor' on workflow 'test-rate-limit-ignored-roles.lock.yml' + User has triggered 3 runs in the last 30 minutes (max: 3) + Cancelling current workflow run... +``` + +Hello! I'm testing the rate limiting feature with role-based exemptions. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index cb39acf026..10ec36bd20 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6113,6 +6113,15 @@ "enum": ["workflow_dispatch", "issue_comment", "pull_request_review", "pull_request_review_comment", "issues", "pull_request", "discussion_comment", "discussion"] }, "minItems": 1 + }, + "ignored-roles": { + "type": "array", + "description": "Optional list of roles that are exempt from rate limiting. Defaults to ['admin', 'maintain', 'write'] if not specified. Users with any of these roles will not be subject to rate limiting checks. To apply rate limiting to all users, set to an empty array: []", + "items": { + "type": "string", + "enum": ["admin", "maintain", "write", "triage", "read"] + }, + "minItems": 0 } }, "additionalProperties": false, @@ -6125,6 +6134,11 @@ "max": 10, "window": 30, "events": ["workflow_dispatch", "issue_comment"] + }, + { + "max": 5, + "window": 60, + "ignored-roles": ["admin", "maintain"] } ] }, diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index f9bced5aa3..4b53e6caf0 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -79,9 +79,10 @@ type PluginsConfig struct { // RateLimitConfig represents rate limiting configuration for workflow triggers // Limits how many times a user can trigger a workflow within a time window type RateLimitConfig struct { - Max int `json:"max,omitempty"` // Maximum number of runs allowed per time window (default: 5) - Window int `json:"window,omitempty"` // Time window in minutes (default: 60) - Events []string `json:"events,omitempty"` // Event types to apply rate limiting to (e.g., ["workflow_dispatch", "issue_comment"]) + Max int `json:"max,omitempty"` // Maximum number of runs allowed per time window (default: 5) + Window int `json:"window,omitempty"` // Time window in minutes (default: 60) + Events []string `json:"events,omitempty"` // Event types to apply rate limiting to (e.g., ["workflow_dispatch", "issue_comment"]) + IgnoredRoles []string `json:"ignored-roles,omitempty"` // Roles that are exempt from rate limiting (e.g., ["admin", "maintainer"]) } // FrontmatterConfig represents the structured configuration from workflow frontmatter diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index b0de86a174..bcd9bf6398 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -70,6 +70,15 @@ func (c *Compiler) generateRateLimitCheck(data *WorkflowData, steps []string) [] steps = append(steps, fmt.Sprintf(" GH_AW_RATE_LIMIT_EVENTS: %q\n", strings.Join(events, ","))) } + // Set ignored roles (if specified) + if len(data.RateLimit.IgnoredRoles) > 0 { + // Sort roles alphabetically for consistent output + ignoredRoles := make([]string, len(data.RateLimit.IgnoredRoles)) + copy(ignoredRoles, data.RateLimit.IgnoredRoles) + sort.Strings(ignoredRoles) + steps = append(steps, fmt.Sprintf(" GH_AW_RATE_LIMIT_IGNORED_ROLES: %q\n", strings.Join(ignoredRoles, ","))) + } + steps = append(steps, " with:\n") steps = append(steps, " github-token: ${{ secrets.GITHUB_TOKEN }}\n") steps = append(steps, " script: |\n") @@ -199,7 +208,27 @@ func (c *Compiler) extractRateLimitConfig(frontmatter map[string]any) *RateLimit } } - roleLog.Printf("Extracted rate-limit config: max=%d, window=%d, events=%v", config.Max, config.Window, config.Events) + // Extract ignored-roles + if ignoredRolesValue, ok := v["ignored-roles"]; ok { + switch ignoredRoles := ignoredRolesValue.(type) { + case []any: + for _, item := range ignoredRoles { + if str, ok := item.(string); ok { + config.IgnoredRoles = append(config.IgnoredRoles, str) + } + } + case []string: + config.IgnoredRoles = ignoredRoles + case string: + config.IgnoredRoles = []string{ignoredRoles} + } + } else { + // Default: admin, maintain, and write roles are exempt from rate limiting + config.IgnoredRoles = []string{"admin", "maintain", "write"} + roleLog.Print("No ignored-roles specified, using defaults: admin, maintain, write") + } + + roleLog.Printf("Extracted rate-limit config: max=%d, window=%d, events=%v, ignored-roles=%v", config.Max, config.Window, config.Events, config.IgnoredRoles) return config } }