@humaan/patch-patrol 0.2.2 → 0.3.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 +9 -3
- package/dist/cli.js +22 -18
- package/dist/git.js +56 -13
- package/dist/options.js +43 -27
- package/dist/package-manager.js +17 -6
- package/dist/prompts.js +31 -1
- package/dist/repo-actions.js +193 -23
- package/dist/scanner.js +37 -2
- package/dist/shell.js +35 -2
- package/dist/targets.js +111 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Patch Patrol
|
|
2
2
|
|
|
3
|
-
Patch Patrol is an interactive CLI for rolling out advisory-driven package bumps across
|
|
3
|
+
Patch Patrol is an interactive CLI for rolling out advisory-driven package bumps across GitHub or local repositories.
|
|
4
4
|
|
|
5
|
-
It scans a repo or folder of repos, shows which projects are affected, then lets you choose exactly which projects should get update branches and pull requests.
|
|
5
|
+
It scans a GitHub owner, GitHub repo, local repo, or folder of local repos, shows which projects are affected, then lets you choose exactly which projects should get update branches and pull requests.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -39,7 +39,8 @@ The CLI guides you through two phases.
|
|
|
39
39
|
|
|
40
40
|
**1. Scan**
|
|
41
41
|
|
|
42
|
-
-
|
|
42
|
+
- Choose GitHub or local scanning.
|
|
43
|
+
- Enter a GitHub owner, GitHub repo, GitHub URL, local repo path, or local parent folder containing repos.
|
|
43
44
|
- Choose an advisory rule from the list.
|
|
44
45
|
- Choose which bump levels to include: major, minor, patch, or any combination.
|
|
45
46
|
- Review the affected-project summary.
|
|
@@ -56,6 +57,8 @@ Use arrow keys to move through selections, spacebar to toggle multiselect items,
|
|
|
56
57
|
|
|
57
58
|
For each selected project, Patch Patrol:
|
|
58
59
|
|
|
60
|
+
- Reads GitHub `package.json` files through the GitHub API during scan.
|
|
61
|
+
- Clones only selected GitHub repos into a temporary workspace for patching, or uses the selected local checkout.
|
|
59
62
|
- Checks the worktree is clean before editing.
|
|
60
63
|
- Checks out and fast-forwards the default branch.
|
|
61
64
|
- Creates an advisory-specific branch under `patch-patrol/`.
|
|
@@ -149,6 +152,8 @@ patch-patrol --list-rules
|
|
|
149
152
|
Scan without changing files:
|
|
150
153
|
|
|
151
154
|
```sh
|
|
155
|
+
patch-patrol --github humaan --rule next-may-2026 --dry-run
|
|
156
|
+
patch-patrol --github humaan/my-app --rule next-may-2026 --dry-run
|
|
152
157
|
patch-patrol --root /Users/sam/repos --rule next-may-2026 --dry-run
|
|
153
158
|
```
|
|
154
159
|
|
|
@@ -166,6 +171,7 @@ Bump filters compare the installed version in `package.json` with the advisory t
|
|
|
166
171
|
Update all affected repos without selection prompts:
|
|
167
172
|
|
|
168
173
|
```sh
|
|
174
|
+
patch-patrol --github humaan --rule next-may-2026 --yes
|
|
169
175
|
patch-patrol --root /Users/sam/repos --rule next-may-2026 --yes
|
|
170
176
|
```
|
|
171
177
|
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { intro, outro } from '@clack/prompts';
|
|
3
2
|
import { loadRules } from './advisories.js';
|
|
4
3
|
import { actionRepo } from './repo-actions.js';
|
|
5
4
|
import { finish, printRules } from './output.js';
|
|
6
|
-
import {
|
|
5
|
+
import { runScan } from './scanner.js';
|
|
7
6
|
import { getRuleOrThrow, parseArgs } from './options.js';
|
|
8
7
|
import { printAffectedRepos } from './format.js';
|
|
9
8
|
import { promptForAffectedRepos, promptForScanOptions } from './prompts.js';
|
|
9
|
+
import { resolveTargets } from './targets.js';
|
|
10
10
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
11
11
|
const options = parseArgs(argv);
|
|
12
12
|
const rules = await loadRules(options.advisoryDir);
|
|
@@ -19,23 +19,27 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
19
19
|
await promptForScanOptions(options, rules);
|
|
20
20
|
}
|
|
21
21
|
const rule = getRuleOrThrow(rules, options.rule);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
const targets = await resolveTargets(options);
|
|
23
|
+
try {
|
|
24
|
+
const affectedRepos = await runScan(targets.repos, targets.root, rule, options);
|
|
25
|
+
printAffectedRepos(affectedRepos, options);
|
|
26
|
+
if (affectedRepos.length === 0 || options.dryRun) {
|
|
27
|
+
if (options.interactive)
|
|
28
|
+
outro('Scan complete.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const selectedRepos = options.yes ? affectedRepos : await promptForAffectedRepos(affectedRepos, options);
|
|
32
|
+
if (selectedRepos.length === 0) {
|
|
33
|
+
finish(options, 'No projects selected. Nothing changed.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const affectedRepo of selectedRepos) {
|
|
37
|
+
await actionRepo(affectedRepo, rule, options);
|
|
38
|
+
}
|
|
27
39
|
if (options.interactive)
|
|
28
|
-
outro('
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
const selectedRepos = options.yes ? affectedRepos : await promptForAffectedRepos(affectedRepos, options);
|
|
32
|
-
if (selectedRepos.length === 0) {
|
|
33
|
-
finish(options, 'No projects selected. Nothing changed.');
|
|
34
|
-
return;
|
|
40
|
+
outro('Selected projects processed.');
|
|
35
41
|
}
|
|
36
|
-
|
|
37
|
-
await
|
|
42
|
+
finally {
|
|
43
|
+
await targets.cleanup?.();
|
|
38
44
|
}
|
|
39
|
-
if (options.interactive)
|
|
40
|
-
outro('Selected projects processed.');
|
|
41
45
|
}
|
package/dist/git.js
CHANGED
|
@@ -1,31 +1,65 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { promptForGithubRemote } from './prompts.js';
|
|
3
|
-
import { run } from './shell.js';
|
|
3
|
+
import { run, runAsync } from './shell.js';
|
|
4
4
|
export function ensureClean(repo) {
|
|
5
5
|
const status = run('git', ['status', '--porcelain'], repo, { capture: true });
|
|
6
6
|
if (status.stdout.trim())
|
|
7
7
|
throw new Error(`${repo} has uncommitted changes; skipping to avoid overwriting local work.`);
|
|
8
8
|
}
|
|
9
|
-
export function checkoutFreshBranch(repo, rule, options) {
|
|
9
|
+
export function checkoutFreshBranch(repo, rule, options, quiet = false) {
|
|
10
10
|
const defaultBranch = getDefaultBranch(repo);
|
|
11
|
-
run('git', ['checkout', defaultBranch], repo);
|
|
12
|
-
run('git', ['pull', '--ff-only'], repo);
|
|
13
|
-
run('git', ['checkout', '-B', getBranchName(rule, options)], repo);
|
|
11
|
+
run('git', ['checkout', defaultBranch], repo, { quiet });
|
|
12
|
+
run('git', ['pull', '--ff-only'], repo, { quiet });
|
|
13
|
+
run('git', ['checkout', '-B', getBranchName(rule, options)], repo, { quiet });
|
|
14
14
|
}
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
export async function checkoutFreshBranchAsync(repo, rule, options, quiet = false) {
|
|
16
|
+
const defaultBranch = getDefaultBranch(repo);
|
|
17
|
+
await runAsync('git', ['checkout', defaultBranch], repo, { quiet });
|
|
18
|
+
await runAsync('git', ['pull', '--ff-only'], repo, { quiet });
|
|
19
|
+
await runAsync('git', ['checkout', '-B', getBranchName(rule, options)], repo, { quiet });
|
|
20
|
+
}
|
|
21
|
+
export function getCheckoutState(repo) {
|
|
22
|
+
const commit = run('git', ['rev-parse', 'HEAD'], repo, { capture: true }).stdout.trim();
|
|
23
|
+
const branch = run('git', ['branch', '--show-current'], repo, { capture: true }).stdout.trim();
|
|
24
|
+
return { ref: branch || commit, commit, detached: !branch };
|
|
25
|
+
}
|
|
26
|
+
export function restoreCheckout(repo, state, quiet = false) {
|
|
27
|
+
run('git', ['checkout', state.ref], repo, { quiet });
|
|
28
|
+
}
|
|
29
|
+
export async function restoreCheckoutAsync(repo, state, quiet = false) {
|
|
30
|
+
await runAsync('git', ['checkout', state.ref], repo, { quiet });
|
|
31
|
+
}
|
|
32
|
+
export function commitChanges(repo, rule, quiet = false) {
|
|
33
|
+
run('git', ['add', ...getChangedFiles(repo)], repo, { quiet });
|
|
34
|
+
run('git', ['commit', '-m', rule.commitMessage], repo, { quiet });
|
|
35
|
+
}
|
|
36
|
+
export async function commitChangesAsync(repo, rule, quiet = false) {
|
|
37
|
+
await runAsync('git', ['add', ...getChangedFiles(repo)], repo, { quiet });
|
|
38
|
+
await runAsync('git', ['commit', '-m', rule.commitMessage], repo, { quiet });
|
|
39
|
+
}
|
|
40
|
+
export async function pushAndCreatePr(repo, rule, options, quiet = false) {
|
|
41
|
+
const branchName = getBranchName(rule, options);
|
|
42
|
+
const githubRemote = await ensureGithubRemote(repo);
|
|
43
|
+
run('git', ['push', '-u', githubRemote.name, branchName], repo, { quiet });
|
|
44
|
+
if (!githubRemote.ownerRepo)
|
|
45
|
+
throw new Error(`Could not determine GitHub repo for remote ${githubRemote.name}.`);
|
|
46
|
+
const prArgs = ['pr', 'create', '--repo', githubRemote.ownerRepo, '--head', branchName, '--title', rule.prTitle, '--body', getPrBody(rule)];
|
|
47
|
+
if (options.base)
|
|
48
|
+
prArgs.push('--base', options.base);
|
|
49
|
+
const result = run('gh', prArgs, repo, { quiet });
|
|
50
|
+
return quiet ? result.stdout.trim() || undefined : undefined;
|
|
18
51
|
}
|
|
19
|
-
export async function
|
|
52
|
+
export async function pushAndCreatePrAsync(repo, rule, options, quiet = false) {
|
|
20
53
|
const branchName = getBranchName(rule, options);
|
|
21
54
|
const githubRemote = await ensureGithubRemote(repo);
|
|
22
|
-
|
|
55
|
+
await runAsync('git', ['push', '-u', githubRemote.name, branchName], repo, { quiet });
|
|
23
56
|
if (!githubRemote.ownerRepo)
|
|
24
57
|
throw new Error(`Could not determine GitHub repo for remote ${githubRemote.name}.`);
|
|
25
|
-
const prArgs = ['pr', 'create', '--repo', githubRemote.ownerRepo, '--head', branchName, '--title', rule.prTitle, '--body', rule
|
|
58
|
+
const prArgs = ['pr', 'create', '--repo', githubRemote.ownerRepo, '--head', branchName, '--title', rule.prTitle, '--body', getPrBody(rule)];
|
|
26
59
|
if (options.base)
|
|
27
60
|
prArgs.push('--base', options.base);
|
|
28
|
-
|
|
61
|
+
const result = await runAsync('gh', prArgs, repo, { quiet });
|
|
62
|
+
return quiet ? result.stdout.trim() || undefined : undefined;
|
|
29
63
|
}
|
|
30
64
|
function getDefaultBranch(repo) {
|
|
31
65
|
const symbolic = run('git', ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], repo, { capture: true, allowFailure: true });
|
|
@@ -37,6 +71,15 @@ function getDefaultBranch(repo) {
|
|
|
37
71
|
function getBranchName(rule, options) {
|
|
38
72
|
return `${options.branchPrefix}/${rule.branchName}`;
|
|
39
73
|
}
|
|
74
|
+
function getPrBody(rule) {
|
|
75
|
+
return [
|
|
76
|
+
'This is an automated PR created by `@humaan/patch-patrol`.',
|
|
77
|
+
'',
|
|
78
|
+
`Rule: ${rule.title}`,
|
|
79
|
+
'',
|
|
80
|
+
rule.prBody,
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
40
83
|
function hasChanges(repo) {
|
|
41
84
|
return Boolean(run('git', ['status', '--porcelain'], repo, { capture: true }).stdout.trim());
|
|
42
85
|
}
|
|
@@ -56,7 +99,7 @@ async function ensureGithubRemote(repo) {
|
|
|
56
99
|
const suggestion = origin ? convertOriginToGithub(origin.url) : '';
|
|
57
100
|
const url = await promptForGithubRemote(path.basename(repo), suggestion, parseRemote);
|
|
58
101
|
const name = remotes.some((remote) => remote.name === 'github') ? 'github-pr' : 'github';
|
|
59
|
-
run('git', ['remote', 'add', name, url], repo);
|
|
102
|
+
run('git', ['remote', 'add', name, url], repo, { quiet: true });
|
|
60
103
|
return parseRemote(name, url);
|
|
61
104
|
}
|
|
62
105
|
function listRemotes(repo) {
|
package/dist/options.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import process from
|
|
3
|
-
import { BUMP_LEVELS, DEFAULT_ADVISORY_DIR, DEFAULT_BRANCH_PREFIX, DEFAULT_RULE_ID } from
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { BUMP_LEVELS, DEFAULT_ADVISORY_DIR, DEFAULT_BRANCH_PREFIX, DEFAULT_RULE_ID } from "./constants.js";
|
|
4
4
|
export function parseArgs(args) {
|
|
5
5
|
const options = {
|
|
6
|
+
source: "github",
|
|
6
7
|
root: process.cwd(),
|
|
8
|
+
github: undefined,
|
|
7
9
|
rule: DEFAULT_RULE_ID,
|
|
8
10
|
base: undefined,
|
|
9
11
|
branchPrefix: DEFAULT_BRANCH_PREFIX,
|
|
@@ -22,7 +24,7 @@ export function parseArgs(args) {
|
|
|
22
24
|
const arg = args[index];
|
|
23
25
|
const readValue = () => {
|
|
24
26
|
const value = args[index + 1];
|
|
25
|
-
if (!value || value.startsWith(
|
|
27
|
+
if (!value || value.startsWith("--"))
|
|
26
28
|
throw new Error(`Missing value for ${arg}`);
|
|
27
29
|
index += 1;
|
|
28
30
|
return value;
|
|
@@ -38,39 +40,48 @@ export function parseArgs(args) {
|
|
|
38
40
|
options.bumpLevels.add(level);
|
|
39
41
|
}
|
|
40
42
|
};
|
|
41
|
-
if (arg ===
|
|
43
|
+
if (arg === "--root") {
|
|
42
44
|
options.root = readValue();
|
|
43
|
-
|
|
45
|
+
options.source = "local";
|
|
46
|
+
}
|
|
47
|
+
else if (arg === "--github") {
|
|
48
|
+
options.github = readValue();
|
|
49
|
+
options.source = "github";
|
|
50
|
+
}
|
|
51
|
+
else if (arg === "--rule")
|
|
44
52
|
options.rule = readValue();
|
|
45
|
-
else if (arg ===
|
|
53
|
+
else if (arg === "--advisory-dir")
|
|
46
54
|
options.advisoryDir = path.resolve(readValue());
|
|
47
|
-
else if (arg ===
|
|
55
|
+
else if (arg === "--base")
|
|
48
56
|
options.base = readValue();
|
|
49
|
-
else if (arg ===
|
|
57
|
+
else if (arg === "--branch-prefix")
|
|
50
58
|
options.branchPrefix = readValue();
|
|
51
|
-
else if (arg ===
|
|
59
|
+
else if (arg === "--limit")
|
|
52
60
|
options.limit = Number(readValue());
|
|
53
|
-
else if (arg ===
|
|
54
|
-
addBumpFilter(readValue()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
else if (arg === "--bump")
|
|
62
|
+
addBumpFilter(readValue()
|
|
63
|
+
.split(",")
|
|
64
|
+
.map((level) => level.trim())
|
|
65
|
+
.filter(Boolean));
|
|
66
|
+
else if (arg === "--major")
|
|
67
|
+
addBumpFilter(["major"]);
|
|
68
|
+
else if (arg === "--minor")
|
|
69
|
+
addBumpFilter(["minor"]);
|
|
70
|
+
else if (arg === "--patch")
|
|
71
|
+
addBumpFilter(["patch"]);
|
|
72
|
+
else if (arg === "--dry-run")
|
|
62
73
|
options.dryRun = true;
|
|
63
|
-
else if (arg ===
|
|
74
|
+
else if (arg === "--interactive")
|
|
64
75
|
options.interactive = true;
|
|
65
|
-
else if (arg ===
|
|
76
|
+
else if (arg === "--list-rules")
|
|
66
77
|
options.listRules = true;
|
|
67
|
-
else if (arg ===
|
|
78
|
+
else if (arg === "--yes" || arg === "-y")
|
|
68
79
|
options.yes = true;
|
|
69
|
-
else if (arg ===
|
|
80
|
+
else if (arg === "--no-pr")
|
|
70
81
|
options.createPr = false;
|
|
71
|
-
else if (arg ===
|
|
82
|
+
else if (arg === "--skip-install")
|
|
72
83
|
options.skipInstall = true;
|
|
73
|
-
else if (arg ===
|
|
84
|
+
else if (arg === "--help" || arg === "-h") {
|
|
74
85
|
printHelp();
|
|
75
86
|
process.exit(0);
|
|
76
87
|
}
|
|
@@ -79,13 +90,15 @@ export function parseArgs(args) {
|
|
|
79
90
|
}
|
|
80
91
|
}
|
|
81
92
|
if (options.bumpLevels.size === 0)
|
|
82
|
-
throw new Error(
|
|
93
|
+
throw new Error("Select at least one bump level.");
|
|
94
|
+
if (options.source === "github" && !options.github)
|
|
95
|
+
throw new Error("Missing GitHub target.");
|
|
83
96
|
return options;
|
|
84
97
|
}
|
|
85
98
|
export function getRuleOrThrow(rules, ruleId) {
|
|
86
99
|
const rule = rules.get(ruleId);
|
|
87
100
|
if (!rule)
|
|
88
|
-
throw new Error(`Unknown rule "${ruleId}". Available rules: ${[...rules.keys()].join(
|
|
101
|
+
throw new Error(`Unknown rule "${ruleId}". Available rules: ${[...rules.keys()].join(", ")}`);
|
|
89
102
|
return rule;
|
|
90
103
|
}
|
|
91
104
|
function printHelp() {
|
|
@@ -94,8 +107,11 @@ function printHelp() {
|
|
|
94
107
|
Usage:
|
|
95
108
|
patch-patrol
|
|
96
109
|
patch-patrol --root /path/to/repos [options]
|
|
110
|
+
patch-patrol --github owner-or-owner/repo [options]
|
|
97
111
|
|
|
98
112
|
Options:
|
|
113
|
+
--root <path> Local repo or folder of repos. Default: current directory.
|
|
114
|
+
--github <target> GitHub owner, owner/repo, or github.com owner/repo URL.
|
|
99
115
|
--rule <name> Rule to apply. Default: ${DEFAULT_RULE_ID}
|
|
100
116
|
--advisory-dir <path> Directory containing advisory JSON files.
|
|
101
117
|
--base <branch> Base branch for PRs. Defaults to GitHub default branch.
|
package/dist/package-manager.js
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fsSyncExists } from './files.js';
|
|
3
|
-
import { run } from './shell.js';
|
|
4
|
-
export function refreshLockfile(repo) {
|
|
3
|
+
import { run, runAsync } from './shell.js';
|
|
4
|
+
export function refreshLockfile(repo, quiet = false) {
|
|
5
5
|
const manager = detectPackageManager(repo);
|
|
6
6
|
if (manager === 'pnpm')
|
|
7
|
-
run('pnpm', ['install', '--lockfile-only'], repo);
|
|
7
|
+
run('pnpm', ['install', '--lockfile-only'], repo, { quiet });
|
|
8
8
|
else if (manager === 'yarn')
|
|
9
|
-
run('yarn', ['install', '--mode=update-lockfile'], repo, { allowFailure: true }) || run('yarn', ['install'], repo);
|
|
9
|
+
run('yarn', ['install', '--mode=update-lockfile'], repo, { allowFailure: true, quiet }) || run('yarn', ['install'], repo, { quiet });
|
|
10
10
|
else if (manager === 'bun')
|
|
11
|
-
run('bun', ['install', '--lockfile-only'], repo);
|
|
11
|
+
run('bun', ['install', '--lockfile-only'], repo, { quiet });
|
|
12
12
|
else
|
|
13
|
-
run('npm', ['install', '--package-lock-only'], repo);
|
|
13
|
+
run('npm', ['install', '--package-lock-only'], repo, { quiet });
|
|
14
|
+
}
|
|
15
|
+
export async function refreshLockfileAsync(repo, quiet = false) {
|
|
16
|
+
const manager = detectPackageManager(repo);
|
|
17
|
+
if (manager === 'pnpm')
|
|
18
|
+
await runAsync('pnpm', ['install', '--lockfile-only'], repo, { quiet });
|
|
19
|
+
else if (manager === 'yarn')
|
|
20
|
+
await runAsync('yarn', ['install', '--mode=update-lockfile'], repo, { allowFailure: true, quiet }) || await runAsync('yarn', ['install'], repo, { quiet });
|
|
21
|
+
else if (manager === 'bun')
|
|
22
|
+
await runAsync('bun', ['install', '--lockfile-only'], repo, { quiet });
|
|
23
|
+
else
|
|
24
|
+
await runAsync('npm', ['install', '--package-lock-only'], repo, { quiet });
|
|
14
25
|
}
|
|
15
26
|
function detectPackageManager(repo) {
|
|
16
27
|
if (fsSyncExists(path.join(repo, 'pnpm-lock.yaml')))
|
package/dist/prompts.js
CHANGED
|
@@ -4,10 +4,26 @@ import { cancel, confirm, isCancel, multiselect, select, text } from '@clack/pro
|
|
|
4
4
|
import { parseSelection } from './selection.js';
|
|
5
5
|
import { summarizeUpdates } from './format.js';
|
|
6
6
|
export async function promptForScanOptions(options, rules) {
|
|
7
|
-
options.
|
|
7
|
+
options.source = await promptForSource(options.source);
|
|
8
|
+
if (options.source === 'github')
|
|
9
|
+
options.github = await promptForGithubTarget(options.github ?? '');
|
|
10
|
+
else
|
|
11
|
+
options.root = await promptForRoot(options.root);
|
|
8
12
|
options.rule = await promptForRule(rules, options.rule);
|
|
9
13
|
options.bumpLevels = new Set(await promptForBumpLevels(options.bumpLevels));
|
|
10
14
|
}
|
|
15
|
+
export async function promptForSource(defaultSource) {
|
|
16
|
+
const source = await select({
|
|
17
|
+
message: 'Where should Patch Patrol scan?',
|
|
18
|
+
initialValue: defaultSource,
|
|
19
|
+
options: [
|
|
20
|
+
{ value: 'github', label: 'GitHub', hint: 'Clone repos from an owner or repo URL' },
|
|
21
|
+
{ value: 'local', label: 'Local', hint: 'Use a local repo or folder of repos' },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
handleCancel(source);
|
|
25
|
+
return source;
|
|
26
|
+
}
|
|
11
27
|
export async function promptForRoot(defaultRoot) {
|
|
12
28
|
const root = await text({
|
|
13
29
|
message: 'Repo or folder of repos',
|
|
@@ -24,6 +40,20 @@ export async function promptForRoot(defaultRoot) {
|
|
|
24
40
|
handleCancel(root);
|
|
25
41
|
return path.resolve(root);
|
|
26
42
|
}
|
|
43
|
+
export async function promptForGithubTarget(defaultTarget) {
|
|
44
|
+
const target = await text({
|
|
45
|
+
message: 'GitHub owner, owner/repo, or repo URL',
|
|
46
|
+
placeholder: 'humaan or humaan/my-app',
|
|
47
|
+
initialValue: defaultTarget,
|
|
48
|
+
validate(value) {
|
|
49
|
+
if (!value.trim())
|
|
50
|
+
return 'Enter a GitHub owner, owner/repo, or repo URL.';
|
|
51
|
+
return undefined;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
handleCancel(target);
|
|
55
|
+
return target.trim();
|
|
56
|
+
}
|
|
27
57
|
export async function promptForRule(rules, defaultRuleId) {
|
|
28
58
|
const selectedRule = await select({
|
|
29
59
|
message: 'Select an advisory rule',
|
package/dist/repo-actions.js
CHANGED
|
@@ -1,41 +1,211 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { spinner } from '@clack/prompts';
|
|
3
4
|
import { getUpdates } from './advisories.js';
|
|
4
|
-
import { checkoutFreshBranch, commitChanges, ensureClean, hasChanges, pushAndCreatePr } from './git.js';
|
|
5
|
-
import { refreshLockfile } from './package-manager.js';
|
|
5
|
+
import { checkoutFreshBranch, checkoutFreshBranchAsync, commitChanges, commitChangesAsync, ensureClean, getCheckoutState, hasChanges, pushAndCreatePr, pushAndCreatePrAsync, restoreCheckout, restoreCheckoutAsync } from './git.js';
|
|
6
|
+
import { refreshLockfile, refreshLockfileAsync } from './package-manager.js';
|
|
7
|
+
import { cloneGithubRepo } from './targets.js';
|
|
6
8
|
export async function actionRepo(affectedRepo, rule, options) {
|
|
9
|
+
const repoName = path.basename(affectedRepo.repo);
|
|
10
|
+
const status = createActionStatus(options, repoName);
|
|
11
|
+
try {
|
|
12
|
+
const result = affectedRepo.github
|
|
13
|
+
? await actionGithubRepo(affectedRepo, rule, options, status, repoName)
|
|
14
|
+
: await actionLocalRepo(affectedRepo, rule, options, status, repoName);
|
|
15
|
+
status?.stop(result);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
status?.stop(`Processing ${repoName} failed.`);
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function actionGithubRepo(affectedRepo, rule, options, status, repoName) {
|
|
23
|
+
if (affectedRepo.github) {
|
|
24
|
+
status?.message(`Cloning ${repoName}`);
|
|
25
|
+
const clone = await cloneGithubRepo(affectedRepo.github, options.interactive);
|
|
26
|
+
let cleaned = false;
|
|
27
|
+
try {
|
|
28
|
+
const result = await actionLocalRepo({
|
|
29
|
+
repo: clone.repoPath,
|
|
30
|
+
packageJsonPath: path.join(clone.repoPath, 'package.json'),
|
|
31
|
+
updates: affectedRepo.updates,
|
|
32
|
+
}, rule, options, status, repoName);
|
|
33
|
+
status?.message(`Cleaning up ${repoName}`);
|
|
34
|
+
await clone.cleanup();
|
|
35
|
+
cleaned = true;
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
if (!cleaned) {
|
|
40
|
+
status?.message(`Cleaning up ${repoName}`);
|
|
41
|
+
await clone.cleanup();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`${repoName} is not a GitHub repo target.`);
|
|
46
|
+
}
|
|
47
|
+
async function actionLocalRepo(affectedRepo, rule, options, status, repoName) {
|
|
7
48
|
const { repo, packageJsonPath, updates } = affectedRepo;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
49
|
+
if (!options.interactive) {
|
|
50
|
+
console.log(`\n${path.basename(repo)}`);
|
|
51
|
+
for (const update of updates) {
|
|
52
|
+
console.log(` [${update.bumpLevel}] ${update.section}.${update.name}: ${update.from} -> ${update.to} (${update.reason})`);
|
|
53
|
+
}
|
|
11
54
|
}
|
|
12
55
|
ensureClean(repo);
|
|
13
|
-
|
|
14
|
-
|
|
56
|
+
const originalCheckout = getCheckoutState(repo);
|
|
57
|
+
status?.message(`Preparing update branch for ${repoName}`);
|
|
58
|
+
if (options.interactive)
|
|
59
|
+
await checkoutFreshBranchAsync(repo, rule, options, true);
|
|
60
|
+
else
|
|
61
|
+
checkoutFreshBranch(repo, rule, options);
|
|
62
|
+
const packageJsonText = await fs.readFile(packageJsonPath, 'utf8');
|
|
63
|
+
const packageJson = JSON.parse(packageJsonText);
|
|
15
64
|
const freshUpdates = getUpdates(packageJson, rule, options.bumpLevels);
|
|
16
65
|
if (freshUpdates.length === 0) {
|
|
17
|
-
|
|
18
|
-
|
|
66
|
+
if (!options.interactive)
|
|
67
|
+
console.log(' No affected dependencies after updating the base branch; skipping.');
|
|
68
|
+
return `Skipped ${repoName}: no affected dependencies after updating the base branch.`;
|
|
69
|
+
}
|
|
70
|
+
status?.message(`Updating package files for ${repoName}`);
|
|
71
|
+
await fs.writeFile(packageJsonPath, applyUpdates(packageJsonText, freshUpdates));
|
|
72
|
+
if (!options.skipInstall) {
|
|
73
|
+
status?.message(`Refreshing lockfile for ${repoName}`);
|
|
74
|
+
if (options.interactive)
|
|
75
|
+
await refreshLockfileAsync(repo, true);
|
|
76
|
+
else
|
|
77
|
+
refreshLockfile(repo);
|
|
19
78
|
}
|
|
20
|
-
applyUpdates(packageJson, freshUpdates);
|
|
21
|
-
await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
22
|
-
if (!options.skipInstall)
|
|
23
|
-
refreshLockfile(repo);
|
|
24
79
|
if (!hasChanges(repo)) {
|
|
25
|
-
|
|
26
|
-
|
|
80
|
+
if (!options.interactive)
|
|
81
|
+
console.log(' No changes after update; skipping.');
|
|
82
|
+
return `Skipped ${repoName}: no changes after update.`;
|
|
27
83
|
}
|
|
28
|
-
|
|
84
|
+
status?.message(`Committing changes for ${repoName}`);
|
|
85
|
+
if (options.interactive)
|
|
86
|
+
await commitChangesAsync(repo, rule, true);
|
|
87
|
+
else
|
|
88
|
+
commitChanges(repo, rule);
|
|
29
89
|
if (!options.createPr) {
|
|
30
|
-
|
|
31
|
-
|
|
90
|
+
if (!options.interactive)
|
|
91
|
+
console.log(' Updated locally; PR creation disabled.');
|
|
92
|
+
return `Updated ${repoName} locally; PR creation disabled.`;
|
|
32
93
|
}
|
|
33
|
-
|
|
94
|
+
status?.message(`Creating PR for ${repoName}`);
|
|
95
|
+
const prUrl = options.interactive ? await pushAndCreatePrAsync(repo, rule, options, true) : await pushAndCreatePr(repo, rule, options);
|
|
96
|
+
status?.message(`Restoring checkout for ${repoName}`);
|
|
97
|
+
if (options.interactive)
|
|
98
|
+
await restoreCheckoutAsync(repo, originalCheckout, true);
|
|
99
|
+
else
|
|
100
|
+
restoreCheckout(repo, originalCheckout);
|
|
101
|
+
return prUrl ? `Created PR for ${repoName}: ${prUrl}` : `Created PR for ${repoName}.`;
|
|
102
|
+
}
|
|
103
|
+
function createActionStatus(options, repoName) {
|
|
104
|
+
if (!options.interactive)
|
|
105
|
+
return null;
|
|
106
|
+
const statusSpinner = spinner();
|
|
107
|
+
statusSpinner.start(`Processing ${repoName}`);
|
|
108
|
+
return statusSpinner;
|
|
34
109
|
}
|
|
35
|
-
function applyUpdates(
|
|
110
|
+
function applyUpdates(packageJsonText, updates) {
|
|
111
|
+
let updatedPackageJsonText = packageJsonText;
|
|
36
112
|
for (const update of updates) {
|
|
37
|
-
|
|
38
|
-
if (dependencies)
|
|
39
|
-
dependencies[update.name] = update.to;
|
|
113
|
+
updatedPackageJsonText = applyUpdate(updatedPackageJsonText, update);
|
|
40
114
|
}
|
|
115
|
+
JSON.parse(updatedPackageJsonText);
|
|
116
|
+
return updatedPackageJsonText;
|
|
117
|
+
}
|
|
118
|
+
function applyUpdate(packageJsonText, update) {
|
|
119
|
+
const sectionRange = findObjectRange(packageJsonText, update.section);
|
|
120
|
+
if (!sectionRange)
|
|
121
|
+
throw new Error(`Could not find ${update.section} in package.json.`);
|
|
122
|
+
const sectionText = packageJsonText.slice(sectionRange.start, sectionRange.end);
|
|
123
|
+
const dependencyPattern = new RegExp(`(${escapeRegExp(JSON.stringify(update.name))}\\s*:\\s*)("(?:\\\\.|[^"\\\\])*")`);
|
|
124
|
+
const match = dependencyPattern.exec(sectionText);
|
|
125
|
+
if (!match?.[2])
|
|
126
|
+
throw new Error(`Could not find ${update.section}.${update.name} in package.json.`);
|
|
127
|
+
if (JSON.parse(match[2]) !== update.from) {
|
|
128
|
+
throw new Error(`Expected ${update.section}.${update.name} to be ${update.from}, but found ${JSON.parse(match[2])}.`);
|
|
129
|
+
}
|
|
130
|
+
const updatedSectionText = sectionText.replace(dependencyPattern, `$1${JSON.stringify(update.to)}`);
|
|
131
|
+
return `${packageJsonText.slice(0, sectionRange.start)}${updatedSectionText}${packageJsonText.slice(sectionRange.end)}`;
|
|
132
|
+
}
|
|
133
|
+
function findObjectRange(jsonText, propertyName) {
|
|
134
|
+
let depth = 0;
|
|
135
|
+
let inString = false;
|
|
136
|
+
let escaped = false;
|
|
137
|
+
let stringStart = -1;
|
|
138
|
+
for (let index = 0; index < jsonText.length; index += 1) {
|
|
139
|
+
const char = jsonText[index];
|
|
140
|
+
if (escaped) {
|
|
141
|
+
escaped = false;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (char === '\\') {
|
|
145
|
+
escaped = inString;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (char === '"') {
|
|
149
|
+
if (!inString) {
|
|
150
|
+
inString = true;
|
|
151
|
+
stringStart = index;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
inString = false;
|
|
155
|
+
const colonIndex = skipWhitespace(jsonText, index + 1);
|
|
156
|
+
if (depth !== 1 || jsonText[colonIndex] !== ':' || JSON.parse(jsonText.slice(stringStart, index + 1)) !== propertyName) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const objectStart = skipWhitespace(jsonText, colonIndex + 1);
|
|
160
|
+
if (jsonText[objectStart] !== '{')
|
|
161
|
+
return null;
|
|
162
|
+
const objectEnd = findObjectEnd(jsonText, objectStart);
|
|
163
|
+
return objectEnd === -1 ? null : { start: objectStart, end: objectEnd };
|
|
164
|
+
}
|
|
165
|
+
if (inString)
|
|
166
|
+
continue;
|
|
167
|
+
if (char === '{' || char === '[')
|
|
168
|
+
depth += 1;
|
|
169
|
+
if (char === '}' || char === ']')
|
|
170
|
+
depth -= 1;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function findObjectEnd(jsonText, objectStart) {
|
|
175
|
+
let depth = 0;
|
|
176
|
+
let inString = false;
|
|
177
|
+
let escaped = false;
|
|
178
|
+
for (let index = objectStart; index < jsonText.length; index += 1) {
|
|
179
|
+
const char = jsonText[index];
|
|
180
|
+
if (escaped) {
|
|
181
|
+
escaped = false;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (char === '\\') {
|
|
185
|
+
escaped = inString;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (char === '"') {
|
|
189
|
+
inString = !inString;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (inString)
|
|
193
|
+
continue;
|
|
194
|
+
if (char === '{')
|
|
195
|
+
depth += 1;
|
|
196
|
+
if (char === '}')
|
|
197
|
+
depth -= 1;
|
|
198
|
+
if (depth === 0)
|
|
199
|
+
return index + 1;
|
|
200
|
+
}
|
|
201
|
+
return -1;
|
|
202
|
+
}
|
|
203
|
+
function skipWhitespace(value, start) {
|
|
204
|
+
let index = start;
|
|
205
|
+
while (/\s/.test(value[index] ?? ''))
|
|
206
|
+
index += 1;
|
|
207
|
+
return index;
|
|
208
|
+
}
|
|
209
|
+
function escapeRegExp(value) {
|
|
210
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
211
|
}
|
package/dist/scanner.js
CHANGED
|
@@ -3,13 +3,15 @@ import path from 'node:path';
|
|
|
3
3
|
import { spinner } from '@clack/prompts';
|
|
4
4
|
import { getUpdates } from './advisories.js';
|
|
5
5
|
import { exists } from './files.js';
|
|
6
|
+
import { run } from './shell.js';
|
|
6
7
|
export async function runScan(repos, root, rule, options) {
|
|
8
|
+
const targetDescription = options.source === 'github' ? `${repos.length} GitHub repos via the GitHub API` : `${repos.length} repos in ${root}`;
|
|
7
9
|
if (!options.interactive) {
|
|
8
|
-
console.log(`Scanning ${
|
|
10
|
+
console.log(`Scanning ${targetDescription} using rule: ${rule.title}`);
|
|
9
11
|
return scanRepos(repos, rule, options);
|
|
10
12
|
}
|
|
11
13
|
const scanSpinner = spinner();
|
|
12
|
-
scanSpinner.start(`Scanning ${
|
|
14
|
+
scanSpinner.start(`Scanning ${targetDescription}`);
|
|
13
15
|
const affectedRepos = await scanRepos(repos, rule, options);
|
|
14
16
|
scanSpinner.stop(`Found ${affectedRepos.length} affected repo${affectedRepos.length === 1 ? '' : 's'}.`);
|
|
15
17
|
return affectedRepos;
|
|
@@ -44,6 +46,11 @@ async function scanRepos(repos, rule, options) {
|
|
|
44
46
|
return affectedRepos;
|
|
45
47
|
}
|
|
46
48
|
async function scanRepo(repo, rule, options) {
|
|
49
|
+
if (repo.source === 'github')
|
|
50
|
+
return scanGithubRepo(repo, rule, options);
|
|
51
|
+
return scanLocalRepo(repo.path, rule, options);
|
|
52
|
+
}
|
|
53
|
+
async function scanLocalRepo(repo, rule, options) {
|
|
47
54
|
const packageJsonPath = path.join(repo, 'package.json');
|
|
48
55
|
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
|
49
56
|
const updates = getUpdates(packageJson, rule, options.bumpLevels);
|
|
@@ -51,3 +58,31 @@ async function scanRepo(repo, rule, options) {
|
|
|
51
58
|
return null;
|
|
52
59
|
return { repo, packageJsonPath, updates };
|
|
53
60
|
}
|
|
61
|
+
async function scanGithubRepo(repo, rule, options) {
|
|
62
|
+
const packageJsonText = getGithubPackageJson(repo);
|
|
63
|
+
if (!packageJsonText)
|
|
64
|
+
return null;
|
|
65
|
+
const packageJson = JSON.parse(packageJsonText);
|
|
66
|
+
const updates = getUpdates(packageJson, rule, options.bumpLevels);
|
|
67
|
+
if (updates.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
return {
|
|
70
|
+
repo: repo.nameWithOwner,
|
|
71
|
+
packageJsonPath: 'package.json',
|
|
72
|
+
updates,
|
|
73
|
+
github: repo,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function getGithubPackageJson(repo) {
|
|
77
|
+
const result = run('gh', [
|
|
78
|
+
'api',
|
|
79
|
+
`repos/${repo.nameWithOwner}/contents/package.json`,
|
|
80
|
+
'--method',
|
|
81
|
+
'GET',
|
|
82
|
+
'--field',
|
|
83
|
+
`ref=${repo.defaultBranch}`,
|
|
84
|
+
'--header',
|
|
85
|
+
'Accept: application/vnd.github.raw',
|
|
86
|
+
], process.cwd(), { capture: true, allowFailure: true });
|
|
87
|
+
return result?.stdout.trim() ? result.stdout : null;
|
|
88
|
+
}
|
package/dist/shell.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
export function run(command, args, cwd, options = {}) {
|
|
3
3
|
const result = spawnSync(command, args, {
|
|
4
4
|
cwd,
|
|
5
5
|
encoding: 'utf8',
|
|
6
|
-
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
6
|
+
stdio: options.capture || options.quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
7
7
|
});
|
|
8
8
|
if (result.status !== 0) {
|
|
9
9
|
if (options.allowFailure)
|
|
@@ -13,3 +13,36 @@ export function run(command, args, cwd, options = {}) {
|
|
|
13
13
|
}
|
|
14
14
|
return result;
|
|
15
15
|
}
|
|
16
|
+
export async function runAsync(command, args, cwd, options = {}) {
|
|
17
|
+
const capture = options.capture || options.quiet;
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const child = spawn(command, args, {
|
|
20
|
+
cwd,
|
|
21
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
22
|
+
});
|
|
23
|
+
const stdout = [];
|
|
24
|
+
const stderr = [];
|
|
25
|
+
if (capture) {
|
|
26
|
+
child.stdout?.on('data', (chunk) => stdout.push(chunk));
|
|
27
|
+
child.stderr?.on('data', (chunk) => stderr.push(chunk));
|
|
28
|
+
}
|
|
29
|
+
child.on('error', reject);
|
|
30
|
+
child.on('close', (status) => {
|
|
31
|
+
const result = {
|
|
32
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
33
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
34
|
+
status,
|
|
35
|
+
};
|
|
36
|
+
if (status !== 0) {
|
|
37
|
+
if (options.allowFailure) {
|
|
38
|
+
resolve(null);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const stderrText = result.stderr ? `\n${result.stderr}` : '';
|
|
42
|
+
reject(new Error(`${command} ${args.join(' ')} failed in ${cwd}.${stderrText}`));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
resolve(result);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
package/dist/targets.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spinner } from '@clack/prompts';
|
|
6
|
+
import { run, runAsync } from './shell.js';
|
|
7
|
+
import { findRepos } from './scanner.js';
|
|
8
|
+
const cleanupPaths = new Set();
|
|
9
|
+
let cleanupHooksRegistered = false;
|
|
10
|
+
export async function resolveTargets(options) {
|
|
11
|
+
if (options.source === 'github')
|
|
12
|
+
return resolveGithubTargets(options.github ?? '', options);
|
|
13
|
+
const root = path.resolve(options.root);
|
|
14
|
+
return { root, repos: (await findRepos(root)).map((repoPath) => ({ source: 'local', path: repoPath })) };
|
|
15
|
+
}
|
|
16
|
+
export async function cloneGithubRepo(repo, quiet = false) {
|
|
17
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-patrol-'));
|
|
18
|
+
registerCleanupPath(root);
|
|
19
|
+
try {
|
|
20
|
+
const repoPath = path.join(root, getRepoName(repo));
|
|
21
|
+
if (quiet)
|
|
22
|
+
await runAsync('git', ['clone', repo.sshUrl, repoPath], process.cwd(), { quiet });
|
|
23
|
+
else
|
|
24
|
+
run('git', ['clone', repo.sshUrl, repoPath], process.cwd());
|
|
25
|
+
return {
|
|
26
|
+
cleanup: async () => {
|
|
27
|
+
unregisterCleanupPath(root);
|
|
28
|
+
await removeCleanupPath(root);
|
|
29
|
+
},
|
|
30
|
+
repoPath,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
unregisterCleanupPath(root);
|
|
35
|
+
await removeCleanupPath(root);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function resolveGithubTargets(target, options) {
|
|
40
|
+
const targetSpinner = options.interactive ? spinner() : null;
|
|
41
|
+
targetSpinner?.start(`Loading GitHub repos from ${target}`);
|
|
42
|
+
const repos = getGithubRepos(target);
|
|
43
|
+
targetSpinner?.stop(`Loaded ${repos.length} GitHub repo${repos.length === 1 ? '' : 's'}.`);
|
|
44
|
+
return { root: normalizeGithubTarget(target), repos: repos.map(toGithubRepoTarget) };
|
|
45
|
+
}
|
|
46
|
+
function getGithubRepos(target) {
|
|
47
|
+
const normalizedTarget = normalizeGithubTarget(target);
|
|
48
|
+
if (isGithubRepoTarget(normalizedTarget))
|
|
49
|
+
return [getGithubRepo(normalizedTarget)];
|
|
50
|
+
const result = run('gh', ['repo', 'list', normalizedTarget, '--json', 'nameWithOwner,sshUrl,defaultBranchRef', '--limit', '1000'], process.cwd(), { capture: true });
|
|
51
|
+
return JSON.parse(result.stdout);
|
|
52
|
+
}
|
|
53
|
+
function getGithubRepo(target) {
|
|
54
|
+
const result = run('gh', ['repo', 'view', target, '--json', 'nameWithOwner,sshUrl,defaultBranchRef'], process.cwd(), { capture: true });
|
|
55
|
+
return JSON.parse(result.stdout);
|
|
56
|
+
}
|
|
57
|
+
function toGithubRepoTarget(repo) {
|
|
58
|
+
return {
|
|
59
|
+
source: 'github',
|
|
60
|
+
nameWithOwner: repo.nameWithOwner,
|
|
61
|
+
sshUrl: repo.sshUrl,
|
|
62
|
+
defaultBranch: repo.defaultBranchRef.name,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function getRepoName(repo) {
|
|
66
|
+
return repo.nameWithOwner.split('/').at(-1) ?? repo.nameWithOwner;
|
|
67
|
+
}
|
|
68
|
+
function normalizeGithubTarget(target) {
|
|
69
|
+
const trimmed = target.trim().replace(/\.git$/, '');
|
|
70
|
+
const orgMatch = trimmed.match(/github\.com\/orgs\/(?<owner>[^/#?]+)/i);
|
|
71
|
+
if (orgMatch?.groups?.owner)
|
|
72
|
+
return orgMatch.groups.owner;
|
|
73
|
+
const userMatch = trimmed.match(/github\.com\/users\/(?<owner>[^/#?]+)/i);
|
|
74
|
+
if (userMatch?.groups?.owner)
|
|
75
|
+
return userMatch.groups.owner;
|
|
76
|
+
const repoMatch = trimmed.match(/github\.com[:/](?<owner>[^/]+)\/(?<repo>[^/#?]+)/i);
|
|
77
|
+
if (repoMatch?.groups?.owner && repoMatch.groups.repo)
|
|
78
|
+
return `${repoMatch.groups.owner}/${repoMatch.groups.repo}`;
|
|
79
|
+
const ownerMatch = trimmed.match(/github\.com\/(?<owner>[^/#?]+)/i);
|
|
80
|
+
if (ownerMatch?.groups?.owner)
|
|
81
|
+
return ownerMatch.groups.owner;
|
|
82
|
+
return trimmed.replace(/^@/, '');
|
|
83
|
+
}
|
|
84
|
+
function isGithubRepoTarget(target) {
|
|
85
|
+
return /^[^/]+\/[^/]+$/.test(target);
|
|
86
|
+
}
|
|
87
|
+
function registerCleanupPath(cleanupPath) {
|
|
88
|
+
cleanupPaths.add(cleanupPath);
|
|
89
|
+
if (cleanupHooksRegistered)
|
|
90
|
+
return;
|
|
91
|
+
cleanupHooksRegistered = true;
|
|
92
|
+
process.once('exit', removeRegisteredCleanupPathsSync);
|
|
93
|
+
process.once('SIGINT', () => exitAfterCleanup(130));
|
|
94
|
+
process.once('SIGTERM', () => exitAfterCleanup(143));
|
|
95
|
+
}
|
|
96
|
+
function unregisterCleanupPath(cleanupPath) {
|
|
97
|
+
cleanupPaths.delete(cleanupPath);
|
|
98
|
+
}
|
|
99
|
+
async function removeCleanupPath(cleanupPath) {
|
|
100
|
+
await fs.rm(cleanupPath, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
function exitAfterCleanup(code) {
|
|
103
|
+
removeRegisteredCleanupPathsSync();
|
|
104
|
+
process.exit(code);
|
|
105
|
+
}
|
|
106
|
+
function removeRegisteredCleanupPathsSync() {
|
|
107
|
+
for (const cleanupPath of cleanupPaths) {
|
|
108
|
+
fsSync.rmSync(cleanupPath, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
cleanupPaths.clear();
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humaan/patch-patrol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Scan local repositories for advisory-driven package bumps, update them, and open pull requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsc",
|
|
40
|
+
"start": "node bin/patch-patrol.js",
|
|
40
41
|
"typecheck": "tsc --noEmit",
|
|
41
42
|
"check": "pnpm run typecheck && pnpm run build && node --check bin/patch-patrol.js && node --check dist/*.js",
|
|
42
43
|
"scan:dry": "pnpm run build && node bin/patch-patrol.js --root .. --dry-run --no-pr"
|