Skip to content

Commit 49188f4

Browse files
Merge pull request #105 from Factory-AI/dev
Merge dev into main: GitLab Phase 1, review refactor, and README auth docs
2 parents 7c7bfea + 602ad67 commit 49188f4

13 files changed

Lines changed: 413 additions & 107 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,47 @@ jobs:
240240
security_scan_days: 7
241241
```
242242

243+
## Authentication
244+
245+
Droid needs two separate kinds of access: permission to run Droid, and permission to post on your pull requests. You set them up independently.
246+
247+
### 1. Factory API key (run Droid)
248+
249+
Droid runs using your Factory API key. Create one at [app.factory.ai/settings/api-keys](https://app.factory.ai/settings/api-keys) and save it as a `FACTORY_API_KEY` secret in your repository or organization. Pass it to the action on every run:
250+
251+
```yaml
252+
- uses: Factory-AI/droid-action@main
253+
with:
254+
factory_api_key: ${{ secrets.FACTORY_API_KEY }}
255+
```
256+
257+
This input is required.
258+
259+
### 2. GitHub access (post reviews)
260+
261+
To leave comments and approvals on your PRs, Droid needs a GitHub token. There are two ways to provide one:
262+
263+
- **Factory Droid GitHub App (default, recommended).** If you don't pass a token, the action securely requests one for the installed Factory Droid GitHub App. For most teams this is all you need: install the app on your repositories from [app.factory.ai/settings/organization](https://app.factory.ai/settings/organization). It requires the `id-token: write` permission so the action can request the token:
264+
265+
```yaml
266+
permissions:
267+
contents: write
268+
pull-requests: write
269+
issues: write
270+
id-token: write # required for GitHub App auth
271+
```
272+
273+
- **Your own token (override).** If you'd rather use a personal access token or your own GitHub App — for example on GitHub Enterprise, or to control which account posts comments — pass it as `github_token`. When set, Droid uses it directly and skips the app. The token needs write access to pull requests and repository contents.
274+
275+
```yaml
276+
- uses: Factory-AI/droid-action@main
277+
with:
278+
factory_api_key: ${{ secrets.FACTORY_API_KEY }}
279+
github_token: ${{ secrets.MY_GITHUB_TOKEN }}
280+
```
281+
282+
> On GitLab, the same two pieces apply: set `FACTORY_API_KEY` and `GITLAB_TOKEN` as CI/CD variables. See [`docs/gitlab-setup.md`](docs/gitlab-setup.md).
283+
243284
## Configuration
244285

245286
### Core Inputs

action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ runs:
307307

308308
- name: Run Droid Exec (validator)
309309
id: droid_validator
310-
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.run_code_review == 'true'
310+
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.run_code_review == 'true' && steps.prepare_validator.outputs.validator_should_run != 'false'
311311
shell: bash
312312
run: |
313313
@@ -345,7 +345,7 @@ runs:
345345
GITHUB_EVENT_NAME: ${{ github.event_name }}
346346
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
347347
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
348-
DROID_SUCCESS: ${{ (steps.prepare.outputs.run_code_review == 'true' && steps.droid_validator.outputs.conclusion == 'success') || (steps.prepare.outputs.run_code_review != 'true' && steps.droid.outputs.conclusion == 'success') }}
348+
DROID_SUCCESS: ${{ (steps.prepare.outputs.run_code_review == 'true' && ((steps.prepare_validator.outputs.validator_should_run == 'false' && steps.droid.outputs.conclusion == 'success') || (steps.prepare_validator.outputs.validator_should_run != 'false' && steps.droid_validator.outputs.conclusion == 'success'))) || (steps.prepare.outputs.run_code_review != 'true' && steps.droid.outputs.conclusion == 'success') }}
349349
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
350350
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
351351
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}

base-action/src/run-droid.ts

Lines changed: 118 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ import { exec, spawn } from "child_process";
33
import { promisify } from "util";
44
import { stat } from "fs/promises";
55
import { parse as parseShellArgs } from "shell-quote";
6+
import { retryWithBackoff } from "./utils/retry";
67

78
const execAsync = promisify(exec);
89

10+
/** Redact inline `--env KEY=value` secrets before logging a command string. */
11+
function redactEnvSecrets(text: string): string {
12+
return text.replace(/--env\s+(\S+?)=\S+/g, "--env $1=***");
13+
}
14+
915
const BASE_ARGS = [
1016
"exec",
1117
"--output-format",
@@ -138,13 +144,6 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
138144
.filter(Boolean)
139145
.join(" ");
140146

141-
// Remove existing server if present (ignore errors)
142-
try {
143-
await execAsync(`droid mcp remove ${name}`);
144-
} catch (_) {
145-
// Ignore - server might not exist
146-
}
147-
148147
// Build env flags
149148
const envFlags = Object.entries(def.env || {})
150149
.map(([k, v]) => `--env ${k}=${String(v)}`)
@@ -153,14 +152,33 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
153152
const addCmd = `droid mcp add ${name} "${cmd}" ${envFlags}`.trim();
154153

155154
try {
156-
await execAsync(addCmd, { env: { ...process.env } });
155+
await retryWithBackoff(
156+
async () => {
157+
// Remove existing server if present (ignore errors) before each attempt
158+
try {
159+
await execAsync(`droid mcp remove ${name}`);
160+
} catch (_) {
161+
// Ignore - server might not exist
162+
}
163+
try {
164+
await execAsync(addCmd, { env: { ...process.env } });
165+
} catch (err) {
166+
// Redact inline --env secrets before they reach any log or rethrow.
167+
const message =
168+
err instanceof Error ? err.message : String(err);
169+
throw new Error(redactEnvSecrets(message));
170+
}
171+
},
172+
{ maxAttempts: 3, initialDelayMs: 2000, maxDelayMs: 10000 },
173+
);
157174
console.log(` ✓ Registered MCP server: ${name}`);
158-
} catch (e: any) {
175+
} catch (e) {
176+
const message = e instanceof Error ? e.message : String(e);
159177
console.error(
160178
` ✗ Failed to register MCP server ${name}:`,
161-
e.message,
179+
message,
162180
);
163-
throw e;
181+
throw new Error(message);
164182
}
165183
}
166184
}
@@ -219,19 +237,6 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
219237
// Use custom executable path if provided, otherwise default to "droid"
220238
const droidExecutable = options.pathToDroidExecutable || "droid";
221239

222-
const droidProcess = spawn(droidExecutable, config.droidArgs, {
223-
stdio: ["ignore", "pipe", "inherit"],
224-
env: {
225-
...process.env,
226-
...config.env,
227-
},
228-
});
229-
230-
// Handle Droid process errors
231-
droidProcess.on("error", (error) => {
232-
console.error("Error spawning Droid process:", error);
233-
});
234-
235240
// Determine if full output should be shown
236241
// Show full output if explicitly set to "true" OR if GitHub Actions debug mode is enabled
237242
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
@@ -247,74 +252,107 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
247252
);
248253
}
249254

250-
// Capture output for parsing execution metrics
251-
let sessionId: string | undefined;
252-
droidProcess.stdout.on("data", (data) => {
253-
const text = data.toString();
254-
255-
// Try to parse as JSON and handle based on verbose setting
256-
const lines = text.split("\n");
257-
lines.forEach((line: string, index: number) => {
258-
if (line.trim() === "") return;
259-
260-
try {
261-
// Check if this line is a JSON object
262-
const parsed = JSON.parse(line);
263-
if (!sessionId && typeof parsed === "object" && parsed !== null) {
264-
const detectedSessionId = parsed.session_id;
265-
if (
266-
typeof detectedSessionId === "string" &&
267-
detectedSessionId.trim()
268-
) {
269-
sessionId = detectedSessionId;
270-
console.log(`Detected Droid session: ${sessionId}`);
255+
// Run Droid Exec with retry for transient failures. Uses the shared
256+
// retryWithBackoff so backoff timing lives in one place (3 total attempts,
257+
// 5s then 10s delays).
258+
let lastExitCode = 1;
259+
260+
const runDroidOnce = (): Promise<number> => {
261+
const droidProcess = spawn(droidExecutable, config.droidArgs, {
262+
stdio: ["ignore", "pipe", "inherit"],
263+
env: {
264+
...process.env,
265+
...config.env,
266+
},
267+
});
268+
269+
// Handle Droid process errors
270+
droidProcess.on("error", (error) => {
271+
console.error("Error spawning Droid process:", error);
272+
});
273+
274+
// Capture output for parsing execution metrics
275+
let sessionId: string | undefined;
276+
droidProcess.stdout.on("data", (data) => {
277+
const text = data.toString();
278+
279+
// Try to parse as JSON and handle based on verbose setting
280+
const lines = text.split("\n");
281+
lines.forEach((line: string, index: number) => {
282+
if (line.trim() === "") return;
283+
284+
try {
285+
// Check if this line is a JSON object
286+
const parsed = JSON.parse(line);
287+
if (!sessionId && typeof parsed === "object" && parsed !== null) {
288+
const detectedSessionId = parsed.session_id;
289+
if (
290+
typeof detectedSessionId === "string" &&
291+
detectedSessionId.trim()
292+
) {
293+
sessionId = detectedSessionId;
294+
console.log(`Detected Droid session: ${sessionId}`);
295+
}
271296
}
272-
}
273-
const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
297+
const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
274298

275-
if (sanitizedOutput) {
276-
process.stdout.write(sanitizedOutput);
277-
if (index < lines.length - 1 || text.endsWith("\n")) {
278-
process.stdout.write("\n");
299+
if (sanitizedOutput) {
300+
process.stdout.write(sanitizedOutput);
301+
if (index < lines.length - 1 || text.endsWith("\n")) {
302+
process.stdout.write("\n");
303+
}
279304
}
280-
}
281-
} catch (e) {
282-
// Not a JSON object
283-
if (showFullOutput) {
284-
// In full output mode, print as is
285-
process.stdout.write(line);
286-
if (index < lines.length - 1 || text.endsWith("\n")) {
287-
process.stdout.write("\n");
305+
} catch (e) {
306+
// Not a JSON object
307+
if (showFullOutput) {
308+
// In full output mode, print as is
309+
process.stdout.write(line);
310+
if (index < lines.length - 1 || text.endsWith("\n")) {
311+
process.stdout.write("\n");
312+
}
288313
}
314+
// In non-full-output mode, suppress non-JSON output
289315
}
290-
// In non-full-output mode, suppress non-JSON output
291-
}
316+
});
292317
});
293-
});
294-
295-
// Handle stdout errors
296-
droidProcess.stdout.on("error", (error) => {
297-
console.error("Error reading Droid stdout:", error);
298-
});
299318

300-
// Wait for Droid Exec to finish
301-
const exitCode = await new Promise<number>((resolve) => {
302-
droidProcess.on("close", (code) => {
303-
resolve(code || 0);
319+
// Handle stdout errors
320+
droidProcess.stdout.on("error", (error) => {
321+
console.error("Error reading Droid stdout:", error);
304322
});
305323

306-
droidProcess.on("error", (error) => {
307-
console.error("Droid process error:", error);
308-
resolve(1);
324+
// Wait for Droid Exec to finish
325+
return new Promise<number>((resolve) => {
326+
droidProcess.on("close", (code) => {
327+
resolve(code || 0);
328+
});
329+
330+
droidProcess.on("error", (error) => {
331+
console.error("Droid process error:", error);
332+
resolve(1);
333+
});
309334
});
310-
});
335+
};
311336

312-
// Set conclusion based on exit code
313-
if (exitCode === 0) {
337+
try {
338+
await retryWithBackoff(
339+
async () => {
340+
lastExitCode = await runDroidOnce();
341+
if (lastExitCode !== 0) {
342+
console.log(`Droid Exec exited with code ${lastExitCode}`);
343+
throw new Error(`Droid Exec exited with code ${lastExitCode}`);
344+
}
345+
},
346+
{ maxAttempts: 3, initialDelayMs: 5000, maxDelayMs: 20000 },
347+
);
314348
core.setOutput("conclusion", "success");
315349
return;
350+
} catch (_) {
351+
// All retry attempts exhausted
352+
console.error(
353+
`Droid Exec failed after 3 total attempts (exit code: ${lastExitCode})`,
354+
);
355+
core.setOutput("conclusion", "failure");
356+
process.exit(lastExitCode);
316357
}
317-
318-
core.setOutput("conclusion", "failure");
319-
process.exit(exitCode);
320358
}

base-action/src/utils/retry.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export type RetryOptions = {
2+
maxAttempts?: number;
3+
initialDelayMs?: number;
4+
maxDelayMs?: number;
5+
backoffFactor?: number;
6+
};
7+
8+
/**
9+
* Retry an async operation with exponential backoff.
10+
* Used for transient failures (Droid Exec, MCP registration, network calls).
11+
*
12+
* Mirrors `src/utils/retry.ts`; base-action is published standalone and cannot
13+
* import from the parent `src/`, so the helper is duplicated here rather than
14+
* shared.
15+
*/
16+
export async function retryWithBackoff<T>(
17+
operation: () => Promise<T>,
18+
options: RetryOptions = {},
19+
): Promise<T> {
20+
const {
21+
maxAttempts = 3,
22+
initialDelayMs = 5000,
23+
maxDelayMs = 20000,
24+
backoffFactor = 2,
25+
} = options;
26+
27+
let delayMs = initialDelayMs;
28+
let lastError: Error | undefined;
29+
30+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
31+
try {
32+
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
33+
return await operation();
34+
} catch (error) {
35+
lastError = error instanceof Error ? error : new Error(String(error));
36+
console.error(`Attempt ${attempt} failed:`, lastError.message);
37+
38+
if (attempt < maxAttempts) {
39+
console.log(`Retrying in ${delayMs / 1000} seconds...`);
40+
await new Promise((resolve) => setTimeout(resolve, delayMs));
41+
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
42+
}
43+
}
44+
}
45+
46+
console.error(`Operation failed after ${maxAttempts} attempts`);
47+
throw lastError;
48+
}

0 commit comments

Comments
 (0)