Skip to content
Draft
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
48 changes: 48 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# CI for the droid-action CI/CD Component project (gitlab.com mirror).
#
# Runs only on the GitLab mirror (factory-components/droid-action). It
# validates the component templates and publishes a CI/CD Catalog release
# when a semantic-version tag is pushed/mirrored. It does NOT run the
# droid-review job itself — that only fires in consumer projects on
# merge_request_event pipelines.

stages:
- validate
- release

# Parse both component templates to catch YAML / spec errors before a release.
validate-templates:
stage: validate
image: oven/bun:1.2.11
rules:
- if: '$CI_COMMIT_TAG'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- |
bun --eval '
import { readFileSync } from "node:fs";
const files = ["templates/droid-review.yml", "templates/fill.yml"];
for (const f of files) {
const text = readFileSync(f, "utf8");
if (!text.includes("spec:") || !text.includes("\n---")) {
console.error(`FAIL ${f}: missing spec/--- document separator`);
process.exit(1);
}
console.log(`OK ${f}`);
}
'

# Publish a CI/CD Catalog release. Requires the project to be marked as a
# catalog project (Settings > General > CI/CD Catalog project). Tags must be
# semantic versions, e.g. v1.0.0 or 1.0.0.
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest
rules:
- if: '$CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$/'
script:
- echo "Publishing catalog release $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
description: "droid-action CI/CD Component release $CI_COMMIT_TAG"
22 changes: 17 additions & 5 deletions gitlab/examples/.gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
# Example project-root .gitlab-ci.yml.
#
# If the project doesn't have a .gitlab-ci.yml yet, create one with at
# minimum the include line below. If it already has one, just append the
# `- local: "factory/droid-review.yml"` entry to its existing `include:`
# block (or add a new `include:` block if there isn't one).
# Add ONE of the include forms below to your existing `include:` block (or
# create this file if the project doesn't have one yet).

# --- Preferred: CI/CD Catalog component -------------------------------------
# Pulls the component straight from the Catalog. No files to copy into your
# repo. Pin to a released version (e.g. @1.0.0). Configure inputs inline.
include:
- local: "factory/droid-review.yml"
- component: gitlab.com/factory-components/droid-action/droid-review@1.0.0
inputs:
gitlab_token: $DROID_GITLAB_TOKEN
factory_api_key: $FACTORY_API_KEY

# --- Fallback: local copy ---------------------------------------------------
# Use this if you can't consume the Catalog (e.g. self-managed without the
# component mirrored). Copy templates/droid-review.yml into your repo at
# factory/droid-review.yml and include it locally instead of the block above:
#
# include:
# - local: "factory/droid-review.yml"
225 changes: 225 additions & 0 deletions src/entrypoints/gitlab-fill-prepare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
#!/usr/bin/env bun

/**
* Prepare step for the GitLab `@droid fill` mode.
*
* Responsibilities:
* 1. Parse GitLab CI env into a normalized context.
* 2. Decide whether this pipeline should run fill, based on whether
* `@droid fill` appears in the MR title, description, or labels
* (or `automatic_fill` is enabled).
* 3. Fetch the MR via API (description is not exposed as an env var)
* so we can match the trigger phrase against it.
* 4. Write the fill prompt + a state file consumed by the CI script.
*
* Unlike the review flow this is a single-pass job: the fill prompt
* itself calls `gitlab_mr___update_mr_description` to write back the
* new description.
*/

import * as fs from "fs/promises";
import * as path from "path";
import { parseGitlabContext, isMergeRequestContext } from "../gitlab/context";
import { setupGitlabToken, MissingGitlabTokenError } from "../gitlab/token";
import { GitlabClient } from "../gitlab/api/client";
import { computeReviewArtifacts } from "../gitlab/data/review-artifacts";
import { checkContainsFillTrigger } from "../gitlab/validation/trigger";
import { generateGitlabFillPrompt } from "../gitlab/prompts/fill";
import { setupDroidSettings } from "../../base-action/src/setup-droid-settings";

export type FillPrepareState = {
shouldRunFill: boolean;
projectId: string;
projectPath: string;
mrIid: number | null;
pipelineUrl: string | null;
jobUrl: string | null;
promptPath: string | null;
fillModel: string | null;
descriptionPath: string | null;
diffPath: string | null;
triggerSource: string;
reason?: string;
};

function promptFilePath(): string {
return process.env.DROID_PROMPT_FILE || "/tmp/droid-prompts/droid-prompt.txt";
}

function promptsDir(): string {
return path.dirname(promptFilePath());
}

function stateFilePath(): string {
return (
process.env.DROID_STATE_FILE ||
path.join(process.env.CI_PROJECT_DIR || "/tmp", ".droid-state.json")
);
}

function resolvedEnvShimPath(): string {
return (
process.env.DROID_RESOLVED_ENV_FILE || "/tmp/droid-prompts/resolved-env.sh"
);
}

function shellEscape(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

async function writeResolvedEnvShim(
extras: Record<string, string | null>,
): Promise<void> {
const filePath = resolvedEnvShimPath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
const lines = [
"# Generated by gitlab-fill-prepare; source this to expose resolved env.",
];
for (const [k, v] of Object.entries(extras)) {
lines.push(`export ${k}=${shellEscape(v ?? "")}`);
}
await fs.writeFile(filePath, lines.join("\n") + "\n");
console.log(`Wrote resolved env shim to ${filePath}`);
}

async function writeState(state: FillPrepareState): Promise<void> {
const filePath = stateFilePath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(state, null, 2));
console.log(`Wrote droid fill state to ${filePath}`);
}

