Skip to content

Commit ccd9550

Browse files
committed
Apply PRs through Blueprints
1 parent 411a7f5 commit ccd9550

File tree

4 files changed

+268
-45
lines changed

4 files changed

+268
-45
lines changed
Lines changed: 222 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,234 @@
11
<?php
22

3-
function download_file($url)
4-
{
5-
$ch = curl_init($url);
6-
curl_setopt($ch, CURLOPT_HEADER, 1);
7-
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
8-
curl_setopt($ch, CURLOPT_BINARYTRANSFER, 1);
9-
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 0);
3+
ini_set('display_errors', 0);
104

11-
$response = curl_exec($ch);
5+
class ApiException extends Exception {}
6+
class PluginDownloader {
127

13-
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
14-
$headers = array_map('trim', explode("\n", substr($response, 0, $header_size)));
15-
$body = substr($response, $header_size);
8+
private $githubToken;
169

17-
return [$headers, $body];
18-
}
10+
public const PLUGINS = 'plugins';
11+
public const THEMES = 'themes';
1912

20-
if (isset($_GET['plugin'])) {
21-
$plugin_name = preg_replace('#[^a-zA-Z0-9\.\-_]#', '', $_GET['plugin']);
22-
$zip_url = 'https://downloads.wordpress.org/plugin/' . $plugin_name;
23-
} else if (isset($_GET['theme'])) {
24-
$theme_name = preg_replace('#[^a-zA-Z0-9\.\-_]#', '', $_GET['theme']);
25-
$zip_url = 'https://downloads.wordpress.org/theme/' . $theme_name;
26-
} else {
27-
die('Invalid request');
28-
}
13+
public function __construct($githubToken)
14+
{
15+
$this->githubToken = $githubToken;
16+
}
17+
18+
public function streamFromDirectory($name, $directory)
19+
{
20+
$name = preg_replace('#[^a-zA-Z0-9\.\-_]#', '', $name);
21+
$zipUrl = "https://downloads.wordpress.org/$directory/$name";
22+
try {
23+
$this->streamHttpResponse($zipUrl, [
24+
'content-length',
25+
'x-frame-options',
26+
'last-modified',
27+
'etag',
28+
'date',
29+
'age',
30+
'vary',
31+
'cache-Control'
32+
]);
33+
} catch (ApiException $e) {
34+
throw new ApiException("Plugin or theme '$name' not found");
35+
}
36+
}
37+
38+
public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $artifact_name) {
39+
$prDetails = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/pulls/$pr")['body'];
40+
if(!$prDetails) {
41+
throw new ApiException('Invalid PR number');
42+
}
43+
$branchName = $prDetails->head->ref;
44+
$ciRuns = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/actions/runs?branch=$branchName")['body'];
45+
if(!$ciRuns) {
46+
throw new ApiException('No CI runs found');
47+
}
48+
49+
$artifactsUrls = [];
50+
foreach($ciRuns->workflow_runs as $run) {
51+
if($run->name === $workflow_name) {
52+
$artifactsUrls[] = $run->artifacts_url;
53+
}
54+
}
55+
if(!$artifactsUrls) {
56+
throw new ApiException('No artifact URL found');
57+
}
58+
59+
foreach($artifactsUrls as $artifactsUrl) {
60+
$zip_download_api_endpoint = $zip_url = null;
61+
62+
$artifacts = $this->gitHubRequest($artifactsUrl)['body'];
63+
if(!$artifacts) {
64+
continue;
65+
}
66+
67+
foreach($artifacts->artifacts as $artifact) {
68+
if($artifact->name === $artifact_name) {
69+
$zip_download_api_endpoint = $artifact->archive_download_url;
70+
break;
71+
}
72+
}
73+
if(!$zip_download_api_endpoint) {
74+
continue;
75+
}
76+
77+
$zip_download_headers = $this->gitHubRequest($zip_download_api_endpoint, true)['headers'];
78+
// Find the location header and store it in $zip_url
79+
foreach($zip_download_headers as $header) {
80+
if(substr(strtolower($header), 0, 10) === 'location: ') {
81+
$zip_url = substr($header, 10);
82+
break;
83+
}
84+
}
85+
if(!$zip_url) {
86+
continue;
87+
}
88+
$this->streamHttpResponse($zip_url, [], [
89+
'Content-Length: '.$artifact->size_in_bytes
90+
]);
91+
}
92+
if(!$artifacts) {
93+
throw new ApiException('No artifacts found under the URL');
94+
}
95+
if(!$zip_download_api_endpoint) {
96+
throw new ApiException('No artifact download URL found with the name');
97+
}
98+
if(!$zip_url) {
99+
throw new ApiException('No zip location returned by the artifact download API');
100+
}
101+
}
102+
103+
protected function gitHubRequest($url)
104+
{
105+
$headers[] = 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36';
106+
$headers[] = 'Authorization: Bearer ' . $this->githubToken;
107+
$context = stream_context_create([
108+
'http' => [
109+
'method' => 'GET',
110+
'header' => implode("\r\n", $headers),
111+
]
112+
]);
113+
$response = file_get_contents($url, false, $context);
114+
if($response === false) {
115+
throw new ApiException('Request failed');
116+
}
117+
return [
118+
'body' => json_decode($response),
119+
'headers' => array_map('trim', array_slice($http_response_header, 1))
120+
];
121+
}
29122

