0

Git-sync: review pull request

by
Published today

Reviews a pull request on a git-synced repository with the workspace's AI, for the git sync setting "Review pull requests with AI". The server enqueues it on a pull request event and posts what it returns.

Script windmill Verified

The script

Submitted by hugo989 Bun
Verified 4 hours ago
1
import * as wmillclient from "windmill-client";
2
import wmill from "[email protected]";
3
import { execFile } from "node:child_process";
4
import { mkdtempSync } from "node:fs";
5
import { tmpdir } from "node:os";
6
import { join } from "node:path";
7
import { promisify } from "node:util";
8
import process from "process";
9

10
const run = promisify(execFile);
11

12
type GitRepository = {
13
  url: string;
14
  branch: string;
15
  folder?: string;
16
  is_github_app: boolean;
17
};
18

19
/**
20
 * Reviews a pull request on a git-synced repository with the workspace's AI, for the git sync
21
 * setting "Review pull requests with AI". Enqueued by the server on a pull request event; the
22
 * server posts what this returns. Runs `wmill review`, which reads the review instructions
23
 * from the base branch only and never reads the checkout's wmill.yaml.
24
 */
25
export async function main(
26
  workspace_id: string,
27
  repo_url_resource_path: string,
28
  base_ref: string, // the tracked branch the pull request targets
29
  pr_ref: string, // the host's head ref: pull/<n>/head on GitHub, merge-requests/<n>/head on GitLab
30
  head_sha: string // the head the event was for; a newer push gets its own review
31
) {
32
  if (!/^(pull|merge-requests)\/\d+\/head$/.test(pr_ref)) {
33
    throw new Error(`Not a pull request head ref: ${pr_ref}`);
34
  }
35
  if (!/^[0-9a-f]{40}$/.test(head_sha)) {
36
    throw new Error(`Not a commit sha: ${head_sha}`);
37
  }
38

39
  const repo: GitRepository = await wmillclient.getResource(repo_url_resource_path);
40
  process.env.GIT_TERMINAL_PROMPT = "0";
41
  process.env["HOME"] = ".";
42

43
  let url = repo.url;
44
  if (repo.is_github_app) {
45
    url = prependTokenToGitHubUrl(url, await get_gh_app_token());
46
  } else if (!urlCarriesCredential(url)) {
47
    const token = await get_stored_git_token();
48
    if (token) url = prependTokenToGitUrl(url, token);
49
  }
50

51
  // A bare clone of the base branch with its history, so the review finds the merge base;
52
  // the pull request's head is fetched from the host's own ref, present even for a
53
  // pull request from another repository.
54
  // The job's result is written to its working directory, so it is restored before returning.
55
  const cwd = process.cwd();
56
  const dir = join(cwd, "repo.git");
57
  await git(url, "clone", "--quiet", "--bare", "--single-branch", "--branch", base_ref, url, dir);
58
  process.chdir(dir);
59
  try {
60
    return await review(url, pr_ref, head_sha, workspace_id, repo.folder);
61
  } finally {
62
    process.chdir(cwd);
63
  }
64
}
65

66
async function review(
67
  url: string,
68
  pr_ref: string,
69
  head_sha: string,
70
  workspace_id: string,
71
  folder: string | undefined
72
) {
73
  await git(url, "fetch", "--quiet", "--no-tags", url, pr_ref);
74
  const fetched = (await git(url, "rev-parse", "FETCH_HEAD")).trim();
75
  if (fetched !== head_sha) {
76
    return { skipped: `The pull request moved from ${head_sha} to ${fetched}` };
77
  }
78
  const base = (await git(url, "rev-parse", "HEAD")).trim();
79

80
  return await wmill_json([
81
    "review",
82
    "--base",
83
    base,
84
    "--head",
85
    head_sha,
86
    ...(folder ? ["--folder", folder] : []),
87
    "--json",
88
    "--base-url",
89
    (process.env["BASE_INTERNAL_URL"] ?? process.env["BASE_URL"]) + "/",
90
    "--workspace",
91
    workspace_id,
92
    "--token",
93
    process.env["WM_TOKEN"] ?? "",
94
    "--config-dir",
95
    mkdtempSync(join(tmpdir(), "wmill-config-")),
96
  ]);
97
}
98