async function run(): Promise<void> {
const context = parseGitlabContext();

const baseState: FillPrepareState = {
shouldRunFill: false,
projectId: context.project.id,
projectPath: context.project.pathWithNamespace,
mrIid: context.mr?.iid ?? null,
pipelineUrl: context.pipelineUrl,
jobUrl: context.jobUrl,
promptPath: null,
fillModel: context.inputs.fillModel || null,
descriptionPath: null,
diffPath: null,
triggerSource: "none",
};

if (!isMergeRequestContext(context)) {
console.log(
"Not a merge_request_event pipeline; skipping droid fill prepare.",
);
await writeState({ ...baseState, reason: "not-merge-request-event" });
return;
}

let token: string;
try {
token = setupGitlabToken();
} catch (err) {
if (err instanceof MissingGitlabTokenError) {
console.error(err.message);
}
throw err;
}

try {
await setupDroidSettings(context.inputs.settings || undefined);
} catch (err) {
console.warn(
"Failed to setup droid settings; continuing with defaults:",
err,
);
}

const client = new GitlabClient(token, context.apiUrl);
const mrIid = context.mr.iid;
const projectId = context.project.id;

// Description is not exposed as an env var; fetch via API.
const mr = await client.getMr(projectId, mrIid);
const description = mr.description ?? "";
const title = mr.title ?? context.mr.title ?? "";
const labels = context.mr.labels;

const trigger = checkContainsFillTrigger({
description,
title,
labels,
triggerPhrase: context.inputs.triggerPhrase,
automaticFill: context.inputs.automaticFill,
});

console.log(
`Fill trigger check: matched=${trigger.matched} source=${trigger.source} ` +
`triggerPhrase=${JSON.stringify(context.inputs.triggerPhrase)} ` +
`labels=${JSON.stringify(labels)}`,
);

if (!trigger.matched) {
console.log(
"No fill trigger found in description/title/labels and automatic_fill is off; skipping droid exec.",
);
await writeState({
...baseState,
reason: "no-fill-trigger",
triggerSource: trigger.source,
});
return;
}

// Reuse the review-artifacts helper for diff + description; we ignore
// existing_comments.json for fill mode (the prompt does not need them).
console.log("Computing fill artifacts (diff, description)...");
const artifacts = await computeReviewArtifacts({
client,
projectId,
mrIid,
outDir: promptsDir(),
});
console.log(
`Artifacts written:\n ${artifacts.diffPath}\n ${artifacts.descriptionPath}`,
);

await writeResolvedEnvShim({
DROID_MR_IID: String(mrIid),
FILL_MODEL: context.inputs.fillModel || "",
});

const promptCtx = {
projectPath: context.project.pathWithNamespace,
mrIid,
mrTitle: title,
sourceBranch: artifacts.mr.source_branch ?? "",
targetBranch: artifacts.mr.target_branch ?? "",
triggerSource: trigger.source,
triggerPhrase: context.inputs.triggerPhrase,
descriptionPath: artifacts.descriptionPath,
diffPath: artifacts.diffPath,
};

const prompt = generateGitlabFillPrompt(promptCtx);
const promptPath = promptFilePath();
await fs.mkdir(path.dirname(promptPath), { recursive: true });
await fs.writeFile(promptPath, prompt);
console.log(`Wrote fill prompt (${prompt.length} bytes) to ${promptPath}`);

await writeState({
...baseState,
shouldRunFill: true,
promptPath,
descriptionPath: artifacts.descriptionPath,
diffPath: artifacts.diffPath,
triggerSource: trigger.source,
});
}

if (import.meta.main) {
run().catch((error) => {
console.error("gitlab-fill-prepare failed:", error);
process.exit(1);
});
}

export { run };
9 changes: 9 additions & 0 deletions src/gitlab/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type ParsedGitlabContext = {
targetBranchSha: string | null;
diffBaseSha: string | null;
title: string | null;
labels: string[];
} | null;
commit: {
sha: string;
Expand All @@ -32,6 +33,7 @@ export type ParsedGitlabContext = {
inputs: {
automaticReview: boolean;
automaticSecurityReview: boolean;
automaticFill: boolean;
triggerPhrase: string;
reviewDepth: string;
reviewModel: string;
Expand Down Expand Up @@ -81,13 +83,19 @@ export function parseGitlabContext(): ParsedGitlabContext {
process.env.CI_PROJECT_URL || `${serverUrl}/${projectPath}`;

const mrIid = optional("CI_MERGE_REQUEST_IID");
const labelsRaw = optional("CI_MERGE_REQUEST_LABELS") ?? "";
const labels = labelsRaw
.split(",")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const mr = mrIid
? {
iid: parseInt(mrIid, 10),
sourceBranchSha: optional("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA"),
targetBranchSha: optional("CI_MERGE_REQUEST_TARGET_BRANCH_SHA"),
diffBaseSha: optional("CI_MERGE_REQUEST_DIFF_BASE_SHA"),
title: optional("CI_MERGE_REQUEST_TITLE"),
labels,
}
: null;

Expand Down Expand Up @@ -119,6 +127,7 @@ export function parseGitlabContext(): ParsedGitlabContext {
inputs: {
automaticReview: process.env.AUTOMATIC_REVIEW === "true",
automaticSecurityReview: process.env.AUTOMATIC_SECURITY_REVIEW === "true",
automaticFill: process.env.AUTOMATIC_FILL === "true",
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@droid",
reviewDepth: process.env.REVIEW_DEPTH ?? "deep",
reviewModel: process.env.REVIEW_MODEL ?? "",
Expand Down
Loading