30-
[$received_headers, $bytes] = download_file($zip_url);
31-
32-
$forward_headers = [
33-
'content-length',
34-
'content-type',
35-
'content-disposition',
36-
'x-frame-options',
37-
'last-modified',
38-
'etag',
39-
'date',
40-
'age',
41-
'vary',
42-
'cache-Control'
43-
];
44-
45-
foreach ($received_headers as $received_header) {
46-
$comparable_header = strtolower($received_header);
47-
foreach ($forward_headers as $sought_header) {
48-
if (substr($comparable_header, 0, strlen($sought_header)) === $sought_header) {
49-
header($received_header);
50-
break;
123+
private function streamHttpResponse($url, $allowed_headers=[], $default_headers=[]) {
124+
$default_headers = array_merge([
125+
'Content-Type: application/zip',
126+
'Content-Disposition: attachment; filename="plugin.zip"',
127+
], $default_headers);
128+
$ch = curl_init($url);
129+
curl_setopt_array($ch,
130+
[
131+
CURLOPT_RETURNTRANSFER => true,
132+
CURLOPT_CONNECTTIMEOUT => 30,
133+
CURLOPT_FAILONERROR => true,
134+
CURLOPT_FOLLOWLOCATION => true,
135+
]
136+
);
137+
138+
$seen_headers = [];
139+
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header_line) use($seen_headers, $allowed_headers)
140+
{
141+
$header_name = strtolower(substr($header_line, 0, strpos($header_line, ':')));
142+
$seen_headers[$header_name] = true;
143+
if(in_array($header_name, $allowed_headers)) {
144+
header($header_line);
145+
}
146+
return strlen($header_line);
147+
}
148+
);
149+
$extra_headers_sent = false;
150+
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($curl, $body) use(&$extra_headers_sent, $default_headers)
151+
{
152+
if(!$extra_headers_sent) {
153+
foreach ($default_headers as $header_line) {
154+
$header_name = strtolower(substr($header_line, 0, strpos($header_line, ':')));
155+
if (!isset($seen_headers[strtolower($header_name)])) {
156+
header($header_line);
157+
}
158+
}
159+
$extra_headers_sent = true;
160+
}
161+
echo $body;
162+
flush();
163+
return strlen($body);
164+
}
165+
);
166+
curl_exec($ch);
167+
$info = curl_getinfo($ch);
168+
curl_close($ch);
169+
if($info['http_code'] > 299 || $info['http_code'] < 200) {
170+
throw new ApiException('Request failed');
51171
}
52172
}
173+
53174
}
54175

55-
header('Access-Control-Allow-Origin: *');
176+
$downloader = new PluginDownloader(
177+
getenv('GITHUB_TOKEN')
178+
);
56179

