@humaan/patch-patrol 0.3.1 → 0.4.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/dist/cli.js +2 -4
- package/dist/git.js +37 -8
- package/dist/options.js +2 -2
- package/dist/repo-actions.js +72 -25
- package/dist/scanner.js +86 -17
- package/dist/spinner.js +12 -0
- package/dist/targets.js +8 -8
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { intro, outro } from '@clack/prompts';
|
|
2
2
|
import { loadRules } from './advisories.js';
|
|
3
|
-
import {
|
|
3
|
+
import { actionRepos } from './repo-actions.js';
|
|
4
4
|
import { finish, printRules } from './output.js';
|
|
5
5
|
import { runScan } from './scanner.js';
|
|
6
6
|
import { getRuleOrThrow, parseArgs } from './options.js';
|
|
@@ -33,9 +33,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
33
33
|
finish(options, 'No projects selected. Nothing changed.');
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
await actionRepo(affectedRepo, rule, options);
|
|
38
|
-
}
|
|
36
|
+
await actionRepos(selectedRepos, rule, options);
|
|
39
37
|
if (options.interactive)
|
|
40
38
|
outro('Selected projects processed.');
|
|
41
39
|
}
|
package/dist/git.js
CHANGED
|
@@ -6,6 +6,11 @@ export function ensureClean(repo) {
|
|
|
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 async function ensureCleanAsync(repo) {
|
|
10
|
+
const status = await runAsync('git', ['status', '--porcelain'], repo, { capture: true });
|
|
11
|
+
if (status.stdout.trim())
|
|
12
|
+
throw new Error(`${repo} has uncommitted changes; skipping to avoid overwriting local work.`);
|
|
13
|
+
}
|
|
9
14
|
export function checkoutFreshBranch(repo, rule, options, quiet = false) {
|
|
10
15
|
const defaultBranch = getDefaultBranch(repo);
|
|
11
16
|
run('git', ['checkout', defaultBranch], repo, { quiet });
|
|
@@ -13,7 +18,7 @@ export function checkoutFreshBranch(repo, rule, options, quiet = false) {
|
|
|
13
18
|
run('git', ['checkout', '-B', getBranchName(rule, options)], repo, { quiet });
|
|
14
19
|
}
|
|
15
20
|
export async function checkoutFreshBranchAsync(repo, rule, options, quiet = false) {
|
|
16
|
-
const defaultBranch =
|
|
21
|
+
const defaultBranch = await getDefaultBranchAsync(repo);
|
|
17
22
|
await runAsync('git', ['checkout', defaultBranch], repo, { quiet });
|
|
18
23
|
await runAsync('git', ['pull', '--ff-only'], repo, { quiet });
|
|
19
24
|
await runAsync('git', ['checkout', '-B', getBranchName(rule, options)], repo, { quiet });
|
|
@@ -23,6 +28,11 @@ export function getCheckoutState(repo) {
|
|
|
23
28
|
const branch = run('git', ['branch', '--show-current'], repo, { capture: true }).stdout.trim();
|
|
24
29
|
return { ref: branch || commit, commit, detached: !branch };
|
|
25
30
|
}
|
|
31
|
+
export async function getCheckoutStateAsync(repo) {
|
|
32
|
+
const commit = (await runAsync('git', ['rev-parse', 'HEAD'], repo, { capture: true })).stdout.trim();
|
|
33
|
+
const branch = (await runAsync('git', ['branch', '--show-current'], repo, { capture: true })).stdout.trim();
|
|
34
|
+
return { ref: branch || commit, commit, detached: !branch };
|
|
35
|
+
}
|
|
26
36
|
export function restoreCheckout(repo, state, quiet = false) {
|
|
27
37
|
run('git', ['checkout', state.ref], repo, { quiet });
|
|
28
38
|
}
|
|
@@ -34,7 +44,7 @@ export function commitChanges(repo, rule, quiet = false) {
|
|
|
34
44
|
run('git', ['commit', '-m', rule.commitMessage], repo, { quiet });
|
|
35
45
|
}
|
|
36
46
|
export async function commitChangesAsync(repo, rule, quiet = false) {
|
|
37
|
-
await runAsync('git', ['add', ...
|
|
47
|
+
await runAsync('git', ['add', ...await getChangedFilesAsync(repo)], repo, { quiet });
|
|
38
48
|
await runAsync('git', ['commit', '-m', rule.commitMessage], repo, { quiet });
|
|
39
49
|
}
|
|
40
50
|
export async function pushAndCreatePr(repo, rule, options, quiet = false) {
|
|
@@ -68,6 +78,13 @@ function getDefaultBranch(repo) {
|
|
|
68
78
|
const current = run('git', ['branch', '--show-current'], repo, { capture: true });
|
|
69
79
|
return current.stdout.trim() || 'main';
|
|
70
80
|
}
|
|
81
|
+
async function getDefaultBranchAsync(repo) {
|
|
82
|
+
const symbolic = await runAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], repo, { capture: true, allowFailure: true });
|
|
83
|
+
if (symbolic?.stdout.trim())
|
|
84
|
+
return symbolic.stdout.trim().replace(/^origin\//, '');
|
|
85
|
+
const current = await runAsync('git', ['branch', '--show-current'], repo, { capture: true });
|
|
86
|
+
return current.stdout.trim() || 'main';
|
|
87
|
+
}
|
|
71
88
|
function getBranchName(rule, options) {
|
|
72
89
|
return `${options.branchPrefix}/${rule.branchName}`;
|
|
73
90
|
}
|
|
@@ -83,15 +100,24 @@ function getPrBody(rule) {
|
|
|
83
100
|
function hasChanges(repo) {
|
|
84
101
|
return Boolean(run('git', ['status', '--porcelain'], repo, { capture: true }).stdout.trim());
|
|
85
102
|
}
|
|
103
|
+
async function hasChangesAsync(repo) {
|
|
104
|
+
return Boolean((await runAsync('git', ['status', '--porcelain'], repo, { capture: true })).stdout.trim());
|
|
105
|
+
}
|
|
86
106
|
export function getChangedFiles(repo) {
|
|
87
107
|
return run('git', ['status', '--porcelain'], repo, { capture: true }).stdout
|
|
88
108
|
.split('\n')
|
|
89
109
|
.filter(Boolean)
|
|
90
110
|
.map((line) => line.slice(3));
|
|
91
111
|
}
|
|
92
|
-
export
|
|
112
|
+
export async function getChangedFilesAsync(repo) {
|
|
113
|
+
return (await runAsync('git', ['status', '--porcelain'], repo, { capture: true })).stdout
|
|
114
|
+
.split('\n')
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((line) => line.slice(3));
|
|
117
|
+
}
|
|
118
|
+
export { hasChanges, hasChangesAsync };
|
|
93
119
|
async function ensureGithubRemote(repo) {
|
|
94
|
-
const remotes =
|
|
120
|
+
const remotes = await listRemotesAsync(repo);
|
|
95
121
|
const github = remotes.find((remote) => remote.github);
|
|
96
122
|
if (github)
|
|
97
123
|
return github;
|
|
@@ -99,13 +125,16 @@ async function ensureGithubRemote(repo) {
|
|
|
99
125
|
const suggestion = origin ? convertOriginToGithub(origin.url) : '';
|
|
100
126
|
const url = await promptForGithubRemote(path.basename(repo), suggestion, parseRemote);
|
|
101
127
|
const name = remotes.some((remote) => remote.name === 'github') ? 'github-pr' : 'github';
|
|
102
|
-
|
|
128
|
+
await runAsync('git', ['remote', 'add', name, url], repo, { quiet: true });
|
|
103
129
|
return parseRemote(name, url);
|
|
104
130
|
}
|
|
105
|
-
function
|
|
106
|
-
const result =
|
|
131
|
+
async function listRemotesAsync(repo) {
|
|
132
|
+
const result = await runAsync('git', ['remote', '-v'], repo, { capture: true });
|
|
133
|
+
return parseRemotes(result.stdout);
|
|
134
|
+
}
|
|
135
|
+
function parseRemotes(stdout) {
|
|
107
136
|
const remotes = new Map();
|
|
108
|
-
for (const line of
|
|
137
|
+
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
|
109
138
|
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
|
|
110
139
|
if (match)
|
|
111
140
|
remotes.set(match[1], parseRemote(match[1], match[2]));
|
package/dist/options.js
CHANGED
|
@@ -10,7 +10,7 @@ export function parseArgs(args) {
|
|
|
10
10
|
base: undefined,
|
|
11
11
|
branchPrefix: DEFAULT_BRANCH_PREFIX,
|
|
12
12
|
advisoryDir: DEFAULT_ADVISORY_DIR,
|
|
13
|
-
bumpLevels: new Set(
|
|
13
|
+
bumpLevels: new Set(["minor", "patch"]),
|
|
14
14
|
dryRun: false,
|
|
15
15
|
yes: false,
|
|
16
16
|
createPr: true,
|
|
@@ -117,7 +117,7 @@ Options:
|
|
|
117
117
|
--base <branch> Base branch for PRs. Defaults to GitHub default branch.
|
|
118
118
|
--branch-prefix <name> Branch namespace. Default: ${DEFAULT_BRANCH_PREFIX}
|
|
119
119
|
--limit <number> Stop after processing this many affected repos.
|
|
120
|
-
--bump <levels> Only include bump levels: major, minor, patch. Comma-separated.
|
|
120
|
+
--bump <levels> Only include bump levels: major, minor, patch. Comma-separated. Default: minor,patch.
|
|
121
121
|
--major Only include major version bumps. Can combine with --minor/--patch.
|
|
122
122
|
--minor Only include minor version bumps. Can combine with --major/--patch.
|
|
123
123
|
--patch Only include patch version bumps. Can combine with --major/--minor.
|
package/dist/repo-actions.js
CHANGED
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { log } from '@clack/prompts';
|
|
4
4
|
import { getUpdates } from './advisories.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { checkoutFreshBranchAsync, commitChangesAsync, ensureCleanAsync, getCheckoutStateAsync, hasChangesAsync, pushAndCreatePrAsync, restoreCheckoutAsync } from './git.js';
|
|
6
|
+
import { refreshLockfileAsync } from './package-manager.js';
|
|
7
|
+
import { paddedSpinner } from './spinner.js';
|
|
7
8
|
import { cloneGithubRepo } from './targets.js';
|
|
8
|
-
|
|
9
|
+
const actionConcurrency = 3;
|
|
10
|
+
export async function actionRepos(affectedRepos, rule, options) {
|
|
11
|
+
if (affectedRepos.length === 1) {
|
|
12
|
+
await actionRepo(affectedRepos[0], rule, options);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
let completed = 0;
|
|
16
|
+
let succeeded = 0;
|
|
17
|
+
let nextIndex = 0;
|
|
18
|
+
const failures = [];
|
|
19
|
+
const processNext = async () => {
|
|
20
|
+
const index = nextIndex;
|
|
21
|
+
nextIndex += 1;
|
|
22
|
+
if (index >= affectedRepos.length)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
const result = await actionRepo(affectedRepos[index], rule, options, null);
|
|
26
|
+
succeeded += 1;
|
|
27
|
+
printActionSuccess(result, options);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
31
|
+
failures.push(failure);
|
|
32
|
+
printActionFailure(path.basename(affectedRepos[index].repo), failure, options);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
completed += 1;
|
|
36
|
+
}
|
|
37
|
+
await processNext();
|
|
38
|
+
};
|
|
39
|
+
await Promise.all(Array.from({ length: Math.min(actionConcurrency, affectedRepos.length) }, processNext));
|
|
40
|
+
if (failures.length > 0) {
|
|
41
|
+
printActionFailureSummary(`Processed ${completed}/${affectedRepos.length} repos; ${failures.length} failed.`, options);
|
|
42
|
+
throw failures[0];
|
|
43
|
+
}
|
|
44
|
+
printActionSuccess(`Processed ${completed}/${affectedRepos.length} repos; ${succeeded} succeeded.`, options);
|
|
45
|
+
}
|
|
46
|
+
export async function actionRepo(affectedRepo, rule, options, statusOverride) {
|
|
9
47
|
const repoName = path.basename(affectedRepo.repo);
|
|
10
|
-
const status = createActionStatus(options, repoName);
|
|
48
|
+
const status = statusOverride === undefined ? createActionStatus(options, repoName) : statusOverride;
|
|
11
49
|
try {
|
|
12
50
|
const result = affectedRepo.github
|
|
13
51
|
? await actionGithubRepo(affectedRepo, rule, options, status, repoName)
|
|
14
52
|
: await actionLocalRepo(affectedRepo, rule, options, status, repoName);
|
|
15
53
|
status?.stop(result);
|
|
54
|
+
return result;
|
|
16
55
|
}
|
|
17
56
|
catch (error) {
|
|
18
57
|
status?.stop(`Processing ${repoName} failed.`);
|
|
@@ -52,13 +91,10 @@ async function actionLocalRepo(affectedRepo, rule, options, status, repoName) {
|
|
|
52
91
|
console.log(` [${update.bumpLevel}] ${update.section}.${update.name}: ${update.from} -> ${update.to} (${update.reason})`);
|
|
53
92
|
}
|
|
54
93
|
}
|
|
55
|
-
|
|
56
|
-
const originalCheckout =
|
|
94
|
+
await ensureCleanAsync(repo);
|
|
95
|
+
const originalCheckout = await getCheckoutStateAsync(repo);
|
|
57
96
|
status?.message(`Preparing update branch for ${repoName}`);
|
|
58
|
-
|
|
59
|
-
await checkoutFreshBranchAsync(repo, rule, options, true);
|
|
60
|
-
else
|
|
61
|
-
checkoutFreshBranch(repo, rule, options);
|
|
97
|
+
await checkoutFreshBranchAsync(repo, rule, options, options.interactive);
|
|
62
98
|
const packageJsonText = await fs.readFile(packageJsonPath, 'utf8');
|
|
63
99
|
const packageJson = JSON.parse(packageJsonText);
|
|
64
100
|
const freshUpdates = getUpdates(packageJson, rule, options.bumpLevels);
|
|
@@ -71,39 +107,50 @@ async function actionLocalRepo(affectedRepo, rule, options, status, repoName) {
|
|
|
71
107
|
await fs.writeFile(packageJsonPath, applyUpdates(packageJsonText, freshUpdates));
|
|
72
108
|
if (!options.skipInstall) {
|
|
73
109
|
status?.message(`Refreshing lockfile for ${repoName}`);
|
|
74
|
-
|
|
75
|
-
await refreshLockfileAsync(repo, true);
|
|
76
|
-
else
|
|
77
|
-
refreshLockfile(repo);
|
|
110
|
+
await refreshLockfileAsync(repo, options.interactive);
|
|
78
111
|
}
|
|
79
|
-
|
|
112
|
+
const changed = await hasChangesAsync(repo);
|
|
113
|
+
if (!changed) {
|
|
80
114
|
if (!options.interactive)
|
|
81
115
|
console.log(' No changes after update; skipping.');
|
|
82
116
|
return `Skipped ${repoName}: no changes after update.`;
|
|
83
117
|
}
|
|
84
118
|
status?.message(`Committing changes for ${repoName}`);
|
|
85
|
-
|
|
86
|
-
await commitChangesAsync(repo, rule, true);
|
|
87
|
-
else
|
|
88
|
-
commitChanges(repo, rule);
|
|
119
|
+
await commitChangesAsync(repo, rule, options.interactive);
|
|
89
120
|
if (!options.createPr) {
|
|
90
121
|
if (!options.interactive)
|
|
91
122
|
console.log(' Updated locally; PR creation disabled.');
|
|
92
123
|
return `Updated ${repoName} locally; PR creation disabled.`;
|
|
93
124
|
}
|
|
94
125
|
status?.message(`Creating PR for ${repoName}`);
|
|
95
|
-
const prUrl =
|
|
126
|
+
const prUrl = await pushAndCreatePrAsync(repo, rule, options, options.interactive);
|
|
96
127
|
status?.message(`Restoring checkout for ${repoName}`);
|
|
128
|
+
await restoreCheckoutAsync(repo, originalCheckout, options.interactive);
|
|
129
|
+
return prUrl ? `Created PR for ${repoName}: ${prUrl}` : `Created PR for ${repoName}.`;
|
|
130
|
+
}
|
|
131
|
+
function printActionSuccess(message, options) {
|
|
97
132
|
if (options.interactive)
|
|
98
|
-
|
|
133
|
+
log.success(message);
|
|
99
134
|
else
|
|
100
|
-
|
|
101
|
-
|
|
135
|
+
console.log(` ${message}`);
|
|
136
|
+
}
|
|
137
|
+
function printActionFailure(repoName, error, options) {
|
|
138
|
+
const message = `${repoName} failed: ${error.message}`;
|
|
139
|
+
if (options.interactive)
|
|
140
|
+
log.error(message);
|
|
141
|
+
else
|
|
142
|
+
console.error(` ${message}`);
|
|
143
|
+
}
|
|
144
|
+
function printActionFailureSummary(message, options) {
|
|
145
|
+
if (options.interactive)
|
|
146
|
+
log.error(message);
|
|
147
|
+
else
|
|
148
|
+
console.error(` ${message}`);
|
|
102
149
|
}
|
|
103
150
|
function createActionStatus(options, repoName) {
|
|
104
151
|
if (!options.interactive)
|
|
105
152
|
return null;
|
|
106
|
-
const statusSpinner =
|
|
153
|
+
const statusSpinner = paddedSpinner();
|
|
107
154
|
statusSpinner.start(`Processing ${repoName}`);
|
|
108
155
|
return statusSpinner;
|
|
109
156
|
}
|
package/dist/scanner.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { spinner } from '@clack/prompts';
|
|
4
3
|
import { getUpdates } from './advisories.js';
|
|
5
4
|
import { exists } from './files.js';
|
|
6
|
-
import {
|
|
5
|
+
import { runAsync } from './shell.js';
|
|
6
|
+
import { paddedSpinner } from './spinner.js';
|
|
7
|
+
const scanConcurrency = 8;
|
|
7
8
|
export async function runScan(repos, root, rule, options) {
|
|
8
9
|
const targetDescription = options.source === 'github' ? `${repos.length} GitHub repos via the GitHub API` : `${repos.length} repos in ${root}`;
|
|
9
10
|
if (!options.interactive) {
|
|
10
11
|
console.log(`Scanning ${targetDescription} using rule: ${rule.title}`);
|
|
11
12
|
return scanRepos(repos, rule, options);
|
|
12
13
|
}
|
|
13
|
-
const scanSpinner =
|
|
14
|
+
const scanSpinner = paddedSpinner();
|
|
14
15
|
scanSpinner.start(`Scanning ${targetDescription}`);
|
|
15
|
-
const affectedRepos = await scanRepos(repos, rule, options)
|
|
16
|
+
const affectedRepos = await scanRepos(repos, rule, options, (progress) => {
|
|
17
|
+
scanSpinner.message(formatScanProgress(progress));
|
|
18
|
+
});
|
|
16
19
|
scanSpinner.stop(`Found ${affectedRepos.length} affected repo${affectedRepos.length === 1 ? '' : 's'}.`);
|
|
17
20
|
return affectedRepos;
|
|
18
21
|
}
|
|
@@ -33,17 +36,36 @@ export async function findRepos(root) {
|
|
|
33
36
|
async function isNodeRepo(repoPath) {
|
|
34
37
|
return await exists(path.join(repoPath, '.git')) && await exists(path.join(repoPath, 'package.json'));
|
|
35
38
|
}
|
|
36
|
-
async function scanRepos(repos, rule, options) {
|
|
37
|
-
const affectedRepos =
|
|
38
|
-
|
|
39
|
+
async function scanRepos(repos, rule, options, onProgress) {
|
|
40
|
+
const affectedRepos = Array.from({ length: repos.length }, () => null);
|
|
41
|
+
let scanned = 0;
|
|
42
|
+
let affected = 0;
|
|
43
|
+
let nextIndex = 0;
|
|
44
|
+
const scanNext = async () => {
|
|
45
|
+
const index = nextIndex;
|
|
46
|
+
nextIndex += 1;
|
|
47
|
+
if (index >= repos.length || hasReachedLimit(affected, options))
|
|
48
|
+
return;
|
|
49
|
+
const repo = repos[index];
|
|
39
50
|
const affectedRepo = await scanRepo(repo, rule, options);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
scanned += 1;
|
|
52
|
+
if (affectedRepo && !hasReachedLimit(affected, options)) {
|
|
53
|
+
affectedRepos[index] = affectedRepo;
|
|
54
|
+
affected += 1;
|
|
55
|
+
}
|
|
56
|
+
onProgress?.({ scanned, total: repos.length, affected });
|
|
57
|
+
if (!hasReachedLimit(affected, options))
|
|
58
|
+
await scanNext();
|
|
59
|
+
};
|
|
60
|
+
await Promise.all(Array.from({ length: Math.min(scanConcurrency, repos.length) }, scanNext));
|
|
61
|
+
return affectedRepos.filter((repo) => Boolean(repo));
|
|
62
|
+
}
|
|
63
|
+
function formatScanProgress(progress) {
|
|
64
|
+
const affectedLabel = progress.affected === 1 ? 'affected repo' : 'affected repos';
|
|
65
|
+
return `Scanned ${progress.scanned}/${progress.total} repos, ${progress.affected} ${affectedLabel}.`;
|
|
66
|
+
}
|
|
67
|
+
function hasReachedLimit(affected, options) {
|
|
68
|
+
return options.limit > 0 && affected >= options.limit;
|
|
47
69
|
}
|
|
48
70
|
async function scanRepo(repo, rule, options) {
|
|
49
71
|
if (repo.source === 'github')
|
|
@@ -56,16 +78,21 @@ async function scanLocalRepo(repo, rule, options) {
|
|
|
56
78
|
const updates = getUpdates(packageJson, rule, options.bumpLevels);
|
|
57
79
|
if (updates.length === 0)
|
|
58
80
|
return null;
|
|
81
|
+
const githubRepo = await getGithubRepoFromLocalRemote(repo);
|
|
82
|
+
if (githubRepo && await hasOpenAdvisoryPr(githubRepo, rule, options))
|
|
83
|
+
return null;
|
|
59
84
|
return { repo, packageJsonPath, updates };
|
|
60
85
|
}
|
|
61
86
|
async function scanGithubRepo(repo, rule, options) {
|
|
62
|
-
const packageJsonText = getGithubPackageJson(repo);
|
|
87
|
+
const packageJsonText = await getGithubPackageJson(repo);
|
|
63
88
|
if (!packageJsonText)
|
|
64
89
|
return null;
|
|
65
90
|
const packageJson = JSON.parse(packageJsonText);
|
|
66
91
|
const updates = getUpdates(packageJson, rule, options.bumpLevels);
|
|
67
92
|
if (updates.length === 0)
|
|
68
93
|
return null;
|
|
94
|
+
if (await hasOpenAdvisoryPr(repo.nameWithOwner, rule, options))
|
|
95
|
+
return null;
|
|
69
96
|
return {
|
|
70
97
|
repo: repo.nameWithOwner,
|
|
71
98
|
packageJsonPath: 'package.json',
|
|
@@ -73,8 +100,8 @@ async function scanGithubRepo(repo, rule, options) {
|
|
|
73
100
|
github: repo,
|
|
74
101
|
};
|
|
75
102
|
}
|
|
76
|
-
function getGithubPackageJson(repo) {
|
|
77
|
-
const result =
|
|
103
|
+
async function getGithubPackageJson(repo) {
|
|
104
|
+
const result = await runAsync('gh', [
|
|
78
105
|
'api',
|
|
79
106
|
`repos/${repo.nameWithOwner}/contents/package.json`,
|
|
80
107
|
'--method',
|
|
@@ -86,3 +113,45 @@ function getGithubPackageJson(repo) {
|
|
|
86
113
|
], process.cwd(), { capture: true, allowFailure: true });
|
|
87
114
|
return result?.stdout.trim() ? result.stdout : null;
|
|
88
115
|
}
|
|
116
|
+
async function hasOpenAdvisoryPr(repo, rule, options) {
|
|
117
|
+
const result = await runAsync('gh', [
|
|
118
|
+
'pr',
|
|
119
|
+
'list',
|
|
120
|
+
'--repo',
|
|
121
|
+
repo,
|
|
122
|
+
'--state',
|
|
123
|
+
'open',
|
|
124
|
+
'--head',
|
|
125
|
+
getAdvisoryBranchName(rule, options),
|
|
126
|
+
'--json',
|
|
127
|
+
'number',
|
|
128
|
+
'--limit',
|
|
129
|
+
'1',
|
|
130
|
+
], process.cwd(), { capture: true, allowFailure: true });
|
|
131
|
+
if (!result?.stdout.trim())
|
|
132
|
+
return false;
|
|
133
|
+
return JSON.parse(result.stdout).length > 0;
|
|
134
|
+
}
|
|
135
|
+
async function getGithubRepoFromLocalRemote(repo) {
|
|
136
|
+
const result = await runAsync('git', ['remote', '-v'], repo, { capture: true, allowFailure: true });
|
|
137
|
+
if (!result?.stdout.trim())
|
|
138
|
+
return null;
|
|
139
|
+
for (const line of result.stdout.trim().split('\n')) {
|
|
140
|
+
const match = line.match(/^\S+\s+(?<url>\S+)\s+\(fetch\)$/);
|
|
141
|
+
const ownerRepo = match?.groups?.url ? parseGithubOwnerRepo(match.groups.url) : null;
|
|
142
|
+
if (ownerRepo)
|
|
143
|
+
return ownerRepo;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
function parseGithubOwnerRepo(url) {
|
|
148
|
+
const normalized = url
|
|
149
|
+
.replace(/^git@github\.com:/, 'https://github.com/')
|
|
150
|
+
.replace(/^ssh:\/\/git@github\.com\//, 'https://github.com/')
|
|
151
|
+
.replace(/\.git$/, '');
|
|
152
|
+
const match = normalized.match(/github\.com[/:](?<owner>[^/]+)\/(?<repo>[^/]+)$/);
|
|
153
|
+
return match ? `${match.groups?.owner}/${match.groups?.repo}` : null;
|
|
154
|
+
}
|
|
155
|
+
function getAdvisoryBranchName(rule, options) {
|
|
156
|
+
return `${options.branchPrefix}/${rule.branchName}`;
|
|
157
|
+
}
|
package/dist/spinner.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spinner } from '@clack/prompts';
|
|
2
|
+
const bottomPaddingLines = 2;
|
|
3
|
+
export function paddedSpinner() {
|
|
4
|
+
reserveBottomPadding();
|
|
5
|
+
return spinner();
|
|
6
|
+
}
|
|
7
|
+
function reserveBottomPadding() {
|
|
8
|
+
if (!process.stdout.isTTY)
|
|
9
|
+
return;
|
|
10
|
+
process.stdout.write('\n'.repeat(bottomPaddingLines));
|
|
11
|
+
process.stdout.write(`\x1b[${bottomPaddingLines}A`);
|
|
12
|
+
}
|
package/dist/targets.js
CHANGED
|
@@ -2,9 +2,9 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import fsSync from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { spinner } from '@clack/prompts';
|
|
6
5
|
import { run, runAsync } from './shell.js';
|
|
7
6
|
import { findRepos } from './scanner.js';
|
|
7
|
+
import { paddedSpinner } from './spinner.js';
|
|
8
8
|
const cleanupPaths = new Set();
|
|
9
9
|
let cleanupHooksRegistered = false;
|
|
10
10
|
export async function resolveTargets(options) {
|
|
@@ -37,21 +37,21 @@ export async function cloneGithubRepo(repo, quiet = false) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
async function resolveGithubTargets(target, options) {
|
|
40
|
-
const targetSpinner = options.interactive ?
|
|
40
|
+
const targetSpinner = options.interactive ? paddedSpinner() : null;
|
|
41
41
|
targetSpinner?.start(`Loading GitHub repos from ${target}`);
|
|
42
|
-
const repos = getGithubRepos(target);
|
|
42
|
+
const repos = await getGithubRepos(target);
|
|
43
43
|
targetSpinner?.stop(`Loaded ${repos.length} GitHub repo${repos.length === 1 ? '' : 's'}.`);
|
|
44
44
|
return { root: normalizeGithubTarget(target), repos: repos.map(toGithubRepoTarget) };
|
|
45
45
|
}
|
|
46
|
-
function getGithubRepos(target) {
|
|
46
|
+
async function getGithubRepos(target) {
|
|
47
47
|
const normalizedTarget = normalizeGithubTarget(target);
|
|
48
48
|
if (isGithubRepoTarget(normalizedTarget))
|
|
49
|
-
return [getGithubRepo(normalizedTarget)];
|
|
50
|
-
const result =
|
|
49
|
+
return [await getGithubRepo(normalizedTarget)];
|
|
50
|
+
const result = await runAsync('gh', ['repo', 'list', normalizedTarget, '--json', 'nameWithOwner,sshUrl,defaultBranchRef', '--limit', '1000'], process.cwd(), { capture: true });
|
|
51
51
|
return JSON.parse(result.stdout);
|
|
52
52
|
}
|
|
53
|
-
function getGithubRepo(target) {
|
|
54
|
-
const result =
|
|
53
|
+
async function getGithubRepo(target) {
|
|
54
|
+
const result = await runAsync('gh', ['repo', 'view', target, '--json', 'nameWithOwner,sshUrl,defaultBranchRef'], process.cwd(), { capture: true });
|
|
55
55
|
return JSON.parse(result.stdout);
|
|
56
56
|
}
|
|
57
57
|
function toGithubRepoTarget(repo) {
|