Skip to content
Open
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
30 changes: 30 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ inputs:
description: "Automatically run the review flow for pull request contexts without requiring an explicit @droid review command. Only supported for PR-related events."
required: false
default: "false"
automatic_security_review:
description: "Automatically run the security review flow for pull request contexts without requiring an explicit @droid security command. Only supported for PR-related events."
required: false
default: "false"
security_model:
description: "Override the model used for security review (e.g., 'claude-sonnet-4-5-20250929', 'gpt-5.1-codex'). Only applies to security review flows."
required: false
default: ""
security_severity_threshold:
description: "Minimum severity to report in security reviews (critical, high, medium, low). Findings below this threshold will be filtered out."
required: false
default: "medium"
security_block_on_critical:
description: "Submit REQUEST_CHANGES review when critical severity findings are detected."
required: false
default: "true"
security_block_on_high:
description: "Submit REQUEST_CHANGES review when high severity findings are detected."
required: false
default: "false"
security_notify_team:
description: "GitHub team to @mention on critical findings (e.g., '@org/security-team')."
required: false
default: ""
review_model:
description: "Override the model used for code review (e.g., 'claude-sonnet-4-5-20250929', 'gpt-5.1-codex'). Only applies to review flows."
required: false
Expand Down Expand Up @@ -131,6 +155,12 @@ runs:
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
TRACK_PROGRESS: ${{ inputs.track_progress }}
AUTOMATIC_REVIEW: ${{ inputs.automatic_review }}
AUTOMATIC_SECURITY_REVIEW: ${{ inputs.automatic_security_review }}
SECURITY_MODEL: ${{ inputs.security_model }}
SECURITY_SEVERITY_THRESHOLD: ${{ inputs.security_severity_threshold }}
SECURITY_BLOCK_ON_CRITICAL: ${{ inputs.security_block_on_critical }}
SECURITY_BLOCK_ON_HIGH: ${{ inputs.security_block_on_high }}
SECURITY_NOTIFY_TEAM: ${{ inputs.security_notify_team }}
REVIEW_MODEL: ${{ inputs.review_model }}
FILL_MODEL: ${{ inputs.fill_model }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
Expand Down
3 changes: 1 addition & 2 deletions base-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ async function run() {
mcpTools: process.env.INPUT_MCP_TOOLS,
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
pathToDroidExecutable:
process.env.INPUT_PATH_TO_DROID_EXECUTABLE,
pathToDroidExecutable: process.env.INPUT_PATH_TO_DROID_EXECUTABLE,
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
});
} catch (error) {
Expand Down
43 changes: 30 additions & 13 deletions base-action/src/run-droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function sanitizeJsonOutput(

export type DroidOptions = {
droidArgs?: string;
reasoningEffort?: string;
pathToDroidExecutable?: string;
allowedTools?: string;
disallowedTools?: string;
Expand All @@ -90,6 +91,11 @@ export function prepareRunConfig(
): PreparedConfig {
const droidArgs = [...BASE_ARGS];

// Add reasoning effort only when explicitly requested
if (options.reasoningEffort?.trim()) {
droidArgs.push("--reasoning-effort", options.reasoningEffort.trim());
}

// Parse and add user's custom Droid arguments
if (options.droidArgs?.trim()) {
const parsed = parseShellArgs(options.droidArgs);
Expand Down Expand Up @@ -121,10 +127,12 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
const cfg = JSON.parse(options.mcpTools);
const servers = cfg?.mcpServers || {};
const serverNames = Object.keys(servers);

if (serverNames.length > 0) {
console.log(`Registering ${serverNames.length} MCP servers: ${serverNames.join(", ")}`);

console.log(
`Registering ${serverNames.length} MCP servers: ${serverNames.join(", ")}`,
);

for (const [name, def] of Object.entries<any>(servers)) {
const cmd = [def.command, ...(def.args || [])]
.filter(Boolean)
Expand All @@ -143,12 +151,15 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
.join(" ");

const addCmd = `droid mcp add ${name} "${cmd}" ${envFlags}`.trim();

try {
await execAsync(addCmd, { env: { ...process.env } });
console.log(` ✓ Registered MCP server: ${name}`);
} catch (e: any) {
console.error(` ✗ Failed to register MCP server ${name}:`, e.message);
console.error(
` ✗ Failed to register MCP server ${name}:`,
e.message,
);
throw e;
}
}
Expand Down Expand Up @@ -184,15 +195,19 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
// Log custom arguments if any
if (options.droidArgs && options.droidArgs.trim() !== "") {
console.log(`Custom Droid arguments: ${options.droidArgs}`);

// Check for deprecated MCP tool naming
const enabledToolsMatch = options.droidArgs.match(/--enabled-tools\s+["\']?([^"\']+)["\']?/);
const enabledToolsMatch = options.droidArgs.match(
/--enabled-tools\s+["\']?([^"\']+)["\']?/,
);
if (enabledToolsMatch && enabledToolsMatch[1]) {
const tools = enabledToolsMatch[1].split(",").map(t => t.trim());
const oldStyleTools = tools.filter(t => t.startsWith("mcp__"));
const tools = enabledToolsMatch[1].split(",").map((t) => t.trim());
const oldStyleTools = tools.filter((t) => t.startsWith("mcp__"));

if (oldStyleTools.length > 0) {
console.warn(`Warning: Found ${oldStyleTools.length} tools with deprecated mcp__ prefix. Update to new pattern (e.g., github_comment___update_droid_comment)`);
console.warn(
`Warning: Found ${oldStyleTools.length} tools with deprecated mcp__ prefix. Update to new pattern (e.g., github_comment___update_droid_comment)`,
);
}
}
}
Expand Down Expand Up @@ -247,7 +262,10 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
const parsed = JSON.parse(line);
if (!sessionId && typeof parsed === "object" && parsed !== null) {
const detectedSessionId = parsed.session_id;
if (typeof detectedSessionId === "string" && detectedSessionId.trim()) {
if (
typeof detectedSessionId === "string" &&
detectedSessionId.trim()
) {
sessionId = detectedSessionId;
console.log(`Detected Droid session: ${sessionId}`);
}
Expand All @@ -272,7 +290,6 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
// In non-full-output mode, suppress non-JSON output
}
});

});

// Handle stdout errors
Expand Down
19 changes: 7 additions & 12 deletions base-action/test/parse-shell-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,8 @@ describe("shell-quote parseShellArgs", () => {
});

test("should parse simple arguments", () => {
expect(parseShellArgs("--auto medium")).toEqual([
"--auto",
"medium",
]);
expect(parseShellArgs("-s session-123")).toEqual([
"-s",
"session-123",
]);
expect(parseShellArgs("--auto medium")).toEqual(["--auto", "medium"]);
expect(parseShellArgs("-s session-123")).toEqual(["-s", "session-123"]);
});

test("should handle double quotes", () => {
Expand All @@ -27,10 +21,11 @@ describe("shell-quote parseShellArgs", () => {
});

test("should handle single quotes", () => {
expect(parseShellArgs("--file '/tmp/prompt.md'"))
.toEqual(["--file", "/tmp/prompt.md"]);
expect(parseShellArgs("'arg with spaces'"))
.toEqual(["arg with spaces"]);
expect(parseShellArgs("--file '/tmp/prompt.md'")).toEqual([
"--file",
"/tmp/prompt.md",
]);
expect(parseShellArgs("'arg with spaces'")).toEqual(["arg with spaces"]);
});
test("should handle escaped characters", () => {
expect(parseShellArgs("arg\\ with\\ spaces")).toEqual(["arg with spaces"]);
Expand Down
63 changes: 37 additions & 26 deletions base-action/test/run-droid-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,15 @@ const mockSpawn = mock(
mock.module("child_process", () => ({
exec: (
command: string,
options?: Record<string, unknown> | ((err: Error | null, result?: any) => void),
options?:
| Record<string, unknown>
| ((err: Error | null, result?: any) => void),
maybeCallback?: (err: Error | null, result?: any) => void,
) => {
const callback =
typeof options === "function" ? options : maybeCallback ?? (() => undefined);
typeof options === "function"
? options
: (maybeCallback ?? (() => undefined));

setImmediate(async () => {
try {
Expand All @@ -98,7 +102,7 @@ let runDroid: RunDroidModule["runDroid"];

beforeAll(async () => {
const module = (await import(
`../src/run-droid?mcp-test=${Math.random().toString(36).slice(2)}`,
`../src/run-droid?mcp-test=${Math.random().toString(36).slice(2)}`
)) as RunDroidModule;
prepareRunConfig = module.prepareRunConfig;
runDroid = module.runDroid;
Expand Down Expand Up @@ -139,23 +143,23 @@ describe("MCP Server Registration", () => {
env: {
GITHUB_TOKEN: "test-token",
REPO_OWNER: "owner",
REPO_NAME: "repo"
}
REPO_NAME: "repo",
},
},
github_ci: {
command: "bun",
args: ["run", "/path/to/github-actions-server.ts"],
env: {
GITHUB_TOKEN: "test-token",
PR_NUMBER: "123"
}
}
}
PR_NUMBER: "123",
},
},
},
});

const options: DroidOptions = {
mcpTools,
pathToDroidExecutable: "droid"
pathToDroidExecutable: "droid",
};
const promptPath = await createPromptFile();
const tempDir = process.env.RUNNER_TEMP!;
Expand All @@ -180,14 +184,14 @@ describe("MCP Server Registration", () => {
mcpTools: "",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);

expect(prepared.droidArgs).not.toContain("--mcp-config");
});

test("should handle invalid JSON in MCP config", () => {
const options: DroidOptions = {
mcpTools: "{ invalid json }",
pathToDroidExecutable: "droid"
pathToDroidExecutable: "droid",
};

// prepareRunConfig doesn't parse MCP config, so it won't throw
Expand All @@ -205,7 +209,8 @@ describe("MCP Server Registration", () => {
console.warn = warnSpy as unknown as typeof console.warn;

const options: DroidOptions = {
droidArgs: '--enabled-tools "mcp__github_comment__update_droid_comment,Execute"'
droidArgs:
'--enabled-tools "mcp__github_comment__update_droid_comment,Execute"',
};

const promptPath = await createPromptFile();
Expand All @@ -216,8 +221,10 @@ describe("MCP Server Registration", () => {

const warningMessages = warnSpy.mock.calls.map((args) => args[0]);
expect(
warningMessages.some((msg) =>
typeof msg === "string" && msg.includes("deprecated mcp__ prefix"),
warningMessages.some(
(msg) =>
typeof msg === "string" &&
msg.includes("deprecated mcp__ prefix"),
),
).toBe(true);
} finally {
Expand All @@ -232,7 +239,8 @@ describe("MCP Server Registration", () => {
console.warn = warnSpy as unknown as typeof console.warn;

const options: DroidOptions = {
droidArgs: '--enabled-tools "github_comment___update_droid_comment,Execute"'
droidArgs:
'--enabled-tools "github_comment___update_droid_comment,Execute"',
};

const promptPath = await createPromptFile();
Expand All @@ -249,14 +257,17 @@ describe("MCP Server Registration", () => {

test("should detect MCP tools with triple underscore pattern", () => {
const options: DroidOptions = {
droidArgs: '--enabled-tools "github_ci___get_ci_status,github_comment___update_droid_comment"'
droidArgs:
'--enabled-tools "github_ci___get_ci_status,github_comment___update_droid_comment"',
};

const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);

// The args should be passed through correctly
expect(prepared.droidArgs).toContain("--enabled-tools");
expect(prepared.droidArgs).toContain("github_ci___get_ci_status,github_comment___update_droid_comment");
expect(prepared.droidArgs).toContain(
"github_ci___get_ci_status,github_comment___update_droid_comment",
);
});
});

Expand All @@ -267,14 +278,14 @@ describe("MCP Server Registration", () => {
failing_server: {
command: "nonexistent",
args: ["command"],
env: {}
}
}
env: {},
},
},
});

const options: DroidOptions = {
mcpTools,
pathToDroidExecutable: "droid"
pathToDroidExecutable: "droid",
};
const promptPath = await createPromptFile();
const tempDir = process.env.RUNNER_TEMP!;
Expand All @@ -299,18 +310,18 @@ describe("MCP Server Registration", () => {
describe("Environment Variables", () => {
test("should include GITHUB_ACTION_INPUTS when present", () => {
process.env.INPUT_ACTION_INPUTS_PRESENT = "true";

const options: DroidOptions = {};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);

expect(prepared.env.GITHUB_ACTION_INPUTS).toBe("true");

delete process.env.INPUT_ACTION_INPUTS_PRESENT;
});

test("should not include GITHUB_ACTION_INPUTS when not present", () => {
delete process.env.INPUT_ACTION_INPUTS_PRESENT;

const options: DroidOptions = {};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);

Expand Down
6 changes: 3 additions & 3 deletions base-action/test/run-droid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("prepareRunConfig", () => {
"exec",
"--output-format",
"stream-json",
"--skip-permissions-unsafe",
"--skip-permissions-unsafe",
"--max-turns",
"10",
"--model",
Expand All @@ -63,7 +63,7 @@ describe("prepareRunConfig", () => {
"exec",
"--output-format",
"stream-json",
"--skip-permissions-unsafe",
"--skip-permissions-unsafe",
"-f",
"/tmp/test-prompt.txt",
]);
Expand All @@ -79,7 +79,7 @@ describe("prepareRunConfig", () => {
"exec",
"--output-format",
"stream-json",
"--skip-permissions-unsafe",
"--skip-permissions-unsafe",
"--system-prompt",
"You are a helpful assistant",
"-f",
Expand Down
4 changes: 3 additions & 1 deletion base-action/test/setup-droid-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ describe("setupDroidSettings", () => {
});

test("should throw error for non-existent file path", async () => {
expect(() => setupDroidSettings("/non/existent/file.json", testHomeDir)).toThrow();
expect(() =>
setupDroidSettings("/non/existent/file.json", testHomeDir),
).toThrow();
});

test("should handle empty string input", async () => {
Expand Down
Loading