import * as wmillclient from "windmill-client";
import wmill from "[email protected]";
import { execFile } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import process from "process";
const run = promisify(execFile);
type GitRepository = {
url: string;
branch: string;
folder?: string;
is_github_app: boolean;
};
/**
* Reviews a pull request on a git-synced repository with the workspace's AI, for the git sync
* setting "Review pull requests with AI". Enqueued by the server on a pull request event; the
* server posts what this returns. Runs `wmill review`, which reads the review instructions
* from the base branch only and never reads the checkout's wmill.yaml.
*/
export async function main(
workspace_id: string,
repo_url_resource_path: string,
base_ref: string, // the tracked branch the pull request targets
pr_ref: string, // the host's head ref: pull/<n>/head on GitHub, merge-requests/<n>/head on GitLab
head_sha: string // the head the event was for; a newer push gets its own review
) {
if (!/^(pull|merge-requests)\/\d+\/head$/.test(pr_ref)) {
throw new Error(`Not a pull request head ref: ${pr_ref}`);
}
if (!/^[0-9a-f]{40}$/.test(head_sha)) {
throw new Error(`Not a commit sha: ${head_sha}`);
}
const repo: GitRepository = await wmillclient.getResource(repo_url_resource_path);
process.env.GIT_TERMINAL_PROMPT = "0";
process.env["HOME"] = ".";
let url = repo.url;
if (repo.is_github_app) {
url = prependTokenToGitHubUrl(url, await get_gh_app_token());
} else if (!urlCarriesCredential(url)) {
const token = await get_stored_git_token();
if (token) url = prependTokenToGitUrl(url, token);
}
// A bare clone of the base branch with its history, so the review finds the merge base;
// the pull request's head is fetched from the host's own ref, present even for a
// pull request from another repository.
// The job's result is written to its working directory, so it is restored before returning.
const cwd = process.cwd();
const dir = join(cwd, "repo.git");
await git(url, "clone", "--quiet", "--bare", "--single-branch", "--branch", base_ref, url, dir);
process.chdir(dir);
try {
return await review(url, pr_ref, head_sha, workspace_id, repo.folder);
} finally {
process.chdir(cwd);
}
}
async function review(
url: string,
pr_ref: string,
head_sha: string,
workspace_id: string,
folder: string | undefined
) {
await git(url, "fetch", "--quiet", "--no-tags", url, pr_ref);
const fetched = (await git(url, "rev-parse", "FETCH_HEAD")).trim();
if (fetched !== head_sha) {
return { skipped: `The pull request moved from ${head_sha} to ${fetched}` };
}
const base = (await git(url, "rev-parse", "HEAD")).trim();
return await wmill_json([
"review",
"--base",
base,
"--head",
head_sha,
...(folder ? ["--folder", folder] : []),
"--json",
"--base-url",
(process.env["BASE_INTERNAL_URL"] ?? process.env["BASE_URL"]) + "/",
"--workspace",
workspace_id,
"--token",
process.env["WM_TOKEN"] ?? "",
"--config-dir",
mkdtempSync(join(tmpdir(), "wmill-config-")),
]);
}
/** Runs git without a shell, keeping the credential-bearing remote out of the log. */
async function git(url: string, ...args: string[]): Promise<string> {
try {
const { stdout } = await run("git", args, { maxBuffer: 512 * 1024 * 1024 });
return stdout;
} catch (error: any) {
throw new Error(`git ${args[0]} failed: ${String(error?.stderr ?? error).replaceAll(url, "***")}`);
}
}
/** The JSON `wmill <cmd> --json` prints. */
async function wmill_json(cmd: string[]) {
const originalLog = console.log;
let output = "";
console.log = (msg: string) => {
output += msg + "\n";
};
try {
await wmill.parse(cmd);
} finally {
console.log = originalLog;
}
const start = output.indexOf("{");
if (start === -1) throw new Error(`wmill review printed no result: ${output}`);
return JSON.parse(output.slice(start));
}
function urlCarriesCredential(url: string | undefined): boolean {
return /:\/\/[^/@]+@/.test(url ?? "");
}
// The credential Windmill stores for this repository, or undefined when it holds
// none. Absence is normal (a public remote), so it must not fail the review.
async function get_stored_git_token() {
try {
return await get_gh_app_token();
} catch (error) {
console.log("no stored git credential for this repository:", error?.message ?? String(error));
return undefined;
}
}
async function get_gh_app_token() {
const workspace = process.env["WM_WORKSPACE"];
const jobToken = process.env["WM_TOKEN"];
const baseUrl = process.env["BASE_INTERNAL_URL"] ?? process.env["BASE_URL"] ?? "http://localhost:8000";
const response = await fetch(`${baseUrl}/api/w/${workspace}/github_app/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jobToken}` },
body: JSON.stringify({ job_token: jobToken }),
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`);
}
return (await response.json()).token;
}
// Rewrite the git remote formats `new URL` can't parse into an https URL:
// scp-like ssh ([user@]host:owner/repo) and scheme-less (host/owner/repo).
// A host:8080/... port form is kept as authority rather than read as scp.
function normalizeGitHubUrl(raw: string): string {
const cleaned = raw.trim();
if (cleaned.includes("://")) {
return cleaned;
}
const colonIdx = cleaned.indexOf(":");
if (colonIdx !== -1) {
const before = cleaned.slice(0, colonIdx);
const after = cleaned.slice(colonIdx + 1);
const firstSeg = after.split("/")[0];
const isPort = !before.includes("@") && firstSeg !== "" && /^\d+$/.test(firstSeg);
const isUserinfo = firstSeg.includes("@");
if (before !== "" && !before.includes("/") && after !== "" && !isPort && !isUserinfo) {
const host = before.split("@").pop();
return `https://${host}/${after.replace(/^\/+/, "")}`;
}
}
return `https://${cleaned}`;
}
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
const url = new URL(normalizeGitHubUrl(gitHubUrl));
return `https://x-access-token:${installationToken}@${url.host}${url.pathname}`;
}
// `oauth2` is the username GitLab documents for token auth; the scheme the resource
// chose is kept, so a self-managed server reached over http is not dialled as https.
function prependTokenToGitUrl(gitUrl: string, token: string) {
const url = new URL(normalizeGitHubUrl(gitUrl));
url.username = "oauth2";
url.password = token;
return url.toString();
}
Submitted by hugo989 4 hours ago