Skip to content

Commit b5d731e

Browse files
feat(gitlab): add @droid fill mode (native parity, no webhook receiver)
Adds GitLab support for `@droid fill` via the three native pipeline- firing surfaces that don't require a webhook receiver: MR description, MR title, and labels. Plus an `automatic_fill` always-on mode. How the trigger works: * `automatic_fill: "true"` -> droid-fill runs on every MR event and decides to fill based on description content. * `@droid fill` in MR title -> matched at rule level via $CI_MERGE_REQUEST_TITLE. * `droid:fill` label -> matched at rule level via $CI_MERGE_REQUEST_LABELS. * `@droid fill` in MR description -> not matchable at rule level (description isn't in env), so the job runs and exits early via state file if no match found in title/labels and the description fetched from the API doesn't contain the phrase. After fill completes the prompt instructs the model to strip the `@droid fill` token from the new description so the next `merge_request_event` (fired by our own update) does not re-fire fill. Discussion-comment triggers (`@droid fill` posted as a note on the MR) still require a webhook receiver because GitLab does not fire CI on note events. That subset is deliberately deferred. Files: * `gitlab/templates/fill.yml` — new GitLab CI/CD Component with one `droid-fill` job, three trigger rules, MCP registration, `.droid-debug/` artifact staging. * `src/gitlab/validation/trigger.ts` — port of GitHub's `checkContainsTrigger` for the fill path with `checkContainsFillTrigger` + `stripFillTrigger`. * `src/gitlab/prompts/fill.ts` — fill prompt mirroring the GitHub `fill-prompt.ts` but writing back via `gitlab_mr___update_mr_description` instead of `github_pr___update_pr_description`. * `src/entrypoints/gitlab-fill-prepare.ts` — single-pass prepare that reads the MR description via API, checks the trigger, and writes the fill prompt + state file. * `src/gitlab/context.ts` — adds `automaticFill` input and `mr.labels` parsed from `CI_MERGE_REQUEST_LABELS`. Tests: 24 new tests across `fill-trigger.test.ts` and `fill-prompt.test.ts`. 469 pass, typecheck clean. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent f1252df commit b5d731e

8 files changed

Lines changed: 705 additions & 0 deletions

File tree

