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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,15 @@ jobs:
| `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. |

### Debug Artifacts

| Input | Default | Purpose |
| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- |
| `debug_artifacts` | `none` | Debug artifact upload mode. Set to `redacted` to upload a sanitized, allowlisted Droid debug bundle. |
| `debug_artifact_retention_days` | `1` | Retention days for sanitized debug artifacts. |

Raw Droid runtime state is not uploaded. In `redacted` mode, the action builds a new allowlisted bundle under the runner temp directory and applies best-effort redaction before upload.

### Review Configuration

| Input | Default | Purpose |
Expand Down
43 changes: 29 additions & 14 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ inputs:
description: "Show full JSON output from Droid Exec. WARNING: This outputs ALL Droid messages including tool execution results which may contain secrets, API keys, or other sensitive information. These logs are publicly visible in GitHub Actions. Only enable for debugging in non-sensitive environments."
required: false
default: "false"
debug_artifacts:
description: "Debug artifact upload mode: none or redacted. Raw runtime state is not uploaded."
required: false
default: "none"
debug_artifact_retention_days:
description: "Retention days for sanitized debug artifacts."
required: false
default: "1"

outputs:
github_token:
Expand Down Expand Up @@ -165,6 +173,14 @@ runs:
cd ${GITHUB_ACTION_PATH}
bun install

- name: Validate debug artifact mode
shell: bash
run: |
case "${{ inputs.debug_artifacts }}" in
none|redacted) ;;
*) echo "debug_artifacts must be one of: none, redacted" >&2; exit 1 ;;
esac

- name: Prepare action
id: prepare
shell: bash
Expand Down Expand Up @@ -354,22 +370,21 @@ runs:
AUTOMATIC_REVIEW: ${{ inputs.automatic_review }}
AUTOMATIC_SECURITY_REVIEW: ${{ inputs.automatic_security_review }}

- name: Collect .factory debug files
if: always() && steps.prepare.outputs.contains_trigger == 'true'
- name: Prepare redacted debug artifacts
if: always() && steps.prepare.outputs.contains_trigger == 'true' && inputs.debug_artifacts == 'redacted'
shell: bash
run: |
if [ -d "$HOME/.factory" ]; then
cp -r "$HOME/.factory" "${{ runner.temp }}/.factory"
fi
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare-debug-artifacts.ts
env:
DEBUG_ARTIFACTS_MODE: ${{ inputs.debug_artifacts }}
DEBUG_ARTIFACTS_DIR: ${{ runner.temp }}/droid-debug-artifacts
DROID_PROMPTS_DIR: ${{ runner.temp }}/droid-prompts

- name: Upload debug artifacts
if: always() && steps.prepare.outputs.contains_trigger == 'true'
- name: Upload redacted debug artifacts
if: always() && steps.prepare.outputs.contains_trigger == 'true' && inputs.debug_artifacts == 'redacted'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: droid-review-debug-${{ github.run_id }}
path: |
${{ runner.temp }}/.factory/**
${{ runner.temp }}/droid-prompts/**
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
name: droid-debug-${{ github.run_id }}
path: ${{ runner.temp }}/droid-debug-artifacts/**
if-no-files-found: error
retention-days: ${{ inputs.debug_artifact_retention_days }}
219 changes: 219 additions & 0 deletions src/debug-artifacts/collect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import {
mkdir,
lstat,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { redactFileText } from "./redact";

export type DebugArtifactMode = "none" | "redacted";

export interface PrepareDebugArtifactsOptions {
mode: DebugArtifactMode;
outputDir: string;
factoryHome: string;
droidPromptsDir: string;
}

export interface PreparedDebugArtifacts {
prepared: boolean;
files: string[];
}

interface AllowlistedFile {
sourcePath: string;
outputPath: string;
}

const PROMPT_FILES = [
"droid-prompt.txt",
"pr.diff",
"existing_comments.json",
"pr_description.txt",
"review_candidates.json",
"review_validated.json",
];

const FACTORY_ROOT_FILES = ["settings.json", "settings.local.json", "mcp.json"];
const MAX_RECURSIVE_FILES = 200;
const MAX_RECURSIVE_DEPTH = 5;

function isEnoent(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT",
);
}

async function isDirectory(path: string): Promise<boolean> {
try {
const info = await lstat(path);
return info.isDirectory();
} catch (error) {
if (isEnoent(error)) {
return false;
}

throw error;
}
}

async function isRegularFile(path: string): Promise<boolean> {
try {
const info = await lstat(path);
return info.isFile();
} catch (error) {
if (isEnoent(error)) {
return false;
}

throw error;
}
}

async function listFilesRecursive(
dir: string,
files: string[] = [],
depth = 0,
): Promise<string[]> {
if (!(await isDirectory(dir))) return [];
if (depth > MAX_RECURSIVE_DEPTH || files.length >= MAX_RECURSIVE_FILES) {
return files;
}

const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;

const entryPath = join(dir, entry.name);
if (entry.isFile()) {
files.push(entryPath);
if (files.length >= MAX_RECURSIVE_FILES) break;
continue;
}

if (entry.isDirectory()) {
await listFilesRecursive(entryPath, files, depth + 1);
}
}

return files;
}

function outputWithRedactedSuffix(
outputDir: string,
...parts: string[]
): string {
const leaf = parts.at(-1);
if (!leaf) throw new Error("Cannot prepare output path without a filename");

return join(outputDir, ...parts.slice(0, -1), `${leaf}.redacted`);
}

function relativePathParts(root: string, path: string): string[] {
return relative(root, path).split(/[\\/]/).filter(Boolean);
}

async function buildAllowlist(
options: PrepareDebugArtifactsOptions,
): Promise<AllowlistedFile[]> {
const promptFiles = PROMPT_FILES.map((file) => ({
sourcePath: join(options.droidPromptsDir, file),
outputPath: outputWithRedactedSuffix(options.outputDir, "prompts", file),
}));

const factoryRootFiles = FACTORY_ROOT_FILES.map((file) => ({
sourcePath: join(options.factoryHome, file),
outputPath: outputWithRedactedSuffix(options.outputDir, "factory", file),
}));

const factoryDroidFiles = [
{
sourcePath: join(options.factoryHome, "droid", "settings.json"),
outputPath: outputWithRedactedSuffix(
options.outputDir,
"factory",
"droid",
"settings.json",
),
},
];

const logsRoot = join(options.factoryHome, "logs");
const logFiles = (await listFilesRecursive(logsRoot)).map((file) => ({
sourcePath: file,
outputPath: outputWithRedactedSuffix(
options.outputDir,
"factory",
"logs",
...relativePathParts(logsRoot, file),
),
}));

const sessionsRoot = join(options.factoryHome, "sessions");
const sessionFiles = (await listFilesRecursive(sessionsRoot)).map((file) => ({
sourcePath: file,
outputPath: outputWithRedactedSuffix(
options.outputDir,
"factory",
"sessions",
...relativePathParts(sessionsRoot, file),
),
}));

return [
...promptFiles,
...factoryRootFiles,
...factoryDroidFiles,
...logFiles,
...sessionFiles,
];
}

export async function prepareDebugArtifacts(
options: PrepareDebugArtifactsOptions,
): Promise<PreparedDebugArtifacts> {
if (options.mode === "none") {
return { prepared: false, files: [] };
}

await rm(options.outputDir, { recursive: true, force: true });
await mkdir(options.outputDir, { recursive: true });

const allowlist = await buildAllowlist(options);
const files: string[] = [];

for (const file of allowlist) {
if (!(await isRegularFile(file.sourcePath))) continue;

const input = await readFile(file.sourcePath, "utf8");
const redacted = redactFileText(file.sourcePath, input);

await mkdir(dirname(file.outputPath), { recursive: true });
await writeFile(file.outputPath, redacted);
files.push(file.outputPath);
}

const manifestPath = join(options.outputDir, "manifest.json");
await writeFile(
manifestPath,
`${JSON.stringify(
{
mode: "redacted",
files: files
.map((file) => relative(options.outputDir, file).replace(/\\/g, "/"))
.sort(),
},
null,
2,
)}\n`,
);
files.push(manifestPath);

return { prepared: true, files };
}
Loading