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 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
+ }