prquicktest 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/bin/prquicktest.mjs +343 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# prquicktest
|
|
2
|
+
|
|
3
|
+
Run code blocks from the "Testing" section of GitHub PR descriptions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g prquicktest
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run directly:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx prquicktest https://github.com/owner/repo/pull/123
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
prquicktest <pr-url> # Fetch PR and run test code blocks
|
|
21
|
+
prquicktest <file.md> # Run from local file
|
|
22
|
+
prquicktest -y <pr-url> # Skip confirmation prompt
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Security
|
|
26
|
+
|
|
27
|
+
Before executing any code, prquicktest shows a preview of all code blocks and asks for confirmation:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Found 2 code block(s) to execute:
|
|
31
|
+
|
|
32
|
+
[bash]
|
|
33
|
+
npm test
|
|
34
|
+
|
|
35
|
+
[bash]
|
|
36
|
+
npm run build
|
|
37
|
+
|
|
38
|
+
⚠ WARNING: This will execute code on your machine.
|
|
39
|
+
Proceed? [y/N]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use `-y` or `--yes` to skip the prompt (for trusted sources only).
|
|
43
|
+
|
|
44
|
+
## How It Works
|
|
45
|
+
|
|
46
|
+
Only executes code blocks found under a **Testing**, **Tests**, or **Test** header (any level: `#`, `##`, `###`, etc.). The section ends when another header of equal or higher level appears.
|
|
47
|
+
|
|
48
|
+
### Example PR Description
|
|
49
|
+
|
|
50
|
+
```markdown
|
|
51
|
+
## Summary
|
|
52
|
+
This PR adds a new feature.
|
|
53
|
+
|
|
54
|
+
## Testing
|
|
55
|
+
Run the tests:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm test
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Verify the build:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm run build
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Deployment
|
|
68
|
+
This section is ignored.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Running `prquicktest <pr-url>` will only execute the two bash blocks under "Testing".
|
|
72
|
+
|
|
73
|
+
## Supported Languages
|
|
74
|
+
|
|
75
|
+
| Language | Aliases | Executor |
|
|
76
|
+
|----------|---------|----------|
|
|
77
|
+
| Bash | `bash`, `sh`, `shell` | `bash -c` |
|
|
78
|
+
| JavaScript | `javascript`, `js`, `node` | `node -e` |
|
|
79
|
+
| Python | `python`, `py`, `python3` | `python3 -c` |
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- Node.js 18+
|
|
84
|
+
- [GitHub CLI](https://cli.github.com) (`gh`) installed and authenticated
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prquicktest - Run code blocks from GitHub PR descriptions
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* prquicktest <github-pr-url>
|
|
8
|
+
* prquicktest https://github.com/owner/repo/pull/123
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn, spawnSync } from 'child_process';
|
|
12
|
+
import { readFileSync } from 'fs';
|
|
13
|
+
import { createInterface } from 'readline';
|
|
14
|
+
|
|
15
|
+
const colors = {
|
|
16
|
+
reset: '\x1b[0m',
|
|
17
|
+
dim: '\x1b[2m',
|
|
18
|
+
cyan: '\x1b[36m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
red: '\x1b[31m',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const langConfig = {
|
|
25
|
+
bash: { cmd: 'bash', args: ['-c'] },
|
|
26
|
+
sh: { cmd: 'sh', args: ['-c'] },
|
|
27
|
+
shell: { cmd: 'bash', args: ['-c'] },
|
|
28
|
+
javascript: { cmd: 'node', args: ['-e'] },
|
|
29
|
+
js: { cmd: 'node', args: ['-e'] },
|
|
30
|
+
node: { cmd: 'node', args: ['-e'] },
|
|
31
|
+
python: { cmd: 'python3', args: ['-c'] },
|
|
32
|
+
py: { cmd: 'python3', args: ['-c'] },
|
|
33
|
+
python3: { cmd: 'python3', args: ['-c'] },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function isGitHubPrUrl(input) {
|
|
37
|
+
return /^https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/.test(input);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fetchPrDescription(url) {
|
|
41
|
+
console.log(`${colors.cyan}Fetching PR...${colors.reset}\n`);
|
|
42
|
+
|
|
43
|
+
const result = spawnSync('gh', ['pr', 'view', url, '--json', 'body,title,url', '-q', '"# " + .title + "\n" + .url + "\n\n" + .body'], {
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
timeout: 30000,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (result.error) {
|
|
49
|
+
if (result.error.code === 'ENOENT') {
|
|
50
|
+
console.error(`${colors.red}Error: GitHub CLI (gh) not found. Install from https://cli.github.com${colors.reset}`);
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`${colors.red}Error: ${result.error.message}${colors.reset}`);
|
|
53
|
+
}
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (result.status !== 0) {
|
|
58
|
+
console.error(`${colors.red}Error fetching PR: ${result.stderr}${colors.reset}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result.stdout;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if a header line starts a "Testing" or "Tests" section
|
|
67
|
+
*/
|
|
68
|
+
function isTestingHeader(line) {
|
|
69
|
+
const match = line.match(/^(#{1,6})\s+(.+)/);
|
|
70
|
+
if (!match) return null;
|
|
71
|
+
|
|
72
|
+
const level = match[1].length;
|
|
73
|
+
const title = match[2].trim().toLowerCase();
|
|
74
|
+
|
|
75
|
+
if (title === 'testing' || title === 'tests' || title === 'test') {
|
|
76
|
+
return { level, title: match[2].trim() };
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if line is any header and return its level
|
|
83
|
+
*/
|
|
84
|
+
function getHeaderLevel(line) {
|
|
85
|
+
const match = line.match(/^(#{1,6})\s+/);
|
|
86
|
+
return match ? match[1].length : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseMarkdown(content) {
|
|
90
|
+
const blocks = [];
|
|
91
|
+
const lines = content.split('\n');
|
|
92
|
+
let i = 0;
|
|
93
|
+
let inTestingSection = false;
|
|
94
|
+
let testingSectionLevel = null;
|
|
95
|
+
|
|
96
|
+
while (i < lines.length) {
|
|
97
|
+
const line = lines[i];
|
|
98
|
+
|
|
99
|
+
// Check for headers
|
|
100
|
+
const testingHeader = isTestingHeader(line);
|
|
101
|
+
const headerLevel = getHeaderLevel(line);
|
|
102
|
+
|
|
103
|
+
if (testingHeader) {
|
|
104
|
+
// Entering a testing section
|
|
105
|
+
inTestingSection = true;
|
|
106
|
+
testingSectionLevel = testingHeader.level;
|
|
107
|
+
blocks.push({ type: 'text', content: line });
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
} else if (headerLevel !== null && inTestingSection) {
|
|
111
|
+
// Another header - check if it ends the testing section
|
|
112
|
+
// (same level or higher/less # means we exit)
|
|
113
|
+
if (headerLevel <= testingSectionLevel) {
|
|
114
|
+
inTestingSection = false;
|
|
115
|
+
testingSectionLevel = null;
|
|
116
|
+
}
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const codeMatch = line.match(/^```(\w+)?/);
|
|
122
|
+
|
|
123
|
+
if (codeMatch) {
|
|
124
|
+
const lang = codeMatch[1] || '';
|
|
125
|
+
const codeLines = [];
|
|
126
|
+
i++;
|
|
127
|
+
|
|
128
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
129
|
+
codeLines.push(lines[i]);
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Only include code blocks from testing sections
|
|
134
|
+
if (inTestingSection) {
|
|
135
|
+
blocks.push({ type: 'code', lang: lang.toLowerCase(), content: codeLines.join('\n') });
|
|
136
|
+
}
|
|
137
|
+
i++;
|
|
138
|
+
} else {
|
|
139
|
+
// Skip non-code content outside testing sections
|
|
140
|
+
if (inTestingSection) {
|
|
141
|
+
const textLines = [line];
|
|
142
|
+
i++;
|
|
143
|
+
|
|
144
|
+
while (i < lines.length && !lines[i].match(/^```/) && !lines[i].match(/^#{1,6}\s+/)) {
|
|
145
|
+
textLines.push(lines[i]);
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const text = textLines.join('\n').trim();
|
|
150
|
+
if (text) {
|
|
151
|
+
blocks.push({ type: 'text', content: text });
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return blocks;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function prompt(question) {
|
|
163
|
+
const rl = createInterface({
|
|
164
|
+
input: process.stdin,
|
|
165
|
+
output: process.stdout,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
rl.question(question, (answer) => {
|
|
170
|
+
rl.close();
|
|
171
|
+
resolve(answer.trim().toLowerCase());
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function runCode(lang, code) {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const config = langConfig[lang];
|
|
179
|
+
|
|
180
|
+
if (!config) {
|
|
181
|
+
console.log(`${colors.yellow}⚠ Skipping: ${lang}${colors.reset}`);
|
|
182
|
+
resolve({ success: true, skipped: true });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const proc = spawn(config.cmd, [...config.args, code], {
|
|
187
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
188
|
+
shell: false,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
proc.stdout.on('data', (data) => process.stdout.write(data));
|
|
192
|
+
proc.stderr.on('data', (data) => process.stderr.write(data));
|
|
193
|
+
|
|
194
|
+
proc.on('close', (exitCode) => {
|
|
195
|
+
resolve({ success: exitCode === 0, code: exitCode });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
proc.on('error', (err) => {
|
|
199
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
200
|
+
resolve({ success: false, error: err });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function run(content, skipConfirm = false) {
|
|
206
|
+
const blocks = parseMarkdown(content);
|
|
207
|
+
|
|
208
|
+
if (blocks.length === 0) {
|
|
209
|
+
console.log(`${colors.yellow}No "Testing" or "Tests" section found in the document.${colors.reset}`);
|
|
210
|
+
console.log(`${colors.dim}Add a section like "## Testing" with code blocks to run.${colors.reset}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const codeBlocks = blocks.filter(b => b.type === 'code');
|
|
215
|
+
|
|
216
|
+
if (codeBlocks.length === 0) {
|
|
217
|
+
console.log(`${colors.yellow}No code blocks found in Testing section.${colors.reset}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(`${colors.cyan}Found ${codeBlocks.length} code block(s) in Testing section.${colors.reset}\n`);
|
|
222
|
+
console.log(`${colors.cyan}─────────────────────────────────${colors.reset}\n`);
|
|
223
|
+
|
|
224
|
+
let codeBlockIndex = 0;
|
|
225
|
+
|
|
226
|
+
for (const block of blocks) {
|
|
227
|
+
if (block.type === 'text') {
|
|
228
|
+
// Echo non-code content
|
|
229
|
+
console.log(block.content);
|
|
230
|
+
console.log();
|
|
231
|
+
} else if (block.type === 'code') {
|
|
232
|
+
codeBlockIndex++;
|
|
233
|
+
|
|
234
|
+
// Show the code block
|
|
235
|
+
console.log(`${colors.cyan}┌─ [${codeBlockIndex}/${codeBlocks.length}] ${block.lang || 'code'} ─────────────────────${colors.reset}`);
|
|
236
|
+
console.log(`${colors.dim}${block.content}${colors.reset}`);
|
|
237
|
+
console.log(`${colors.cyan}└─────────────────────────────────${colors.reset}`);
|
|
238
|
+
|
|
239
|
+
// Prompt to run this block
|
|
240
|
+
if (!skipConfirm) {
|
|
241
|
+
const answer = await prompt(`${colors.yellow}Run this block? [y/N/q] ${colors.reset}`);
|
|
242
|
+
|
|
243
|
+
if (answer === 'q' || answer === 'quit') {
|
|
244
|
+
console.log('Aborted.');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
249
|
+
console.log(`${colors.dim}Skipped.${colors.reset}\n`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Execute the block
|
|
255
|
+
console.log(`${colors.cyan}├─ output ─────────────────────${colors.reset}`);
|
|
256
|
+
const result = await runCode(block.lang, block.content);
|
|
257
|
+
|
|
258
|
+
if (!result.skipped) {
|
|
259
|
+
if (result.success) {
|
|
260
|
+
console.log(`${colors.cyan}└─ ${colors.green}✓ success${colors.reset}`);
|
|
261
|
+
} else {
|
|
262
|
+
console.log(`${colors.cyan}└─ ${colors.red}✗ failed (exit ${result.code})${colors.reset}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
console.log();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log(`${colors.green}Done!${colors.reset}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showHelp() {
|
|
273
|
+
console.log(`${colors.cyan}prquicktest${colors.reset} - Run code blocks from GitHub PR descriptions
|
|
274
|
+
|
|
275
|
+
${colors.yellow}Usage:${colors.reset}
|
|
276
|
+
prquicktest <pr-url> Fetch PR and run code blocks
|
|
277
|
+
prquicktest <file.md> Run code blocks from a local file
|
|
278
|
+
|
|
279
|
+
${colors.yellow}Options:${colors.reset}
|
|
280
|
+
-y, --yes Run all blocks without prompting
|
|
281
|
+
-h, --help Show this help
|
|
282
|
+
-v, --version Show version
|
|
283
|
+
|
|
284
|
+
${colors.yellow}Examples:${colors.reset}
|
|
285
|
+
prquicktest https://github.com/owner/repo/pull/123
|
|
286
|
+
prquicktest ./test-instructions.md
|
|
287
|
+
prquicktest -y https://github.com/owner/repo/pull/123
|
|
288
|
+
|
|
289
|
+
${colors.yellow}How it works:${colors.reset}
|
|
290
|
+
Only runs code blocks under a "Testing", "Tests", or "Test" header.
|
|
291
|
+
The section ends when another header of equal or higher level appears.
|
|
292
|
+
|
|
293
|
+
${colors.yellow}Supported languages:${colors.reset}
|
|
294
|
+
bash, sh, shell Executed with bash -c
|
|
295
|
+
javascript, js, node Executed with node -e
|
|
296
|
+
python, py, python3 Executed with python3 -c
|
|
297
|
+
|
|
298
|
+
Requires GitHub CLI (gh): https://cli.github.com
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function main() {
|
|
303
|
+
const args = process.argv.slice(2);
|
|
304
|
+
|
|
305
|
+
// Parse flags
|
|
306
|
+
const skipConfirm = args.includes('-y') || args.includes('--yes');
|
|
307
|
+
const showHelpFlag = args.includes('-h') || args.includes('--help');
|
|
308
|
+
const showVersionFlag = args.includes('-v') || args.includes('--version');
|
|
309
|
+
|
|
310
|
+
// Get the target (non-flag argument)
|
|
311
|
+
const target = args.find(a => !a.startsWith('-'));
|
|
312
|
+
|
|
313
|
+
if (showHelpFlag || !target) {
|
|
314
|
+
showHelp();
|
|
315
|
+
process.exit(0);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (showVersionFlag) {
|
|
319
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
320
|
+
console.log(pkg.version);
|
|
321
|
+
process.exit(0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let content;
|
|
325
|
+
|
|
326
|
+
if (isGitHubPrUrl(target)) {
|
|
327
|
+
content = fetchPrDescription(target);
|
|
328
|
+
} else {
|
|
329
|
+
try {
|
|
330
|
+
content = readFileSync(target, 'utf-8');
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await run(content, skipConfirm);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
main().catch((err) => {
|
|
341
|
+
console.error(err);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prquicktest",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Run code blocks from GitHub PR descriptions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test test/*.test.mjs"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"prquicktest": "bin/prquicktest.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"github",
|
|
17
|
+
"pull-request",
|
|
18
|
+
"pr",
|
|
19
|
+
"test",
|
|
20
|
+
"cli",
|
|
21
|
+
"code-blocks",
|
|
22
|
+
"markdown"
|
|
23
|
+
],
|
|
24
|
+
"author": "Murphy <m@mrmurphy.dev>",
|
|
25
|
+
"license": "GPL-3.0",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/mrmurphy/prquicktest.git"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|