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
18 changes: 7 additions & 11 deletions actions/setup/js/dispatch_repository.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { parseRepoSlug, validateTargetRepo, parseAllowedRepos } = require("./repo
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { buildAwContext } = require("./aw_context.cjs");
const { SAFE_OUTPUT_E001, SAFE_OUTPUT_E099 } = require("./error_codes.cjs");

/**
* Main handler factory for dispatch_repository
Expand Down Expand Up @@ -45,7 +46,7 @@ async function main(config = {}) {
core.warning("dispatch_repository: tool_name is empty, skipping");
return {
success: false,
error: "E001: tool_name is required",
error: `${SAFE_OUTPUT_E001}: tool_name is required`,
};
}

Expand All @@ -55,7 +56,7 @@ async function main(config = {}) {
core.warning(`dispatch_repository: unknown tool "${toolName}", skipping`);
return {
success: false,
error: `E001: tool "${toolName}" is not configured in dispatch_repository`,
error: `${SAFE_OUTPUT_E001}: tool "${toolName}" is not configured in dispatch_repository`,
};
}

Expand Down Expand Up @@ -88,7 +89,7 @@ async function main(config = {}) {
core.warning(`dispatch_repository: no target repository for tool "${toolName}"`);
return {
success: false,
error: `E001: No target repository configured for tool "${toolName}"`,
error: `${SAFE_OUTPUT_E001}: No target repository configured for tool "${toolName}"`,
};
}

Expand All @@ -110,22 +111,17 @@ async function main(config = {}) {
core.warning(`dispatch_repository: invalid repository slug "${targetRepoSlug}"`);
return {
success: false,
error: `E001: Invalid repository slug "${targetRepoSlug}" (expected "owner/repo")`,
error: `${SAFE_OUTPUT_E001}: Invalid repository slug "${targetRepoSlug}" (expected "owner/repo")`,
};
}

// Build client_payload from message inputs + workflow identifier
/** @type {Record<string, any>} */
const clientPayload = {
workflow: toolConfig.workflow || "",
...(message.inputs && typeof message.inputs === "object" ? message.inputs : {}),
};

if (message.inputs && typeof message.inputs === "object") {
for (const [key, value] of Object.entries(message.inputs)) {
clientPayload[key] = value;
}
}

// Inject aw_context so the receiving repository can trace the dispatch back to its caller.
clientPayload["aw_context"] = buildAwContext();

Expand Down Expand Up @@ -178,7 +174,7 @@ async function main(config = {}) {

return {
success: false,
error: `E099: Failed to dispatch repository_dispatch event "${eventType}" to ${targetRepoSlug}: ${errorMessage}`,
error: `${SAFE_OUTPUT_E099}: Failed to dispatch repository_dispatch event "${eventType}" to ${targetRepoSlug}: ${errorMessage}`,
};
}
};
Expand Down
263 changes: 263 additions & 0 deletions actions/setup/js/dispatch_repository.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// @ts-check
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

const { main } = require("./dispatch_repository.cjs");

Comment on lines +3 to +5
describe("dispatch_repository", () => {
/** @type {any} */
let mockCore;
/** @type {any} */
let mockGithub;
/** @type {any} */
let mockContext;

/** @type {any} */
let dispatchEventCalls;

/** @type {{ core: any, github: any, context: any }} */
let savedGlobals;

beforeEach(() => {
savedGlobals = {
core: global.core,
github: global.github,
context: global.context,
};

dispatchEventCalls = [];

mockCore = {
infos: /** @type {string[]} */ [],
warnings: /** @type {string[]} */ [],
errors: /** @type {string[]} */ [],
info: /** @param {string} msg */ msg => mockCore.infos.push(msg),
warning: /** @param {string} msg */ msg => mockCore.warnings.push(msg),
error: /** @param {string} msg */ msg => mockCore.errors.push(msg),
};

mockGithub = {
rest: {
repos: {
createDispatchEvent: /** @param {any} params */ async params => {
dispatchEventCalls.push(params);
return { data: {} };
},
},
},
};

mockContext = {
runId: 9999,
repo: { owner: "test-owner", repo: "test-repo" },
payload: {},
};

global.core = mockCore;
global.github = mockGithub;
global.context = mockContext;
});

afterEach(() => {
global.core = savedGlobals.core;
global.github = savedGlobals.github;
global.context = savedGlobals.context;
vi.restoreAllMocks();
});

describe("main factory", () => {
it("should return a handler function", async () => {
const handler = await main({ tools: {} });
expect(typeof handler).toBe("function");
});

it("should return a handler function with no config", async () => {
const handler = await main();
expect(typeof handler).toBe("function");
});

it("should log initialization info", async () => {
await main({ tools: { deploy: { event_type: "deploy", max: 2 } } });
expect(mockCore.infos.some(/** @param {string} m */ m => m.includes("dispatch_repository handler initialized"))).toBe(true);
});
});

describe("handleDispatchRepository", () => {
it("should return error when tool_name is missing", async () => {
const handler = await main({ tools: {} });
const result = await handler({ tool_name: "" }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/tool_name is required/);
});

it("should return error when tool_name is whitespace-only", async () => {
const handler = await main({ tools: {} });
const result = await handler({ tool_name: " " }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/tool_name is required/);
});

it("should return error when tool is not configured", async () => {
const handler = await main({ tools: {} });
const result = await handler({ tool_name: "unknown_tool" }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/not configured/);
});

it("should return error when max count is reached", async () => {
const handler = await main({
tools: {
deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: 1 },
},
});

// First dispatch succeeds
const first = await handler({ tool_name: "deploy" }, {});
expect(first.success).toBe(true);

// Second dispatch hits the limit
const second = await handler({ tool_name: "deploy" }, {});
expect(second.success).toBe(false);
expect(second.error).toMatch(/Max count/);
});

it("should return error when no target repository is configured", async () => {
const handler = await main({
tools: { deploy: { event_type: "deploy" } },
});
const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/No target repository/);
});

it("should return error when repository slug is invalid", async () => {
const handler = await main({
tools: { deploy: { event_type: "deploy", repository: "not-valid-slug" } },
});
const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Invalid repository slug/);
});

it("should return error when event_type is not configured", async () => {
const handler = await main({
tools: { deploy: { repository: "test-owner/test-repo" } },
});
const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/event_type is required/);
});

