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
19 changes: 17 additions & 2 deletions .github/workflows/ai-moderator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .github/workflows/ai-moderator.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
types: [created]
lock-for-agent: true
skip-roles: [admin, maintainer, write, triage]
skip-bots: [github-actions, copilot]
rate-limit:
max: 5
window: 60
Expand Down
63 changes: 63 additions & 0 deletions actions/setup/js/check_skip_bots.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* Check if the workflow should be skipped based on bot/user identity
* Reads skip-bots from GH_AW_SKIP_BOTS environment variable
* If the github.actor is in the skip-bots list, set skip_bots_ok to false (skip the workflow)
* Otherwise, set skip_bots_ok to true (allow the workflow to proceed)
*/
async function main() {
const { eventName } = context;
const actor = context.actor;

// Parse skip-bots from environment variable
const skipBotsEnv = process.env.GH_AW_SKIP_BOTS;
if (!skipBotsEnv || skipBotsEnv.trim() === "") {
// No skip-bots configured, workflow should proceed
core.info("✅ No skip-bots configured, workflow will proceed");
core.setOutput("skip_bots_ok", "true");
core.setOutput("result", "no_skip_bots");
return;
}

const skipBots = skipBotsEnv
.split(",")
.map(u => u.trim())
.filter(u => u);
core.info(`Checking if user '${actor}' is in skip-bots: ${skipBots.join(", ")}`);

// Check if the actor is in the skip-bots list
// Match both exact username and username with [bot] suffix
// e.g., "github-actions" matches both "github-actions" and "github-actions[bot]"
const isSkipped = skipBots.some(skipBot => {
// Exact match
if (actor === skipBot) {
return true;
}
// Match with [bot] suffix
if (actor === `${skipBot}[bot]`) {
return true;
}
// Match if skip-bot has [bot] suffix and actor matches base name
if (skipBot.endsWith("[bot]") && actor === skipBot.slice(0, -5)) {
return true;
}
return false;
});

if (isSkipped) {
// User is in skip-bots, skip the workflow
core.info(`❌ User '${actor}' is in skip-bots [${skipBots.join(", ")}]. Workflow will be skipped.`);
core.setOutput("skip_bots_ok", "false");
core.setOutput("result", "skipped");
core.setOutput("error_message", `Workflow skipped: User '${actor}' is in skip-bots: [${skipBots.join(", ")}]`);
} else {
// User is NOT in skip-bots, allow workflow to proceed
core.info(`✅ User '${actor}' is NOT in skip-bots [${skipBots.join(", ")}]. Workflow will proceed.`);
core.setOutput("skip_bots_ok", "true");
core.setOutput("result", "not_skipped");
}
}

module.exports = { main };
140 changes: 140 additions & 0 deletions actions/setup/js/check_skip_bots.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

describe("check_skip_bots.cjs", () => {
let mockCore;
let mockContext;

beforeEach(() => {
// Mock core actions methods
mockCore = {
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
};

mockContext = {
actor: "test-user",
eventName: "issues",
repo: {
owner: "test-owner",
repo: "test-repo",
},
};

// Set up global mocks
global.core = mockCore;
global.context = mockContext;

// Clear environment variables
delete process.env.GH_AW_SKIP_BOTS;

// Clear module cache to ensure fresh import
vi.resetModules();
});

afterEach(() => {
vi.clearAllMocks();
delete global.core;
delete global.context;
});

it("should allow workflow when no skip-bots configured", async () => {
delete process.env.GH_AW_SKIP_BOTS;

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "no_skip_bots");
});

it("should skip workflow for exact username match", async () => {
process.env.GH_AW_SKIP_BOTS = "test-user,other-user";
mockContext.actor = "test-user";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "skipped");
});

it("should allow workflow when user not in skip-bots", async () => {
process.env.GH_AW_SKIP_BOTS = "other-user,another-user";
mockContext.actor = "test-user";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "not_skipped");
});

it("should skip workflow for bot with [bot] suffix when base name in skip-bots", async () => {
process.env.GH_AW_SKIP_BOTS = "github-actions,copilot";
mockContext.actor = "github-actions[bot]";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "skipped");
});

it("should skip workflow for base name when skip-bots has [bot] suffix", async () => {
process.env.GH_AW_SKIP_BOTS = "github-actions[bot],copilot[bot]";
mockContext.actor = "github-actions";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "skipped");
});

it("should skip workflow for exact match with [bot] suffix", async () => {
process.env.GH_AW_SKIP_BOTS = "github-actions[bot]";
mockContext.actor = "github-actions[bot]";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "skipped");
});

it("should handle multiple users with mixed bot syntax", async () => {
process.env.GH_AW_SKIP_BOTS = "user1,github-actions,copilot[bot]";
mockContext.actor = "copilot";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "skipped");
});

