diff --git a/README.md b/README.md index 8d785ea..c97bbeb 100644 --- a/README.md +++ b/README.md @@ -285,10 +285,44 @@ To leave comments and approvals on your PRs, Droid needs a GitHub token. There a ### Core Inputs -| 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. | +| 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. | +| `prompt` | Custom automation task. When set, Droid Exec runs this prompt directly on automation events (`schedule`, `workflow_dispatch`, `repository_dispatch`, `workflow_run`) and on PR/issue events that carry no `@droid` command and no automatic-review flags. Used by Factory-managed Custom CI automations. | +| `droid_args` | Additional arguments passed to the Droid CLI (e.g. `-m ` to pick the model for a custom `prompt` run). | + +### Custom Automations + +Run an arbitrary task on a schedule (or manual dispatch) by passing a `prompt`: + +```yaml +name: Code Consolidation +on: + workflow_dispatch: {} + schedule: + - cron: "0 16 * * 1-5" +permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: Factory-AI/droid-action@main + with: + factory_api_key: ${{ secrets.FACTORY_API_KEY }} + prompt: | + Find duplicated code across the repository, consolidate it, + and open a pull request with the changes. + droid_args: "-m claude-fable-5" +``` + +Explicit `@droid` commands and the `automatic_review` / `automatic_security_review` flows always take precedence; the `prompt` only runs when no command or review flow claimed the event. Workflows created from the Factory Automations UI (Custom template) generate this shape automatically. ### Review Configuration diff --git a/action.yml b/action.yml index 838724f..2de7ca5 100644 --- a/action.yml +++ b/action.yml @@ -30,6 +30,10 @@ inputs: description: "Droid Exec settings as JSON string or path to settings JSON file" required: false default: "" + prompt: + description: "Custom automation task prompt. When set, Droid Exec runs this prompt directly on automation events (schedule, workflow_dispatch, repository_dispatch, workflow_run) and on entity events that carry no @droid command and no automatic-review flags. Used by Factory-managed Custom CI automations." + required: false + default: "" # Auth configuration factory_api_key: @@ -199,6 +203,7 @@ runs: FILL_MODEL: ${{ inputs.fill_model }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} DROID_ARGS: ${{ inputs.droid_args }} + PROMPT: ${{ inputs.prompt }} INCLUDE_SUGGESTIONS: ${{ inputs.include_suggestions }} ALL_INPUTS: ${{ toJson(inputs) }} diff --git a/src/custom-automation/index.ts b/src/custom-automation/index.ts new file mode 100644 index 0000000..ac53b51 --- /dev/null +++ b/src/custom-automation/index.ts @@ -0,0 +1,86 @@ +import * as core from "@actions/core"; +import { mkdir, writeFile } from "fs/promises"; +import { checkHumanActor } from "../github/validation/actor"; +import { isEntityContext, type GitHubContext } from "../github/context"; +import { normalizeDroidArgs } from "../utils/parse-tools"; +import type { PrepareOptions, PrepareResult } from "../prepare/types"; + +/** + * Custom automation mode: runs the workflow's `prompt` input directly via + * Droid Exec. This is the execution path for Factory-managed Custom CI + * automations, whose task is free text authored in the Factory Automations UI + * rather than an @droid command or a review flow. + * + * Triggers whenever a non-empty `prompt` input is present. Dispatch precedence + * lives in `src/prepare/index.ts`: explicit @droid commands and the + * automatic-review flags always win, so a prompt only runs when no tag/review + * flow claimed the event. + */ +export function shouldTriggerCustomAutomation(context: GitHubContext): boolean { + return context.inputs.prompt.trim().length > 0; +} + +function describeTriggeringEvent(context: GitHubContext): string { + if (!isEntityContext(context)) { + return `a \`${context.eventName}\` event`; + } + const entityKind = context.isPR ? "pull request" : "issue"; + return `a \`${context.eventName}\` event on ${entityKind} #${context.entityNumber}`; +} + +function buildCustomAutomationPrompt(context: GitHubContext): string { + const { repository } = context; + return `You are running as a Factory custom automation inside a GitHub Actions runner. + +Environment: +- Repository: ${repository.full_name} (already checked out in the working directory) +- Triggered by: ${describeTriggeringEvent(context)} +- The \`gh\` CLI is authenticated via the GITHUB_TOKEN environment variable. +- This is a non-interactive environment: never wait for user input, and complete the task in this single run. +- If your task produces code changes, create a new branch and open a pull request with \`gh\`; do not push directly to the default branch. + +Your task: + +${context.inputs.prompt.trim()} +`; +} + +export async function prepareCustomAutomationMode({ + context, + octokit, +}: PrepareOptions): Promise { + // Entity-triggered automations (e.g. `on: pull_request` with a prompt) keep + // the same actor hygiene as the tag flows: bot-authored events only run when + // the bot is explicitly allow-listed. Automation events (schedule / + // workflow_dispatch / ...) have no meaningful actor to validate. + if (isEntityContext(context)) { + await checkHumanActor(octokit.rest, context); + } + + const promptContent = buildCustomAutomationPrompt(context); + + console.log("===== CUSTOM AUTOMATION PROMPT ====="); + console.log(promptContent); + console.log("===================================="); + + const promptDir = `${process.env.RUNNER_TEMP || "/tmp"}/droid-prompts`; + await mkdir(promptDir, { recursive: true }); + await writeFile(`${promptDir}/droid-prompt.txt`, promptContent); + + core.exportVariable("DROID_EXEC_RUN_TYPE", "droid-custom-automation"); + + // Pass user-supplied droid_args through untouched (this is how Factory's + // Custom CI automations carry their model selection, e.g. `-m `). + // No tool restrictions are imposed: the task is arbitrary by design. + const normalizedUserArgs = normalizeDroidArgs(process.env.DROID_ARGS || ""); + core.setOutput("droid_args", normalizedUserArgs); + core.setOutput("mcp_tools", ""); + + return { + branchInfo: { + baseBranch: "", + currentBranch: "", + }, + mcpTools: "", + }; +} diff --git a/src/entrypoints/collect-inputs.ts b/src/entrypoints/collect-inputs.ts index 442da29..b095953 100644 --- a/src/entrypoints/collect-inputs.ts +++ b/src/entrypoints/collect-inputs.ts @@ -21,6 +21,7 @@ export function collectActionInputsPresence(): void { factory_api_key: "", github_token: "", droid_args: "", + prompt: "", max_turns: "", use_sticky_comment: "false", experimental_allowed_domains: "", diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index 5763f35..80b9a05 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -11,6 +11,7 @@ import { checkWritePermissions } from "../github/validation/permissions"; import { createOctokit } from "../github/api/client"; import { parseGitHubContext, isEntityContext } from "../github/context"; import { shouldTriggerTag } from "../tag"; +import { shouldTriggerCustomAutomation } from "../custom-automation"; import { prepare } from "../prepare"; import { collectActionInputsPresence } from "./collect-inputs"; @@ -42,8 +43,10 @@ async function run() { } } - // Check trigger conditions - const containsTrigger = shouldTriggerTag(context); + // Check trigger conditions: @droid tag / automatic review flows, or a + // custom automation `prompt` input (Factory Custom CI automations). + const containsTrigger = + shouldTriggerTag(context) || shouldTriggerCustomAutomation(context); console.log(`Trigger result: ${containsTrigger}`); diff --git a/src/github/context.ts b/src/github/context.ts index ee0e888..35298b7 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -98,6 +98,12 @@ type BaseContext = { securityNotifyTeam: string; securityScanSchedule: boolean; securityScanDays: number; + /** + * Custom automation task prompt (the `prompt` action input). When set, + * Droid Exec runs it directly instead of the tag/review flows for events + * that carry no explicit @droid command. + */ + prompt: string; }; }; @@ -161,6 +167,7 @@ export function parseGitHubContext(): GitHubContext { 1, parseInt(process.env.SECURITY_SCAN_DAYS ?? "7", 10) || 7, ), + prompt: process.env.PROMPT ?? "", }, }; diff --git a/src/github/utils/command-parser.test.ts b/src/github/utils/command-parser.test.ts index 8d30c0f..abba6e8 100644 --- a/src/github/utils/command-parser.test.ts +++ b/src/github/utils/command-parser.test.ts @@ -35,6 +35,7 @@ const baseContext: Omit = { securityNotifyTeam: "", securityScanSchedule: false, securityScanDays: 7, + prompt: "", }, entityNumber: 1, isPR: true, diff --git a/src/prepare/index.ts b/src/prepare/index.ts index 069adbf..152e1c4 100644 --- a/src/prepare/index.ts +++ b/src/prepare/index.ts @@ -3,12 +3,32 @@ */ import type { PrepareOptions, PrepareResult } from "./types"; -import { prepareTagExecution } from "../tag"; +import { shouldTriggerTag, prepareTagExecution } from "../tag"; +import { + shouldTriggerCustomAutomation, + prepareCustomAutomationMode, +} from "../custom-automation"; export async function prepare(options: PrepareOptions): Promise { const { context } = options; - console.log(`Preparing tag execution for event: ${context.eventName}`); + // Explicit @droid commands and the automatic-review flags always win; the + // custom automation prompt only runs when no tag/review flow claimed the + // event (schedule / workflow_dispatch runs, or entity events with no + // command). + if (shouldTriggerTag(context)) { + console.log(`Preparing tag execution for event: ${context.eventName}`); + return prepareTagExecution(options); + } - return prepareTagExecution(options); + if (shouldTriggerCustomAutomation(context)) { + console.log( + `Preparing custom automation execution for event: ${context.eventName}`, + ); + return prepareCustomAutomationMode(options); + } + + throw new Error( + `No execution mode matched event: ${context.eventName}. This indicates a trigger-detection bug in the prepare entrypoint.`, + ); } diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index f4176ff..1b5b100 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -34,6 +34,7 @@ describe("prepareContext", () => { securityNotifyTeam: "", securityScanSchedule: false, securityScanDays: 7, + prompt: "", }, } as const; diff --git a/test/create-prompt/templates/security-report-prompt.test.ts b/test/create-prompt/templates/security-report-prompt.test.ts index f2698d3..0ffdaa0 100644 --- a/test/create-prompt/templates/security-report-prompt.test.ts +++ b/test/create-prompt/templates/security-report-prompt.test.ts @@ -45,6 +45,7 @@ describe("generateSecurityReportPrompt", () => { securityNotifyTeam: "@org/security-team", securityScanSchedule: false, securityScanDays: 7, + prompt: "", }, }, }; diff --git a/test/custom-automation.test.ts b/test/custom-automation.test.ts new file mode 100644 index 0000000..567b01f --- /dev/null +++ b/test/custom-automation.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { PullRequestEvent } from "@octokit/webhooks-types"; +import { + shouldTriggerCustomAutomation, + prepareCustomAutomationMode, +} from "../src/custom-automation"; +import { shouldTriggerTag } from "../src/tag"; +import { createMockContext, createMockAutomationContext } from "./mockContext"; +import type { Octokits } from "../src/github/api/client"; + +const PROMPT = "Find duplicated code and open a PR consolidating it."; + +// Minimal PR payload so checkContainsTrigger can inspect body/title. +const PR_PAYLOAD = { + pull_request: { body: "A regular PR body", title: "A regular PR title" }, +} as PullRequestEvent; + +describe("shouldTriggerCustomAutomation", () => { + it("triggers on automation events when a prompt is set", () => { + const context = createMockAutomationContext({ + eventName: "schedule", + inputs: { prompt: PROMPT }, + }); + expect(shouldTriggerCustomAutomation(context)).toBe(true); + }); + + it("triggers on workflow_dispatch when a prompt is set", () => { + const context = createMockAutomationContext({ + eventName: "workflow_dispatch", + inputs: { prompt: PROMPT }, + }); + expect(shouldTriggerCustomAutomation(context)).toBe(true); + }); + + it("does not trigger when the prompt is empty or whitespace", () => { + expect( + shouldTriggerCustomAutomation( + createMockAutomationContext({ eventName: "schedule" }), + ), + ).toBe(false); + expect( + shouldTriggerCustomAutomation( + createMockAutomationContext({ + eventName: "schedule", + inputs: { prompt: " " }, + }), + ), + ).toBe(false); + }); + + it("triggers on entity events with a prompt and no @droid command", () => { + const context = createMockContext({ + eventName: "pull_request", + isPR: true, + payload: PR_PAYLOAD, + inputs: { prompt: PROMPT }, + }); + expect(shouldTriggerCustomAutomation(context)).toBe(true); + // The tag flow must not claim this event, so dispatch precedence sends it + // to the custom automation mode. + expect(shouldTriggerTag(context)).toBe(false); + }); + + it("cedes precedence to the automatic review flow on PR events", () => { + const context = createMockContext({ + eventName: "pull_request", + isPR: true, + inputs: { prompt: PROMPT, automaticReview: true }, + }); + // Both would trigger, but prepare() dispatches shouldTriggerTag first. + expect(shouldTriggerTag(context)).toBe(true); + expect(shouldTriggerCustomAutomation(context)).toBe(true); + }); +}); + +describe("prepareCustomAutomationMode", () => { + let tempDir: string; + const originalRunnerTemp = process.env.RUNNER_TEMP; + const originalDroidArgs = process.env.DROID_ARGS; + const originalGithubOutput = process.env.GITHUB_OUTPUT; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "custom-automation-test-")); + process.env.RUNNER_TEMP = tempDir; + // Route @actions/core setOutput/exportVariable writes into temp files so + // the test never touches a real Actions environment. @actions/core + // requires the command files to already exist. + process.env.GITHUB_OUTPUT = join(tempDir, "github-output"); + process.env.GITHUB_ENV = join(tempDir, "github-env"); + writeFileSync(process.env.GITHUB_OUTPUT, ""); + writeFileSync(process.env.GITHUB_ENV, ""); + delete process.env.DROID_ARGS; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + process.env.RUNNER_TEMP = originalRunnerTemp; + process.env.DROID_ARGS = originalDroidArgs; + process.env.GITHUB_OUTPUT = originalGithubOutput; + }); + + it("writes the prompt file with the user task and environment preamble", async () => { + const context = createMockAutomationContext({ + eventName: "schedule", + inputs: { prompt: PROMPT }, + }); + + const result = await prepareCustomAutomationMode({ + context, + octokit: {} as Octokits, + githubToken: "token", + }); + + const promptFile = readFileSync( + join(tempDir, "droid-prompts", "droid-prompt.txt"), + "utf8", + ); + expect(promptFile).toContain(PROMPT); + expect(promptFile).toContain("test-owner/test-repo"); + expect(promptFile).toContain("`schedule` event"); + expect(promptFile).toContain("open a pull request"); + expect(result.mcpTools).toBe(""); + }); + + it("passes user droid_args through to the exec step", async () => { + process.env.DROID_ARGS = "-m claude-fable-5"; + const context = createMockAutomationContext({ + eventName: "workflow_dispatch", + inputs: { prompt: PROMPT }, + }); + + await prepareCustomAutomationMode({ + context, + octokit: {} as Octokits, + githubToken: "token", + }); + + const output = readFileSync(join(tempDir, "github-output"), "utf8"); + expect(output).toContain("droid_args"); + expect(output).toContain("-m claude-fable-5"); + }); + + it("describes the triggering entity for entity events", async () => { + const octokit = { + rest: { + users: { + getByUsername: async () => ({ data: { type: "User" } }), + }, + }, + } as unknown as Octokits; + const context = createMockContext({ + eventName: "pull_request", + isPR: true, + entityNumber: 42, + inputs: { prompt: PROMPT }, + }); + + await prepareCustomAutomationMode({ + context, + octokit, + githubToken: "token", + }); + + const promptFile = readFileSync( + join(tempDir, "droid-prompts", "droid-prompt.txt"), + "utf8", + ); + expect(promptFile).toContain("pull request #42"); + }); + + it("rejects bot actors on entity events unless allow-listed", async () => { + const octokit = { + rest: { + users: { + getByUsername: async () => ({ data: { type: "Bot" } }), + }, + }, + } as unknown as Octokits; + const context = createMockContext({ + eventName: "pull_request", + isPR: true, + actor: "some-bot", + inputs: { prompt: PROMPT }, + }); + + await expect( + prepareCustomAutomationMode({ + context, + octokit, + githubToken: "token", + }), + ).rejects.toThrow(); + }); +}); diff --git a/test/mockContext.ts b/test/mockContext.ts index 4c197a7..885ba15 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -27,6 +27,7 @@ const defaultInputs = { securityNotifyTeam: "", securityScanSchedule: false, securityScanDays: 7, + prompt: "", }; const defaultRepository = { diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 4db439f..ed471dd 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -76,6 +76,7 @@ describe("checkWritePermissions", () => { securityNotifyTeam: "", securityScanSchedule: false, securityScanDays: 7, + prompt: "", }, });