57-
echo $bytes;
180+
// Serve the request:
181+
header('Access-Control-Allow-Origin: *');
182+
$pluginResponse;
183+
try {
184+
if (isset($_GET['plugin'])) {
185+
$downloader->streamFromDirectory($_GET['plugin'], PluginDownloader::PLUGINS);
186+
} else if (isset($_GET['theme'])) {
187+
$downloader->streamFromDirectory($_GET['plugin'], PluginDownloader::THEMES);
188+
} else if (isset($_GET['org']) && isset($_GET['repo']) && isset($_GET['workflow']) && isset($_GET['pr']) && isset($_GET['artifact'])) {
189+
$allowedInputs = [
190+
[
191+
'org' => 'WordPress',
192+
'repo' => 'gutenberg',
193+
'workflow' => 'Build Gutenberg Plugin Zip',
194+
'artifact' => 'gutenberg-plugin'
195+
],
196+
[
197+
'org' => 'woocommerce',
198+
'repo' => 'woocommerce',
199+
'workflow' => 'Build Live Branch',
200+
'artifact' => 'plugins'
201+
]
202+
];
203+
$allowed = false;
204+
foreach ($allowedInputs as $allowedInput) {
205+
if (
206+
$_GET['org'] === $allowedInput['org'] &&
207+
$_GET['repo'] === $allowedInput['repo'] &&
208+
$_GET['workflow'] === $allowedInput['workflow'] &&
209+
$_GET['artifact'] === $allowedInput['artifact']
210+
) {
211+
$allowed = true;
212+
break;
213+
}
214+
}
215+
if (!$allowed) {
216+
die('Invalid request');
217+
}
218+
$downloader->streamFromGithubPR(
219+
$_GET['org'],
220+
$_GET['repo'],
221+
$_GET['pr'],
222+
$_GET['workflow'],
223+
$_GET['artifact']
224+
);
225+
} else {
226+
throw new ApiException('Invalid query parameters');
227+
}
228+
} catch (ApiException $e) {
229+
header('HTTP/1.1 400 Invalid request');
230+
if(!headers_sent()) {
231+
header('Content-Type: application/json');
232+
}
233+
die(json_encode(['error' => $e->getMessage()]));
234+
}

packages/playground/website/src/lib/make-blueprint.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface MakeBlueprintOptions {
66
landingPage?: string;
77
theme?: string;
88
plugins?: string[];
9+
gutenbergPR?: number;
910
}
1011
export function makeBlueprint(options: MakeBlueprintOptions): Blueprint {
1112
const plugins = options.plugins || [];
@@ -37,6 +38,43 @@ export function makeBlueprint(options: MakeBlueprintOptions): Blueprint {
3738
},
3839
progress: { weight: 2 },
3940
})),
41+
...(typeof options.gutenbergPR === 'number'
42+
? applyGutenbergPRSteps(options.gutenbergPR)
43+
: []),
4044
],
4145
};
4246
}
47+
48+
function applyGutenbergPRSteps(prNumber: number): StepDefinition[] {
49+
return [
50+
{
51+
step: 'mkdir',
52+
path: '/wordpress/pr',
53+
},
54+
{
55+
step: 'writeFile',
56+
path: '/wordpress/pr/pr.zip',
57+
data: {
58+
resource: 'url',
59+
url: `/plugin-proxy?org=WordPress&repo=gutenberg&workflow=Build%20Gutenberg%20Plugin%20Zip&artifact=gutenberg-plugin&pr=${prNumber}`,
60+
caption: `Downloading Gutenberg PR ${prNumber}`,
61+
},
62+
progress: {
63+
weight: 2,
64+
caption: `Applying Gutenberg PR ${prNumber}`,
65+
},
66+
},
67+
{
68+
step: 'unzip',
69+
zipPath: '/wordpress/pr/pr.zip',
70+
extractToPath: '/wordpress/pr',
71+
},
72+
{
73+
step: 'installPlugin',
74+
pluginZipFile: {
75+
resource: 'vfs',
76+
path: '/wordpress/pr/gutenberg.zip',
77+
},
78+
},
79+
];
80+
}

packages/playground/website/src/main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ const blueprint = makeBlueprint({
1818
theme: query.get('theme') || undefined,
1919
plugins: query.getAll('plugin'),
2020
landingPage: query.get('url') || undefined,
21+
gutenbergPR: query.has('gutenberg-pr')
22+
? Number(query.get('gutenberg-pr'))
23+
: undefined,
2124
});
2225

2326
const isSeamless = (query.get('mode') || 'browser') === 'seamless';

packages/playground/website/vite.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import {
1515
import virtualModule from '../vite-virtual-module';
1616

1717
const proxy = {
18+
'^/plugin-proxy.*&artifact=.*': {
19+
target: 'https://playground.wordpress.net',
20+
changeOrigin: true,
21+
secure: true,
22+
},
1823
'/plugin-proxy': {
1924
target: 'https://downloads.wordpress.org',
2025
changeOrigin: true,

0 commit comments

Comments
 (0)