gitlab/templates/fill.yml

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
spec:
2+
inputs:
3+
automatic_fill:
4+
description: |
5+
Run `@droid fill` on every MR event, deciding whether to fill
6+
based on the description content. If `false` (default), fill
7+
only runs when an explicit `@droid fill` trigger phrase is
8+
found in the MR title, description, or `droid:fill` label.
9+
default: "false"
10+
trigger_phrase:
11+
description: |
12+
Phrase to look for to invoke droid (e.g. `@droid fill`).
13+
Mirrors the GitHub action's `trigger_phrase` input.
14+
default: "@droid"
15+
fill_model:
16+
description: |
17+
Override the model used for `@droid fill`. Empty = use the
18+
Droid CLI default.
19+
default: ""
20+
settings:
21+
description: |
22+
Droid Exec settings as a JSON string or a path to a JSON file.
23+
Merged into ~/.factory/droid/settings.json on the runner before
24+
`droid exec` runs. Mirrors the GitHub action's `settings` input.
25+
default: ""
26+
droid_action_repo:
27+
description: |
28+
Git repo (clone URL) of droid-action. Override if you mirror it
29+
privately. Must be reachable from your GitLab runner.
30+
default: "https://github.com/Factory-AI/droid-action.git"
31+
droid_action_ref:
32+
description: |
33+
Git ref (tag/branch/sha) of droid-action to clone at runtime.
34+
The same ref SHOULD match the `include: remote:` URL above.
35+
default: "dev"
36+
stage:
37+
description: "GitLab CI stage to assign the droid-fill job to."
38+
default: "test"
39+
---
40+
stages:
41+
- $[[ inputs.stage ]]
42+
43+
droid-fill:
44+
stage: $[[ inputs.stage ]]
45+
image: oven/bun:1.2.11
46+
rules:
47+
# Always-on mode: fire on every MR event and decide via description content.
48+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $AUTOMATIC_FILL == "true"'
49+
# Explicit trigger in MR title.
50+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE =~ /@droid\s+fill/i'
51+
# Explicit trigger via label.
52+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_LABELS =~ /(^|,)\s*droid:fill\s*(,|$)/i'
53+
variables:
54+
DROID_STATE_FILE: "$CI_PROJECT_DIR/.droid-state.json"
55+
DROID_PROMPT_FILE: "/tmp/droid-prompts/droid-prompt.txt"
56+
DROID_ACTION_DIR: "/tmp/droid-action"
57+
DROID_ACTION_REPO: "$[[ inputs.droid_action_repo ]]"
58+
DROID_ACTION_REF: "$[[ inputs.droid_action_ref ]]"
59+
AUTOMATIC_FILL: "$[[ inputs.automatic_fill ]]"
60+
TRIGGER_PHRASE: "$[[ inputs.trigger_phrase ]]"
61+
FILL_MODEL: "$[[ inputs.fill_model ]]"
62+
DROID_SETTINGS: "$[[ inputs.settings ]]"
63+
DROID_SUCCESS: "true"
64+
before_script:
65+
- apt-get update -qq
66+
- apt-get install -y -qq --no-install-recommends git ca-certificates curl
67+
- git clone --depth 1 --branch "$DROID_ACTION_REF" "$DROID_ACTION_REPO" "$DROID_ACTION_DIR"
68+
- cd "$DROID_ACTION_DIR" && bun install --frozen-lockfile
69+
- cd "$CI_PROJECT_DIR"
70+
- curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://app.factory.ai/cli | sh
71+
- export PATH="$HOME/.local/bin:$PATH"
72+
- droid --version
73+
- mkdir -p ~/.factory/droids
74+
- |
75+
if [ -d "$DROID_ACTION_DIR/.factory/droids" ]; then
76+
cp -r "$DROID_ACTION_DIR/.factory/droids/." ~/.factory/droids/
77+
echo "Copied custom droids from $DROID_ACTION_DIR/.factory/droids to ~/.factory/droids"
78+
fi
79+
script:
80+
- export PATH="$HOME/.local/bin:$PATH"
81+
- |
82+
if ! bun run "$DROID_ACTION_DIR/src/entrypoints/gitlab-fill-prepare.ts"; then
83+
export DROID_SUCCESS=false
84+
echo "gitlab-fill-prepare exited non-zero" > /tmp/droid-error.txt
85+
exit 1
86+
fi
87+
- |
88+
if ! grep -q '"shouldRunFill": true' "$DROID_STATE_FILE"; then
89+
echo "shouldRunFill=false in state; skipping droid exec."
90+
exit 0
91+
fi
92+
- |
93+
if [ -f /tmp/droid-prompts/resolved-env.sh ]; then
94+
# shellcheck disable=SC1091
95+
. /tmp/droid-prompts/resolved-env.sh
96+
fi
97+
- |
98+
droid mcp remove gitlab_mr 2>/dev/null || true
99+
droid mcp add gitlab_mr "bun run $DROID_ACTION_DIR/src/mcp/gitlab-mr-server.ts" \
100+
--env CI_PROJECT_ID="$CI_PROJECT_ID" \
101+
--env CI_API_V4_URL="$CI_API_V4_URL" \
102+
--env GITLAB_TOKEN="$GITLAB_TOKEN" \
103+
--env DROID_MR_IID="$DROID_MR_IID"
104+
- |
105+
set -o pipefail
106+
FILL_TOOLS="Read,Grep,Glob,LS,Execute,Skill,gitlab_mr___update_mr_description"
107+
FILL_LOG="/tmp/droid-prompts/fill-output.jsonl"
108+
DROID_ARGS="exec -f $DROID_PROMPT_FILE --enabled-tools $FILL_TOOLS --output-format stream-json --skip-permissions-unsafe --tag droid-fill"
109+
if [ -n "$FILL_MODEL" ]; then DROID_ARGS="$DROID_ARGS --model $FILL_MODEL"; fi
110+
echo "Running fill: droid $DROID_ARGS"
111+
if ! droid $DROID_ARGS 2>&1 | tee "$FILL_LOG"; then
112+
export DROID_SUCCESS=false
113+
echo "droid exec fill failed" > /tmp/droid-error.txt
114+
exit 1
115+
fi
116+
after_script:
117+
- mkdir -p "$CI_PROJECT_DIR/.droid-debug/prompts" || true
118+
- cp -r /tmp/droid-prompts/. "$CI_PROJECT_DIR/.droid-debug/prompts/" 2>/dev/null || true
119+
- cp /tmp/droid-error.txt "$CI_PROJECT_DIR/.droid-debug/" 2>/dev/null || true
120+
artifacts:
121+
when: always
122+
paths:
123+
- .droid-state.json
124+
- .droid-debug/
125+
expire_in: 1 week
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Prepare step for the GitLab `@droid fill` mode.
5+
*
6+
* Responsibilities:
7+
* 1. Parse GitLab CI env into a normalized context.
8+
* 2. Decide whether this pipeline should run fill, based on whether
9+
* `@droid fill` appears in the MR title, description, or labels
10+
* (or `automatic_fill` is enabled).
11+
* 3. Fetch the MR via API (description is not exposed as an env var)
12+
* so we can match the trigger phrase against it.
13+
* 4. Write the fill prompt + a state file consumed by the CI script.
14+
*
15+
* Unlike the review flow this is a single-pass job: the fill prompt
16+
* itself calls `gitlab_mr___update_mr_description` to write back the
17+
* new description.
18+
*/
19+
20+
import * as fs from "fs/promises";
21+
import * as path from "path";
22+
import { parseGitlabContext, isMergeRequestContext } from "../gitlab/context";
23+
import { setupGitlabToken, MissingGitlabTokenError } from "../gitlab/token";
24+
import { GitlabClient } from "../gitlab/api/client";
25+
import { computeReviewArtifacts } from "../gitlab/data/review-artifacts";
26+
import { checkContainsFillTrigger } from "../gitlab/validation/trigger";
27+
import { generateGitlabFillPrompt } from "../gitlab/prompts/fill";
28+
import { setupDroidSettings } from "../../base-action/src/setup-droid-settings";
29+
30+
export type FillPrepareState = {
31+
shouldRunFill: boolean;
32+
projectId: string;
33+
projectPath: string;
34+
mrIid: number | null;
35+
pipelineUrl: string | null;
36+
jobUrl: string | null;
37+
promptPath: string | null;
38+
fillModel: string | null;
39+
descriptionPath: string | null;
40+
diffPath: string | null;
41+
triggerSource: string;
42+
reason?: string;
43+
};
44+
45+
function promptFilePath(): string {
46+
return process.env.DROID_PROMPT_FILE || "/tmp/droid-prompts/droid-prompt.txt";
47+
}
48+
49+
function promptsDir(): string {
50+
return path.dirname(promptFilePath());
51+
}
52+
53+
function stateFilePath(): string {
54+
return (
55+
process.env.DROID_STATE_FILE ||
56+
path.join(process.env.CI_PROJECT_DIR || "/tmp", ".droid-state.json")
57+
);
58+
}
59+
60+
function resolvedEnvShimPath(): string {
61+
return (
62+
process.env.DROID_RESOLVED_ENV_FILE || "/tmp/droid-prompts/resolved-env.sh"
63+
);
64+
}
65+
66+
function shellEscape(value: string): string {
67+
return `'${value.replace(/'/g, `'\\''`)}'`;
68+
}
69+
70+
async function writeResolvedEnvShim(
71+
extras: Record<string, string | null>,
72+
): Promise<void> {
73+
const filePath = resolvedEnvShimPath();
74+
await fs.mkdir(path.dirname(filePath), { recursive: true });
75+
const lines = [
76+
"# Generated by gitlab-fill-prepare; source this to expose resolved env.",
77+
];
78+
for (const [k, v] of Object.entries(extras)) {
79+
lines.push(`export ${k}=${shellEscape(v ?? "")}`);
80+
}
81+
await fs.writeFile(filePath, lines.join("\n") + "\n");
82+
console.log(`Wrote resolved env shim to ${filePath}`);
83+
}
84+
85+
async function writeState(state: FillPrepareState): Promise<void> {
86+
const filePath = stateFilePath();
87+
await fs.mkdir(path.dirname(filePath), { recursive: true });
88+
await fs.writeFile(filePath, JSON.stringify(state, null, 2));
89+
console.log(`Wrote droid fill state to ${filePath}`);
90+
}
91+
92+
async function run(): Promise<void> {
93+
const context = parseGitlabContext();
94+
95+
const baseState: FillPrepareState = {
96+
shouldRunFill: false,
97+
projectId: context.project.id,
98+
projectPath: context.project.pathWithNamespace,
99+
mrIid: context.mr?.iid ?? null,
100+
pipelineUrl: context.pipelineUrl,
101+
jobUrl: context.jobUrl,
102+
promptPath: null,
103+
fillModel: context.inputs.fillModel || null,
104+
descriptionPath: null,
105+
diffPath: null,
106+
triggerSource: "none",
107+
};
108+
109+
if (!isMergeRequestContext(context)) {
110+
console.log(
111+
"Not a merge_request_event pipeline; skipping droid fill prepare.",
112+
);
113+
await writeState({ ...baseState, reason: "not-merge-request-event" });
114+
return;
115+
}
116+
117+
let token: string;
118+
try {
119+
token = setupGitlabToken();
120+
} catch (err) {
121+
if (err instanceof MissingGitlabTokenError) {
122+
console.error(err.message);
123+
}
124+
throw err;
125+
}
126+
127+
try {
128+
await setupDroidSettings(context.inputs.settings || undefined);
129+
} catch (err) {
130+
console.warn(
131+
"Failed to setup droid settings; continuing with defaults:",
132+
err,
133+
);
134+
}
135+
136+
const client = new GitlabClient(token, context.apiUrl);
137+
const mrIid = context.mr.iid;
138+
const projectId = context.project.id;
139+
140+
// Description is not exposed as an env var; fetch via API.
141+
const mr = await client.getMr(projectId, mrIid);
142+
const description = mr.description ?? "";
143+
const title = mr.title ?? context.mr.title ?? "";
144+
const labels = context.mr.labels;
145+
146+
const trigger = checkContainsFillTrigger({
147+
description,
148+
title,
149+
labels,
150+
triggerPhrase: context.inputs.triggerPhrase,
151+
automaticFill: context.inputs.automaticFill,
152+
});
153+
154+
console.log(
155+
`Fill trigger check: matched=${trigger.matched} source=${trigger.source} ` +
156+
`triggerPhrase=${JSON.stringify(context.inputs.triggerPhrase)} ` +
157+
`labels=${JSON.stringify(labels)}`,
158+
);
159+
160+
if (!trigger.matched) {
161+
console.log(
162+
"No fill trigger found in description/title/labels and automatic_fill is off; skipping droid exec.",
163+
);
164+
await writeState({
165+
...baseState,
166+
reason: "no-fill-trigger",
167+
triggerSource: trigger.source,
168+
});
169+
return;
170+
}
171+
172+
// Reuse the review-artifacts helper for diff + description; we ignore
173+
// existing_comments.json for fill mode (the prompt does not need them).
174+
console.log("Computing fill artifacts (diff, description)...");
175+
const artifacts = await computeReviewArtifacts({
176+
client,
177+
projectId,
178+
mrIid,
179+
outDir: promptsDir(),
180+
});
181+
console.log(
182+
`Artifacts written:\n ${artifacts.diffPath}\n ${artifacts.descriptionPath}`,
183+
);
184+
185+
await writeResolvedEnvShim({
186+
DROID_MR_IID: String(mrIid),
187+
FILL_MODEL: context.inputs.fillModel || "",
188+
});
189+
190+
const promptCtx = {
191+
projectPath: context.project.pathWithNamespace,
192+
mrIid,
193+
mrTitle: title,
194+
sourceBranch: artifacts.mr.source_branch ?? "",
195+
targetBranch: artifacts.mr.target_branch ?? "",
196+
triggerSource: trigger.source,
197+
triggerPhrase: context.inputs.triggerPhrase,
198+
descriptionPath: artifacts.descriptionPath,
199+
diffPath: artifacts.diffPath,
200+
};
201+
202+
const prompt = generateGitlabFillPrompt(promptCtx);
203+
const promptPath = promptFilePath();
204+
await fs.mkdir(path.dirname(promptPath), { recursive: true });
205+
await fs.writeFile(promptPath, prompt);
206+
console.log(`Wrote fill prompt (${prompt.length} bytes) to ${promptPath}`);
207+
208+
await writeState({
209+
...baseState,
210+
shouldRunFill: true,
211+
promptPath,
212+
descriptionPath: artifacts.descriptionPath,
213+
diffPath: artifacts.diffPath,
214+
triggerSource: trigger.source,
215+
});
216+
}
217+
218+
if (import.meta.main) {
219+
run().catch((error) => {
220+
console.error("gitlab-fill-prepare failed:", error);
221+
process.exit(1);
222+
});
223+
}
224+
225+
export { run };

src/gitlab/context.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type ParsedGitlabContext = {
1818
targetBranchSha: string | null;
1919
diffBaseSha: string | null;
2020
title: string | null;
21+
labels: string[];
2122
} | null;
2223
commit: {
2324
sha: string;
@@ -32,6 +33,7 @@ export type ParsedGitlabContext = {
3233
inputs: {
3334
automaticReview: boolean;
3435
automaticSecurityReview: boolean;
36+
automaticFill: boolean;
3537
triggerPhrase: string;
3638
reviewDepth: string;
3739
reviewModel: string;
@@ -81,13 +83,19 @@ export function parseGitlabContext(): ParsedGitlabContext {
8183
process.env.CI_PROJECT_URL || `${serverUrl}/${projectPath}`;
8284

8385
const mrIid = optional("CI_MERGE_REQUEST_IID");
86+
const labelsRaw = optional("CI_MERGE_REQUEST_LABELS") ?? "";
87+
const labels = labelsRaw
88+
.split(",")
89+
.map((l) => l.trim())
90+
.filter((l) => l.length > 0);
8491
const mr = mrIid
8592
? {
8693
iid: parseInt(mrIid, 10),
8794
sourceBranchSha: optional("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA"),
8895
targetBranchSha: optional("CI_MERGE_REQUEST_TARGET_BRANCH_SHA"),
8996
diffBaseSha: optional("CI_MERGE_REQUEST_DIFF_BASE_SHA"),
9097
title: optional("CI_MERGE_REQUEST_TITLE"),
98+
labels,
9199
}
92100
: null;
93101

@@ -119,6 +127,7 @@ export function parseGitlabContext(): ParsedGitlabContext {
119127
inputs: {
120128
automaticReview: process.env.AUTOMATIC_REVIEW === "true",
121129
automaticSecurityReview: process.env.AUTOMATIC_SECURITY_REVIEW === "true",
130+
automaticFill: process.env.AUTOMATIC_FILL === "true",
122131
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@droid",
123132
reviewDepth: process.env.REVIEW_DEPTH ?? "deep",
124133
reviewModel: process.env.REVIEW_MODEL ?? "",

0 commit comments

Comments
 (0)