Skip to content
Closed
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
42 changes: 22 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

This GitHub Action powers the Factory **Droid** app. It watches your pull requests for the two supported commands and runs a full Droid Exec session to help you ship faster:

* `@droid fill` — turns a bare pull request into a polished description that matches your template or our opinionated fallback.
* `@droid review` — performs an automated code review, surfaces potential bugs, and leaves inline comments directly on the diff.
- `@droid fill` — turns a bare pull request into a polished description that matches your template or our opinionated fallback.
- `@droid review` — performs an automated code review, surfaces potential bugs, and leaves inline comments directly on the diff.

Everything runs inside GitHub Actions using your Factory API key, so the bot never leaves your repository and operates with the permissions you grant.

Expand All @@ -18,11 +18,11 @@ Everything runs inside GitHub Actions using your Factory API key, so the bot nev
## Installation

1. **Install the Droid GitHub App**
* Install from the Factory dashboard and grant it access to the repositories where you want Droid to operate.
- Install from the Factory dashboard and grant it access to the repositories where you want Droid to operate.
2. **Create a Factory API Key**
* Generate a token at [https://app.factory.ai/settings/api-keys](https://app.factory.ai/settings/api-keys) and save it as `FACTORY_API_KEY` in your repository or organization secrets.
- Generate a token at [https://app.factory.ai/settings/api-keys](https://app.factory.ai/settings/api-keys) and save it as `FACTORY_API_KEY` in your repository or organization secrets.
3. **Add the Action Workflows**
* Create two workflow files under `.github/workflows/` to separate on-demand tagging from automatic PR reviews.
- Create two workflow files under `.github/workflows/` to separate on-demand tagging from automatic PR reviews.

`droid.yml` (responds to explicit `@droid` mentions):

Expand Down Expand Up @@ -105,26 +105,28 @@ Once committed, tagging `@droid fill` or `@droid review` on an open PR will trig
## Using the Commands

### `@droid fill`
* Place the command in the PR description or in a top-level comment.
* Droid searches for common PR template locations (`.github/pull_request_template.md`, etc.). When a template exists, it fills the sections; otherwise it writes a structured summary (overview, changes, testing, rollout).
* The original request is replaced with the generated description so reviewers can merge immediately.

- Place the command in the PR description or in a top-level comment.
- Droid searches for common PR template locations (`.github/pull_request_template.md`, etc.). When a template exists, it fills the sections; otherwise it writes a structured summary (overview, changes, testing, rollout).
- The original request is replaced with the generated description so reviewers can merge immediately.

### `@droid review`
* Mention `@droid review` in a PR comment.
* Droid inspects the diff, prioritizes potential bugs or high-impact issues, and leaves inline comments directly on the changed lines.
* A short summary comment is posted in the original thread highlighting the findings and linking to any inline feedback.

- Mention `@droid review` in a PR comment.
- Droid inspects the diff, prioritizes potential bugs or high-impact issues, and leaves inline comments directly on the changed lines.
- A short summary comment is posted in the original thread highlighting the findings and linking to any inline feedback.

## Configuration Essentials

| Input | Purpose |
| --- | --- |
| `factory_api_key` | **Required.** Grants Droid Exec permission to run via Factory. |
| `github_token` | Optional override if you prefer a custom GitHub App/token. By default the installed app token is used. |
| `review_model` | Optional. Override the model used for code review (e.g., `claude-sonnet-4-5-20250929`, `gpt-5.1-codex`). Only applies to review flows. |
| `fill_model` | Optional. Override the model used for PR description fill (e.g., `claude-sonnet-4-5-20250929`, `gpt-5.1-codex`). Only applies to fill flows. |
| Input | Purpose |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `factory_api_key` | **Required.** Grants Droid Exec permission to run via Factory. |
| `github_token` | Optional override if you prefer a custom GitHub App/token. By default the installed app token is used. |
| `review_model` | Optional. Override the model used for code review (e.g., `claude-sonnet-4-5-20250929`, `gpt-5.1-codex`). Only applies to review flows. |
| `fill_model` | Optional. Override the model used for PR description fill (e.g., `claude-sonnet-4-5-20250929`, `gpt-5.1-codex`). Only applies to fill flows. |

## Troubleshooting & Support

* Check the workflow run linked from the Droid tracking comment for execution logs.
* Verify that the workflow file and repository allow the GitHub App to run (branch protections can block bots).
* Need more detail? Start with the [Setup Guide](./docs/setup.md) or [FAQ](./docs/faq.md).
- Check the workflow run linked from the Droid tracking comment for execution logs.
- Verify that the workflow file and repository allow the GitHub App to run (branch protections can block bots).
- Need more detail? Start with the [Setup Guide](./docs/setup.md) or [FAQ](./docs/faq.md).
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
37 changes: 24 additions & 13 deletions base-action/src/run-droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,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 +145,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 +189,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 +256,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 +284,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)}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] Fix invalid dynamic import syntax (missing trailing comma)

This multiline import( call is missing the trailing comma after the template string, which makes the file invalid syntax and will break the test suite.

)) 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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] Fix string element missing indentation (syntax error)

This line is missing array indentation (it starts at column 1), which makes the test file invalid TS/JS and will cause parsing/CI failures.

"--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
Loading