99
/** Runs git without a shell, keeping the credential-bearing remote out of the log. */
100
async function git(url: string, ...args: string[]): Promise<string> {
101
  try {
102
    const { stdout } = await run("git", args, { maxBuffer: 512 * 1024 * 1024 });
103
    return stdout;
104
  } catch (error: any) {
105
    throw new Error(`git ${args[0]} failed: ${String(error?.stderr ?? error).replaceAll(url, "***")}`);
106
  }
107
}
108

109
/** The JSON `wmill <cmd> --json` prints. */
110
async function wmill_json(cmd: string[]) {
111
  const originalLog = console.log;
112
  let output = "";
113
  console.log = (msg: string) => {
114
    output += msg + "\n";
115
  };
116
  try {
117
    await wmill.parse(cmd);
118
  } finally {
119
    console.log = originalLog;
120
  }
121
  const start = output.indexOf("{");
122
  if (start === -1) throw new Error(`wmill review printed no result: ${output}`);
123
  return JSON.parse(output.slice(start));
124
}
125

126
function urlCarriesCredential(url: string | undefined): boolean {
127
  return /:\/\/[^/@]+@/.test(url ?? "");
128
}
129

130
// The credential Windmill stores for this repository, or undefined when it holds
131
// none. Absence is normal (a public remote), so it must not fail the review.
132
async function get_stored_git_token() {
133
  try {
134
    return await get_gh_app_token();
135
  } catch (error) {
136
    console.log("no stored git credential for this repository:", error?.message ?? String(error));
137
    return undefined;
138
  }
139
}
140

141
async function get_gh_app_token() {
142
  const workspace = process.env["WM_WORKSPACE"];
143
  const jobToken = process.env["WM_TOKEN"];
144
  const baseUrl = process.env["BASE_INTERNAL_URL"] ?? process.env["BASE_URL"] ?? "http://localhost:8000";
145
  const response = await fetch(`${baseUrl}/api/w/${workspace}/github_app/token`, {
146
    method: "POST",
147
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${jobToken}` },
148
    body: JSON.stringify({ job_token: jobToken }),
149
  });
150
  if (!response.ok) {
151
    const errorBody = await response.text().catch(() => "");
152
    throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`);
153
  }
154
  return (await response.json()).token;
155
}
156

157
// Rewrite the git remote formats `new URL` can't parse into an https URL:
158
// scp-like ssh ([user@]host:owner/repo) and scheme-less (host/owner/repo).
159
// A host:8080/... port form is kept as authority rather than read as scp.
160
function normalizeGitHubUrl(raw: string): string {
161
  const cleaned = raw.trim();
162
  if (cleaned.includes("://")) {
163
    return cleaned;
164
  }
165
  const colonIdx = cleaned.indexOf(":");
166
  if (colonIdx !== -1) {
167
    const before = cleaned.slice(0, colonIdx);
168
    const after = cleaned.slice(colonIdx + 1);
169
    const firstSeg = after.split("/")[0];
170
    const isPort = !before.includes("@") && firstSeg !== "" && /^\d+$/.test(firstSeg);
171
    const isUserinfo = firstSeg.includes("@");
172
    if (before !== "" && !before.includes("/") && after !== "" && !isPort && !isUserinfo) {
173
      const host = before.split("@").pop();
174
      return `https://${host}/${after.replace(/^\/+/, "")}`;
175
    }
176
  }
177
  return `https://${cleaned}`;
178
}
179

180
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
181
  const url = new URL(normalizeGitHubUrl(gitHubUrl));
182
  return `https://x-access-token:${installationToken}@${url.host}${url.pathname}`;
183
}
184

185
// `oauth2` is the username GitLab documents for token auth; the scheme the resource
186
// chose is kept, so a self-managed server reached over http is not dialled as https.
187
function prependTokenToGitUrl(gitUrl: string, token: string) {
188
  const url = new URL(normalizeGitHubUrl(gitUrl));
189
  url.username = "oauth2";
190
  url.password = token;
191
  return url.toString();
192
}
193