it("should not skip for partial matches", async () => {
process.env.GH_AW_SKIP_BOTS = "github-actions";
mockContext.actor = "github-actions-bot";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "not_skipped");
});

it("should handle whitespace in skip-bots list", async () => {
process.env.GH_AW_SKIP_BOTS = " github-actions , copilot , renovate ";
mockContext.actor = "copilot[bot]";

const { main } = await import("./check_skip_bots.cjs");
await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("skip_bots_ok", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "skipped");
});
});
2 changes: 2 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ const CheckSkipIfNoMatchStepID StepID = "check_skip_if_no_match"
const CheckCommandPositionStepID StepID = "check_command_position"
const CheckRateLimitStepID StepID = "check_rate_limit"
const CheckSkipRolesStepID StepID = "check_skip_roles"
const CheckSkipBotsStepID StepID = "check_skip_bots"

// Output names for pre-activation job steps
const IsTeamMemberOutput = "is_team_member"
Expand All @@ -659,6 +660,7 @@ const CommandPositionOkOutput = "command_position_ok"
const MatchedCommandOutput = "matched_command"
const RateLimitOkOutput = "rate_limit_ok"
const SkipRolesOkOutput = "skip_roles_ok"
const SkipBotsOkOutput = "skip_bots_ok"
const ActivatedOutput = "activated"

// Rate limit defaults
Expand Down
72 changes: 72 additions & 0 deletions pkg/parser/content_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ func extractBotsFromContent(content string) (string, error) {
return extractFrontmatterField(content, "bots", "[]")
}

// extractSkipRolesFromContent extracts skip-roles from on: section as JSON string
func extractSkipRolesFromContent(content string) (string, error) {
return extractOnSectionField(content, "skip-roles")
}

// extractSkipBotsFromContent extracts skip-bots from on: section as JSON string
func extractSkipBotsFromContent(content string) (string, error) {
return extractOnSectionField(content, "skip-bots")
}

// extractPluginsFromContent extracts plugins section from frontmatter as JSON string
func extractPluginsFromContent(content string) (string, error) {
return extractFrontmatterField(content, "plugins", "[]")
Expand Down Expand Up @@ -213,3 +223,65 @@ func extractFrontmatterField(content, fieldName, emptyValue string) (string, err
contentExtractorLog.Printf("Successfully extracted field %s: size=%d bytes", fieldName, len(fieldJSON))
return strings.TrimSpace(string(fieldJSON)), nil
}

// extractOnSectionField extracts a specific field from the on: section in frontmatter as JSON string
func extractOnSectionField(content, fieldName string) (string, error) {
contentExtractorLog.Printf("Extracting on: section field: %s", fieldName)
result, err := ExtractFrontmatterFromContent(content)
if err != nil {
contentExtractorLog.Printf("Failed to extract frontmatter for field %s: %v", fieldName, err)
return "[]", nil // Return empty array on error
}

// Extract the "on" section
onValue, exists := result.Frontmatter["on"]
if !exists {
contentExtractorLog.Printf("Field 'on' not found in frontmatter")
return "[]", nil
}

// The on: section should be a map
onMap, ok := onValue.(map[string]any)
if !ok {
contentExtractorLog.Printf("Field 'on' is not a map: %T", onValue)
return "[]", nil
}

// Extract the requested field from the on: section
fieldValue, exists := onMap[fieldName]
if !exists {
contentExtractorLog.Printf("Field %s not found in 'on' section", fieldName)
return "[]", nil
}

// Normalize field value to an array
var normalizedValue []any
switch v := fieldValue.(type) {
case string:
// Single string value
if v != "" {
normalizedValue = []any{v}
}
case []any:
// Already an array
normalizedValue = v
case []string:
// String array - convert to []any
for _, s := range v {
normalizedValue = append(normalizedValue, s)
}
default:
contentExtractorLog.Printf("Unexpected type for field %s: %T", fieldName, fieldValue)
return "[]", nil
}

// Return JSON string
jsonData, err := json.Marshal(normalizedValue)
if err != nil {
contentExtractorLog.Printf("Failed to marshal field %s to JSON: %v", fieldName, err)
return "[]", nil
}

contentExtractorLog.Printf("Successfully extracted field %s from on: section: %d bytes", fieldName, len(jsonData))
return string(jsonData), nil
}
2 changes: 2 additions & 0 deletions pkg/parser/frontmatter_hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ func buildCanonicalFrontmatter(frontmatter map[string]any, result *ImportsResult
addString("merged-secret-masking", result.MergedSecretMasking)
addSlice("merged-bots", result.MergedBots)
addString("merged-post-steps", result.MergedPostSteps)
addSlice("merged-skip-roles", result.MergedSkipRoles)
addSlice("merged-skip-bots", result.MergedSkipBots)
addSlice("merged-labels", result.MergedLabels)
addSlice("merged-caches", result.MergedCaches)

Expand Down
Loading
Loading