diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index 17f898be63e..9abcf1a949b 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -18,6 +18,8 @@ # create_issue["create_issue"] # detection["detection"] # missing_tool["missing_tool"] +# pre_activation["pre_activation"] +# pre_activation --> activation # activation --> agent # agent --> create_issue # detection --> create_issue @@ -48,6 +50,7 @@ name: "Daily File Diet" "on": schedule: - cron: "0 13 * * 1-5" + # skip-if-match: is:issue is:open in:title "[file-diet]" # Skip-if-match processed as search check in pre-activation job workflow_dispatch: null permissions: @@ -62,6 +65,8 @@ run-name: "Daily File Diet" jobs: activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' runs-on: ubuntu-slim permissions: contents: read @@ -3761,3 +3766,126 @@ jobs: core.setFailed(`Error processing missing-tool reports: ${error}`); }); + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_skip_if_match.outputs.skip_check_ok == 'true') }} + steps: + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + script: | + async function main() { + const { eventName } = context; + const actor = context.actor; + const { owner, repo } = context.repo; + const requiredPermissionsEnv = process.env.GH_AW_REQUIRED_ROLES; + const requiredPermissions = requiredPermissionsEnv ? requiredPermissionsEnv.split(",").filter(p => p.trim() !== "") : []; + if (eventName === "workflow_dispatch") { + const hasWriteRole = requiredPermissions.includes("write"); + if (hasWriteRole) { + core.info(`✅ Event ${eventName} does not require validation (write role allowed)`); + core.setOutput("is_team_member", "true"); + core.setOutput("result", "safe_event"); + return; + } + core.info(`Event ${eventName} requires validation (write role not allowed)`); + } + const safeEvents = ["schedule"]; + if (safeEvents.includes(eventName)) { + core.info(`✅ Event ${eventName} does not require validation`); + core.setOutput("is_team_member", "true"); + core.setOutput("result", "safe_event"); + return; + } + if (!requiredPermissions || requiredPermissions.length === 0) { + core.warning("❌ Configuration error: Required permissions not specified. Contact repository administrator."); + core.setOutput("is_team_member", "false"); + core.setOutput("result", "config_error"); + core.setOutput("error_message", "Configuration error: Required permissions not specified"); + return; + } + try { + core.info(`Checking if user '${actor}' has required permissions for ${owner}/${repo}`); + core.info(`Required permissions: ${requiredPermissions.join(", ")}`); + const repoPermission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: owner, + repo: repo, + username: actor, + }); + const permission = repoPermission.data.permission; + core.info(`Repository permission level: ${permission}`); + for (const requiredPerm of requiredPermissions) { + if (permission === requiredPerm || (requiredPerm === "maintainer" && permission === "maintain")) { + core.info(`✅ User has ${permission} access to repository`); + core.setOutput("is_team_member", "true"); + core.setOutput("result", "authorized"); + core.setOutput("user_permission", permission); + return; + } + } + core.warning(`User permission '${permission}' does not meet requirements: ${requiredPermissions.join(", ")}`); + core.setOutput("is_team_member", "false"); + core.setOutput("result", "insufficient_permissions"); + core.setOutput("user_permission", permission); + core.setOutput( + "error_message", + `Access denied: User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}` + ); + } catch (repoError) { + const errorMessage = repoError instanceof Error ? repoError.message : String(repoError); + core.warning(`Repository permission check failed: ${errorMessage}`); + core.setOutput("is_team_member", "false"); + core.setOutput("result", "api_error"); + core.setOutput("error_message", `Repository permission check failed: ${errorMessage}`); + return; + } + } + await main(); + - name: Check skip-if-match query + id: check_skip_if_match + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SKIP_QUERY: "is:issue is:open in:title \"[file-diet]\"" + GH_AW_WORKFLOW_NAME: "Daily File Diet" + with: + script: | + async function main() { + const skipQuery = process.env.GH_AW_SKIP_QUERY; + const workflowName = process.env.GH_AW_WORKFLOW_NAME; + if (!skipQuery) { + core.setFailed("Configuration error: GH_AW_SKIP_QUERY not specified."); + return; + } + if (!workflowName) { + core.setFailed("Configuration error: GH_AW_WORKFLOW_NAME not specified."); + return; + } + core.info(`Checking skip-if-match query: ${skipQuery}`); + const { owner, repo } = context.repo; + const scopedQuery = `${skipQuery} repo:${owner}/${repo}`; + core.info(`Scoped query: ${scopedQuery}`); + try { + const response = await github.rest.search.issuesAndPullRequests({ + q: scopedQuery, + per_page: 1, + }); + const totalCount = response.data.total_count; + core.info(`Search found ${totalCount} matching items`); + if (totalCount > 0) { + core.warning(`🔍 Skip condition matched (${totalCount} items found). Workflow execution will be prevented by activation job.`); + core.setOutput("skip_check_ok", "false"); + return; + } + core.info("✓ No matches found, workflow can proceed"); + core.setOutput("skip_check_ok", "true"); + } catch (error) { + core.setFailed(`Failed to execute search query: ${error instanceof Error ? error.message : String(error)}`); + return; + } + } + await main(); + diff --git a/.github/workflows/daily-file-diet.md b/.github/workflows/daily-file-diet.md index 9825262828e..0420f5ac8b1 100644 --- a/.github/workflows/daily-file-diet.md +++ b/.github/workflows/daily-file-diet.md @@ -5,6 +5,7 @@ on: workflow_dispatch: schedule: - cron: "0 13 * * 1-5" # Weekdays at 1 PM UTC + skip-if-match: 'is:issue is:open in:title "[file-diet]"' permissions: contents: read diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index dc3f71a028b..84f7ba1c99f 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -192,11 +192,13 @@ const SafeOutputsMCPServerID = "safeoutputs" // Step IDs for pre-activation job const CheckMembershipStepID = "check_membership" const CheckStopTimeStepID = "check_stop_time" +const CheckSkipIfMatchStepID = "check_skip_if_match" const CheckCommandPositionStepID = "check_command_position" // Output names for pre-activation job steps const IsTeamMemberOutput = "is_team_member" const StopTimeOkOutput = "stop_time_ok" +const SkipCheckOkOutput = "skip_check_ok" const CommandPositionOkOutput = "command_position_ok" const ActivatedOutput = "activated" diff --git a/pkg/constants/constants_test.go b/pkg/constants/constants_test.go index 81aa715f32d..3c8cbdfa406 100644 --- a/pkg/constants/constants_test.go +++ b/pkg/constants/constants_test.go @@ -220,9 +220,11 @@ func TestConstantValues(t *testing.T) { {"SafeOutputsMCPServerID", SafeOutputsMCPServerID, "safeoutputs"}, {"CheckMembershipStepID", CheckMembershipStepID, "check_membership"}, {"CheckStopTimeStepID", CheckStopTimeStepID, "check_stop_time"}, + {"CheckSkipIfMatchStepID", CheckSkipIfMatchStepID, "check_skip_if_match"}, {"CheckCommandPositionStepID", CheckCommandPositionStepID, "check_command_position"}, {"IsTeamMemberOutput", IsTeamMemberOutput, "is_team_member"}, {"StopTimeOkOutput", StopTimeOkOutput, "stop_time_ok"}, + {"SkipCheckOkOutput", SkipCheckOkOutput, "skip_check_ok"}, {"CommandPositionOkOutput", CommandPositionOkOutput, "command_position_ok"}, {"ActivatedOutput", ActivatedOutput, "activated"}, {"DefaultActivationJobRunnerImage", DefaultActivationJobRunnerImage, "ubuntu-slim"}, diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 0c3f4f21af7..0ac23dfdc4c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -916,6 +916,10 @@ "type": "string", "description": "Time when workflow should stop running. Supports multiple formats: absolute dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.) or relative time deltas (+25h, +3d, +1d12h30m)" }, + "skip-if-match": { + "type": "string", + "description": "GitHub search query string to check before running workflow. If the search returns any results, the workflow will be skipped. Query is automatically scoped to the current repository. Example: 'is:issue is:open label:bug'" + }, "manual-approval": { "type": "string", "description": "Environment name that requires manual approval before the workflow can run. Must match a valid environment configured in the repository settings." diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 87c0a5c5323..a8705b61052 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -219,6 +219,7 @@ type WorkflowData struct { EngineConfig *EngineConfig // Extended engine configuration AgentFile string // Path to custom agent file (from imports) StopTime string + SkipIfMatch string // GitHub search query to check before running workflow ManualApproval string // environment name for manual approval from on: section Command string // for /command trigger support CommandEvents []string // events where command should be active (nil = all events) @@ -1176,6 +1177,12 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) return nil, err } + // Process skip-if-match configuration from the on: section + err = c.processSkipIfMatchConfiguration(result.Frontmatter, workflowData) + if err != nil { + return nil, err + } + // Process manual-approval configuration from the on: section err = c.processManualApprovalConfiguration(result.Frontmatter, workflowData) if err != nil { diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 143435224b0..4fef8e67119 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -47,7 +47,8 @@ func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error { // Determine if permission checks or stop-time checks are needed needsPermissionCheck := c.needsRoleCheck(data, frontmatter) hasStopTime := data.StopTime != "" - compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasCommand=%v", needsPermissionCheck, hasStopTime, data.Command != "") + hasSkipIfMatch := data.SkipIfMatch != "" + compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasSkipIfMatch=%v, hasCommand=%v", needsPermissionCheck, hasStopTime, hasSkipIfMatch, data.Command != "") // Determine if we need to add workflow_run repository safety check // Add the check if the agentic workflow declares a workflow_run trigger @@ -61,10 +62,10 @@ func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error { // Extract lock filename for timestamp check lockFilename := filepath.Base(strings.TrimSuffix(markdownPath, ".md") + ".lock.yml") - // Build pre-activation job if needed (combines membership checks, stop-time validation, and command position check) + // Build pre-activation job if needed (combines membership checks, stop-time validation, skip-if-match check, and command position check) var preActivationJobCreated bool hasCommandTrigger := data.Command != "" - if needsPermissionCheck || hasStopTime || hasCommandTrigger { + if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasCommandTrigger { preActivationJob, err := c.buildPreActivationJob(data, needsPermissionCheck) if err != nil { return fmt.Errorf("failed to build %s job: %w", constants.PreActivationJobName, err) @@ -438,6 +439,25 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, formattedScript...) } + // Add skip-if-match check if configured + if data.SkipIfMatch != "" { + // Extract workflow name for the skip-if-match check + workflowName := data.Name + + steps = append(steps, " - name: Check skip-if-match query\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfMatchStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_QUERY: %q\n", data.SkipIfMatch)) + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + + // Add the JavaScript script with proper indentation + formattedScript := FormatJavaScriptForYAML(checkSkipIfMatchScript) + steps = append(steps, formattedScript...) + } + // Add command position check if this is a command workflow if data.Command != "" { steps = append(steps, " - name: Check command position\n") @@ -479,6 +499,16 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec conditions = append(conditions, stopTimeCheck) } + if data.SkipIfMatch != "" { + // Add skip-if-match check condition + skipCheckOk := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfMatchStepID, constants.SkipCheckOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, skipCheckOk) + } + if data.Command != "" { // Add command position check condition commandPositionCheck := BuildComparison( diff --git a/pkg/workflow/frontmatter_extraction.go b/pkg/workflow/frontmatter_extraction.go index e9f970d12e2..d19cb0a7a77 100644 --- a/pkg/workflow/frontmatter_extraction.go +++ b/pkg/workflow/frontmatter_extraction.go @@ -184,6 +184,9 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string) string { } else if strings.HasPrefix(trimmedLine, "stop-after:") { shouldComment = true commentReason = " # Stop-after processed as stop-time check in pre-activation job" + } else if strings.HasPrefix(trimmedLine, "skip-if-match:") { + shouldComment = true + commentReason = " # Skip-if-match processed as search check in pre-activation job" } else if strings.HasPrefix(trimmedLine, "reaction:") { shouldComment = true commentReason = " # Reaction processed as activation job step" diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index 3810fdeb9d0..4f6b379cf43 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -26,6 +26,9 @@ var checkMembershipScript string //go:embed js/check_stop_time.cjs var checkStopTimeScript string +//go:embed js/check_skip_if_match.cjs +var checkSkipIfMatchScript string + //go:embed js/check_command_position.cjs var checkCommandPositionScript string diff --git a/pkg/workflow/js/check_skip_if_match.cjs b/pkg/workflow/js/check_skip_if_match.cjs new file mode 100644 index 00000000000..b4e9163cde7 --- /dev/null +++ b/pkg/workflow/js/check_skip_if_match.cjs @@ -0,0 +1,51 @@ +// @ts-check +/// + +async function main() { + const skipQuery = process.env.GH_AW_SKIP_QUERY; + const workflowName = process.env.GH_AW_WORKFLOW_NAME; + + if (!skipQuery) { + core.setFailed("Configuration error: GH_AW_SKIP_QUERY not specified."); + return; + } + + if (!workflowName) { + core.setFailed("Configuration error: GH_AW_WORKFLOW_NAME not specified."); + return; + } + + core.info(`Checking skip-if-match query: ${skipQuery}`); + + // Get repository information from context + const { owner, repo } = context.repo; + + // Scope the query to the current repository + const scopedQuery = `${skipQuery} repo:${owner}/${repo}`; + + core.info(`Scoped query: ${scopedQuery}`); + + try { + // Search for issues and pull requests using the GitHub API + const response = await github.rest.search.issuesAndPullRequests({ + q: scopedQuery, + per_page: 1, // We only need to know if there are any matches + }); + + const totalCount = response.data.total_count; + core.info(`Search found ${totalCount} matching items`); + + if (totalCount > 0) { + core.warning(`🔍 Skip condition matched (${totalCount} items found). Workflow execution will be prevented by activation job.`); + core.setOutput("skip_check_ok", "false"); + return; + } + + core.info("✓ No matches found, workflow can proceed"); + core.setOutput("skip_check_ok", "true"); + } catch (error) { + core.setFailed(`Failed to execute search query: ${error instanceof Error ? error.message : String(error)}`); + return; + } +} +await main(); diff --git a/pkg/workflow/js/check_skip_if_match.test.cjs b/pkg/workflow/js/check_skip_if_match.test.cjs new file mode 100644 index 00000000000..cc3dfc36027 --- /dev/null +++ b/pkg/workflow/js/check_skip_if_match.test.cjs @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; + +// Mock the global objects that GitHub Actions provides +const mockCore = { + // Core logging functions + debug: vi.fn(), + info: vi.fn(), + notice: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + + // Core workflow functions + setFailed: vi.fn(), + setOutput: vi.fn(), + exportVariable: vi.fn(), + setSecret: vi.fn(), + setCancelled: vi.fn(), + setError: vi.fn(), + + // Input/state functions + getInput: vi.fn(), + getBooleanInput: vi.fn(), + getMultilineInput: vi.fn(), + getState: vi.fn(), + saveState: vi.fn(), + + // Group functions + startGroup: vi.fn(), + endGroup: vi.fn(), + group: vi.fn(), + + // Other utility functions + addPath: vi.fn(), + setCommandEcho: vi.fn(), + isDebug: vi.fn().mockReturnValue(false), + getIDToken: vi.fn(), + toPlatformPath: vi.fn(), + toPosixPath: vi.fn(), + toWin32Path: vi.fn(), + + // Summary object with chainable methods + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(), + }, +}; + +const mockGithub = { + rest: { + search: { + issuesAndPullRequests: vi.fn(), + }, + }, +}; + +const mockContext = { + repo: { + owner: "testowner", + repo: "testrepo", + }, +}; + +// Set up global variables +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +describe("check_skip_if_match.cjs", () => { + let checkSkipIfMatchScript; + let originalEnv; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Store original environment + originalEnv = { + GH_AW_SKIP_QUERY: process.env.GH_AW_SKIP_QUERY, + GH_AW_WORKFLOW_NAME: process.env.GH_AW_WORKFLOW_NAME, + }; + + // Read the script content + const scriptPath = path.join(process.cwd(), "check_skip_if_match.cjs"); + checkSkipIfMatchScript = fs.readFileSync(scriptPath, "utf8"); + }); + + afterEach(() => { + // Restore original environment + if (originalEnv.GH_AW_SKIP_QUERY !== undefined) { + process.env.GH_AW_SKIP_QUERY = originalEnv.GH_AW_SKIP_QUERY; + } else { + delete process.env.GH_AW_SKIP_QUERY; + } + if (originalEnv.GH_AW_WORKFLOW_NAME !== undefined) { + process.env.GH_AW_WORKFLOW_NAME = originalEnv.GH_AW_WORKFLOW_NAME; + } else { + delete process.env.GH_AW_WORKFLOW_NAME; + } + }); + + describe("when skip query is not configured", () => { + it("should fail if GH_AW_SKIP_QUERY is not set", async () => { + delete process.env.GH_AW_SKIP_QUERY; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + + await eval(`(async () => { ${checkSkipIfMatchScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith( + expect.stringContaining("GH_AW_SKIP_QUERY not specified") + ); + expect(mockCore.setOutput).not.toHaveBeenCalled(); + }); + + it("should fail if GH_AW_WORKFLOW_NAME is not set", async () => { + process.env.GH_AW_SKIP_QUERY = "is:issue is:open"; + delete process.env.GH_AW_WORKFLOW_NAME; + + await eval(`(async () => { ${checkSkipIfMatchScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith( + expect.stringContaining("GH_AW_WORKFLOW_NAME not specified") + ); + expect(mockCore.setOutput).not.toHaveBeenCalled(); + }); + }); + + describe("when search returns no matches", () => { + it("should allow execution", async () => { + process.env.GH_AW_SKIP_QUERY = "is:issue is:open label:nonexistent"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 0, + items: [], + }, + }); + + await eval(`(async () => { ${checkSkipIfMatchScript} })()`); + + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: "is:issue is:open label:nonexistent repo:testowner/testrepo", + per_page: 1, + }); + expect(mockCore.info).toHaveBeenCalledWith( + expect.stringContaining("No matches found") + ); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_check_ok", "true"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + }); + + describe("when search returns matches", () => { + it("should set skip_check_ok to false", async () => { + process.env.GH_AW_SKIP_QUERY = "is:issue is:open label:bug"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 5, + items: [{ id: 1, title: "Test Issue" }], + }, + }); + + await eval(`(async () => { ${checkSkipIfMatchScript} })()`); + + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: "is:issue is:open label:bug repo:testowner/testrepo", + per_page: 1, + }); + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining("Skip condition matched") + ); + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining("5 items found") + ); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_check_ok", "false"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should handle single match", async () => { + process.env.GH_AW_SKIP_QUERY = "is:pr is:open"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [{ id: 1, title: "Test PR" }], + }, + }); + + await eval(`(async () => { ${checkSkipIfMatchScript} })()`); + + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining("1 items found") + ); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_check_ok", "false"); + }); + }); + + describe("when search API fails", () => { + it("should fail with error message", async () => { + process.env.GH_AW_SKIP_QUERY = "is:issue"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + + const errorMessage = "API rate limit exceeded"; + mockGithub.rest.search.issuesAndPullRequests.mockRejectedValue( + new Error(errorMessage) + ); + + await eval(`(async () => { ${checkSkipIfMatchScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith( + expect.stringContaining("Failed to execute search query") + ); + expect(mockCore.setFailed).toHaveBeenCalledWith( + expect.stringContaining(errorMessage) + ); + expect(mockCore.setOutput).not.toHaveBeenCalled(); + }); + }); + + describe("query scoping", () => { + it("should automatically scope query to current repository", async () => { + process.env.GH_AW_SKIP_QUERY = "is:issue label:enhancement"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + + await eval(`(async () => { ${checkSkipIfMatchScript} })()`); + + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: "is:issue label:enhancement repo:testowner/testrepo", + per_page: 1, + }); + }); + }); +}); diff --git a/pkg/workflow/skip_if_match_test.go b/pkg/workflow/skip_if_match_test.go new file mode 100644 index 00000000000..c02918abe49 --- /dev/null +++ b/pkg/workflow/skip_if_match_test.go @@ -0,0 +1,184 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/githubnext/gh-aw/pkg/testutil" +) + +// TestSkipIfMatchPreActivationJob tests that skip-if-match check is created correctly in pre-activation job +func TestSkipIfMatchPreActivationJob(t *testing.T) { + tmpDir := testutil.TempDir(t, "skip-if-match-test") + + compiler := NewCompiler(false, "", "test") + + t.Run("pre_activation_job_created_with_skip_if_match", func(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: + skip-if-match: "is:issue is:open label:in-progress" +engine: claude +--- + +# Skip If Match Workflow + +This workflow has a skip-if-match configuration. +` + workflowFile := filepath.Join(tmpDir, "skip-if-match-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := strings.TrimSuffix(workflowFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify pre_activation job exists + if !strings.Contains(lockContentStr, "pre_activation:") { + t.Error("Expected pre_activation job to be created") + } + + // Verify skip-if-match check is present + if !strings.Contains(lockContentStr, "Check skip-if-match query") { + t.Error("Expected skip-if-match check to be present") + } + + // Verify the skip query environment variable is set correctly + if !strings.Contains(lockContentStr, `GH_AW_SKIP_QUERY: "is:issue is:open label:in-progress"`) { + t.Error("Expected GH_AW_SKIP_QUERY environment variable with correct value") + } + + // Verify the check_skip_if_match step ID is present + if !strings.Contains(lockContentStr, "id: check_skip_if_match") { + t.Error("Expected check_skip_if_match step ID") + } + + // Verify the activated output includes skip_check_ok condition + if !strings.Contains(lockContentStr, "steps.check_skip_if_match.outputs.skip_check_ok") { + t.Error("Expected activated output to include skip_check_ok condition") + } + + // Verify skip-if-match is commented out in the frontmatter + if !strings.Contains(lockContentStr, "# skip-if-match:") { + t.Error("Expected skip-if-match to be commented out in lock file") + } + + if !strings.Contains(lockContentStr, "Skip-if-match processed as search check in pre-activation job") { + t.Error("Expected comment explaining skip-if-match processing") + } + }) + + t.Run("pre_activation_job_with_multiple_checks", func(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: + stop-after: "+48h" + skip-if-match: "is:pr is:open" +roles: [admin, maintainer] +engine: claude +--- + +# Multiple Checks Workflow + +This workflow has both stop-after and skip-if-match. +` + workflowFile := filepath.Join(tmpDir, "multiple-checks-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := strings.TrimSuffix(workflowFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify pre_activation job exists + if !strings.Contains(lockContentStr, "pre_activation:") { + t.Error("Expected pre_activation job to be created") + } + + // Verify both checks are present + if !strings.Contains(lockContentStr, "Check stop-time limit") { + t.Error("Expected stop-time check to be present") + } + + if !strings.Contains(lockContentStr, "Check skip-if-match query") { + t.Error("Expected skip-if-match check to be present") + } + + // Verify the activated output includes both conditions + // The actual format has nested parentheses: ((a && b) && c) + if !strings.Contains(lockContentStr, "steps.check_membership.outputs.is_team_member == 'true'") || + !strings.Contains(lockContentStr, "steps.check_stop_time.outputs.stop_time_ok == 'true'") || + !strings.Contains(lockContentStr, "steps.check_skip_if_match.outputs.skip_check_ok == 'true'") { + t.Error("Expected activated output to include all three conditions") + } + }) + + t.Run("skip_if_match_without_roles", func(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: + skip-if-match: "is:issue label:bug" +engine: claude +--- + +# Skip If Match Without Roles + +This workflow has skip-if-match but no role restrictions. +` + workflowFile := filepath.Join(tmpDir, "skip-no-roles-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := strings.TrimSuffix(workflowFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify pre_activation job exists (created due to skip-if-match) + if !strings.Contains(lockContentStr, "pre_activation:") { + t.Error("Expected pre_activation job to be created even without role checks") + } + + // Verify skip-if-match check is present + if !strings.Contains(lockContentStr, "Check skip-if-match query") { + t.Error("Expected skip-if-match check to be present") + } + + // Since there's no role check, activated should only depend on skip_check_ok + // Note: There's still a membership check with default roles, so both will be present + if !strings.Contains(lockContentStr, "steps.check_skip_if_match.outputs.skip_check_ok") { + t.Error("Expected activated output to include skip_check_ok condition") + } + }) +} diff --git a/pkg/workflow/stop_after.go b/pkg/workflow/stop_after.go index 9bae07c13a3..48fc4332ffc 100644 --- a/pkg/workflow/stop_after.go +++ b/pkg/workflow/stop_after.go @@ -139,3 +139,45 @@ func ExtractStopTimeFromLockFile(lockFilePath string) string { } return "" } + +// extractSkipIfMatchFromOn extracts the skip-if-match value from the on: section +func (c *Compiler) extractSkipIfMatchFromOn(frontmatter map[string]any) (string, error) { + onSection, exists := frontmatter["on"] + if !exists { + return "", nil + } + + // Handle different formats of the on: section + switch on := onSection.(type) { + case string: + // Simple string format like "on: push" - no skip-if-match possible + return "", nil + case map[string]any: + // Complex object format - look for skip-if-match + if skipIfMatch, exists := on["skip-if-match"]; exists { + if str, ok := skipIfMatch.(string); ok { + return str, nil + } + return "", fmt.Errorf("skip-if-match value must be a string, got %T. Example: skip-if-match: \"is:issue is:open label:bug\"", skipIfMatch) + } + return "", nil + default: + return "", fmt.Errorf("invalid on: section format") + } +} + +// processSkipIfMatchConfiguration extracts and processes skip-if-match configuration from frontmatter +func (c *Compiler) processSkipIfMatchConfiguration(frontmatter map[string]any, workflowData *WorkflowData) error { + // Extract skip-if-match from the on: section + skipIfMatch, err := c.extractSkipIfMatchFromOn(frontmatter) + if err != nil { + return err + } + workflowData.SkipIfMatch = skipIfMatch + + if c.verbose && workflowData.SkipIfMatch != "" { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Skip-if-match query configured: %s", workflowData.SkipIfMatch))) + } + + return nil +}