it("should dispatch successfully with valid config", async () => {
const handler = await main({
tools: {
deploy: { event_type: "deploy", repository: "test-owner/other-repo", max: 2 },
},
});

const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(true);
expect(result.tool_name).toBe("deploy");
expect(result.repository).toBe("test-owner/other-repo");
expect(result.event_type).toBe("deploy");
expect(dispatchEventCalls.length).toBe(1);
expect(dispatchEventCalls[0].event_type).toBe("deploy");
});

it("should include message inputs in client_payload", async () => {
const handler = await main({
tools: {
deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: 5 },
},
});

await handler({ tool_name: "deploy", inputs: { env: "production", version: "1.2.3" } }, {});

expect(dispatchEventCalls.length).toBe(1);
expect(dispatchEventCalls[0].client_payload.env).toBe("production");
expect(dispatchEventCalls[0].client_payload.version).toBe("1.2.3");
});

it("should inject aw_context into client_payload", async () => {
const handler = await main({
tools: {
deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: 5 },
},
});

await handler({ tool_name: "deploy" }, {});

expect(dispatchEventCalls.length).toBe(1);
expect(dispatchEventCalls[0].client_payload).toHaveProperty("aw_context");
});

it("should use message.repository over toolConfig.repository when both set", async () => {
const handler = await main({
tools: {
deploy: { event_type: "deploy", repository: "test-owner/default-repo", max: 5 },
},
});

const result = await handler({ tool_name: "deploy", repository: "test-owner/override-repo" }, {});

expect(result.success).toBe(true);
expect(result.repository).toBe("test-owner/override-repo");
expect(dispatchEventCalls[0].owner).toBe("test-owner");
expect(dispatchEventCalls[0].repo).toBe("override-repo");
});

it("should default to first allowed_repository when no target given", async () => {
const handler = await main({
tools: {
deploy: { event_type: "deploy", allowed_repositories: ["test-owner/allowed-repo"], max: 5 },
},
});

const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(true);
expect(result.repository).toBe("test-owner/allowed-repo");
});

it("should return error on API failure", async () => {
mockGithub.rest.repos.createDispatchEvent = async () => {
throw new Error("API rate limit exceeded");
};

const handler = await main({
tools: { deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: 5 } },
});

const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Failed to dispatch/);
expect(result.error).toMatch(/API rate limit exceeded/);
});

it("should return staged result when isStaged is true", async () => {
const handler = await main({
tools: { deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: 5 } },
staged: true,
});

const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(true);
expect(result.staged).toBe(true);
expect(dispatchEventCalls.length).toBe(0);
});

it("should parse numeric max from string config", async () => {
const handler = await main({
tools: { deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: "3" } },
});

// Should allow up to 3 dispatches
for (let i = 0; i < 3; i++) {
const result = await handler({ tool_name: "deploy" }, {});
expect(result.success).toBe(true);
}

const overflow = await handler({ tool_name: "deploy" }, {});
expect(overflow.success).toBe(false);
expect(overflow.error).toMatch(/Max count of 3/);
});
});
});