Skip to content

Commit 459080a

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 7561989 commit 459080a

9 files changed

Lines changed: 722 additions & 3 deletions

File tree

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 ?? "",

src/gitlab/prompts/fill.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Prompt for the `@droid fill` mode on GitLab MRs. Mirrors the GitHub
3+
* action's fill-prompt but writes back via the GitLab MCP server's
4+
* `update_mr_description` tool instead of `update_pr_description`.
5+
*/
6+
7+
export type GitlabFillPromptContext = {
8+
projectPath: string;
9+
mrIid: number;
10+
mrTitle: string;
11+
sourceBranch: string;
12+
targetBranch: string;
13+
triggerSource: "description" | "title" | "label" | "automatic_fill" | "none";
14+
triggerPhrase: string;
15+
descriptionPath: string;
16+
diffPath: string;
17+
};
18+
19+
export function generateGitlabFillPrompt(ctx: GitlabFillPromptContext): string {
20+
return `You are updating the description of merge request !${ctx.mrIid} in ${ctx.projectPath}.
21+
22+
## MR details
23+
* Title: ${ctx.mrTitle}
24+
* Source branch: ${ctx.sourceBranch}
25+
* Target branch: ${ctx.targetBranch}
26+
* Trigger: \`${ctx.triggerPhrase} fill\` detected via **${ctx.triggerSource}**
27+
28+
## Inputs already on disk
29+
* Current description: \`${ctx.descriptionPath}\` (may contain \`${ctx.triggerPhrase} fill\` as placeholder text)
30+
* Full unified diff: \`${ctx.diffPath}\`
31+
32+
## Procedure
33+
1. Read \`${ctx.descriptionPath}\` and \`${ctx.diffPath}\` to ground every claim in verified changes.
34+
2. Check the workspace for an MR description template:
35+
* \`.gitlab/merge_request_templates/Default.md\`
36+
* \`.gitlab/merge_request_templates/default.md\`
37+
* Any other file under \`.gitlab/merge_request_templates/\` (use the first match)
38+
If one exists, fill out its sections. Otherwise use:
39+
\`\`\`
40+
## Summary
41+
## Changes
42+
## Implementation Details (optional when not applicable)
43+
## Testing
44+
## Breaking Changes (only when relevant)
45+
\`\`\`
46+
3. Preserve any non-placeholder content already in the existing description (ticket links, custom sections, manual notes). If a detail is uncertain, keep it as-is.
47+
4. Do **not** infer or invent ticket references (Linear, Jira, GitLab issues, etc.) that are not already explicitly present in the existing description, MR comments, or branch name. Never extract ticket IDs from commit messages or other indirect sources. If no ticket is linked, leave the section empty or omit it.
48+
5. **Strip the trigger phrase.** Remove any literal occurrence of \`${ctx.triggerPhrase} fill\` from the new description so the next \`merge_request_event\` (fired by this update) does not re-trigger fill mode.
49+
6. Keep the tone concise and factual. Use clear Markdown headings and bullets. For sections you cannot verify, write \`[To be filled by author]\`.
50+
51+
## Submission
52+
* Call \`gitlab_mr___update_mr_description\` with the final Markdown body and \`mr_iid: ${ctx.mrIid}\`. This is the only mutation tool available in this pass.
53+
* Do **not** post inline review comments, summary notes, or anything else. Fill mode is description-only.
54+
* Do **not** call \`update_mr_description\` more than once. Compose the final body in one go and submit it.
55+
56+
## Hard constraints
57+
* Output must be Markdown — no surrounding code fence, no commentary.
58+
* Do not modify any files other than the MR description.
59+
* If the diff is empty or unreadable, abort with a clear error message and do **not** call \`update_mr_description\`.
60+
`;
61+
}

0 commit comments

Comments
 (0)