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, |
29 | pr_ref: string, |
30 | head_sha: string |
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 | |
52 | |
53 | |
54 | |
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 | |
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 | |
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 | |
131 | |
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 | |
158 | |
159 | |
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 | |
186 | |
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 |
|