kickload-watcher-mcp 0.1.4 → 0.1.6
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/compliance-poller.js +215 -216
- package/email-sender.js +5 -1
- package/github-webhook.js +321 -322
- package/kickload-client.js +41 -60
- package/package.json +1 -1
- package/pipeline.js +18 -83
package/github-webhook.js
CHANGED
|
@@ -1,322 +1,321 @@
|
|
|
1
|
-
// github-webhook.js — Optional GitHub/GitLab webhook listener
|
|
2
|
-
// Only needed if TRIGGER_MODE=github or TRIGGER_MODE=both
|
|
3
|
-
// Listens for Pull Request events containing new API code
|
|
4
|
-
|
|
5
|
-
import { createServer } from "http";
|
|
6
|
-
import crypto from "crypto";
|
|
7
|
-
import { config } from "./config.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
res.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
res.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
req.on("
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
res.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
res.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
res.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
console.log(
|
|
76
|
-
console.log(`
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
.
|
|
288
|
-
.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
Buffer.from(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
/\+.*
|
|
315
|
-
|
|
316
|
-
/\+.*@
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
1
|
+
// github-webhook.js — Optional GitHub/GitLab webhook listener
|
|
2
|
+
// Only needed if TRIGGER_MODE=github or TRIGGER_MODE=both
|
|
3
|
+
// Listens for Pull Request events containing new API code
|
|
4
|
+
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import crypto from "crypto";
|
|
7
|
+
import { config } from "./config.js";
|
|
8
|
+
|
|
9
|
+
const webhookCallbacks = [];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register a callback for GitHub PR events with API code.
|
|
13
|
+
* Callback receives: { prNumber, prTitle, branch, author, authorEmail, files, timestamp }
|
|
14
|
+
*/
|
|
15
|
+
export function onGithubPR(callback) {
|
|
16
|
+
webhookCallbacks.push(callback);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Start the webhook HTTP server.
|
|
21
|
+
* Point your GitHub/GitLab webhook to: http://your-server:3456/webhook
|
|
22
|
+
*/
|
|
23
|
+
export function startGithubWebhookServer() {
|
|
24
|
+
const port = config.github.webhookPort;
|
|
25
|
+
|
|
26
|
+
const server = createServer(async (req, res) => {
|
|
27
|
+
// Health check
|
|
28
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
29
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
30
|
+
res.end(JSON.stringify({ status: "ok", server: "kickload-webhook" }));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Only handle POST /webhook
|
|
35
|
+
if (req.method !== "POST" || req.url !== "/webhook") {
|
|
36
|
+
res.writeHead(404);
|
|
37
|
+
res.end("Not found");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Read body
|
|
42
|
+
let body = "";
|
|
43
|
+
req.on("data", (chunk) => (body += chunk.toString()));
|
|
44
|
+
req.on("end", async () => {
|
|
45
|
+
try {
|
|
46
|
+
// Verify GitHub signature if secret is configured
|
|
47
|
+
if (config.github.webhookSecret) {
|
|
48
|
+
const signature = req.headers["x-hub-signature-256"];
|
|
49
|
+
if (!verifyGithubSignature(body, signature, config.github.webhookSecret)) {
|
|
50
|
+
console.warn("⚠️ GitHub webhook: invalid signature — rejected");
|
|
51
|
+
res.writeHead(401);
|
|
52
|
+
res.end("Invalid signature");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const event = req.headers["x-github-event"] || req.headers["x-gitlab-event"];
|
|
58
|
+
const payload = JSON.parse(body);
|
|
59
|
+
|
|
60
|
+
// Handle the event
|
|
61
|
+
await handleWebhookEvent(event, payload);
|
|
62
|
+
|
|
63
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
64
|
+
res.end(JSON.stringify({ received: true }));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error("❌ Webhook handler error:", err.message);
|
|
67
|
+
res.writeHead(500);
|
|
68
|
+
res.end("Internal error");
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server.listen(port, () => {
|
|
74
|
+
console.log(`🔗 GitHub webhook server listening on port ${port}`);
|
|
75
|
+
console.log(` Webhook URL: http://your-server:${port}/webhook`);
|
|
76
|
+
console.log(` Configure at: GitHub → Repo → Settings → Webhooks`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return server;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Route incoming webhook events to the right handler.
|
|
84
|
+
*/
|
|
85
|
+
async function handleWebhookEvent(event, payload) {
|
|
86
|
+
// GitHub Pull Request events
|
|
87
|
+
if (event === "pull_request") {
|
|
88
|
+
await handleGithubPR(payload);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// GitHub Push events (direct commit to branch)
|
|
93
|
+
if (event === "push") {
|
|
94
|
+
await handleGithubPush(payload);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// GitLab Merge Request events
|
|
99
|
+
if (event === "Merge Request Hook") {
|
|
100
|
+
await handleGitlabMR(payload);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`ℹ️ Unhandled webhook event: ${event}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle GitHub Pull Request opened/synchronized events.
|
|
109
|
+
*/
|
|
110
|
+
async function handleGithubPR(payload) {
|
|
111
|
+
const action = payload.action;
|
|
112
|
+
|
|
113
|
+
// Only trigger on new PRs or new commits to existing PRs
|
|
114
|
+
if (!["opened", "synchronize", "reopened"].includes(action)) return;
|
|
115
|
+
|
|
116
|
+
const pr = payload.pull_request;
|
|
117
|
+
const repo = payload.repository;
|
|
118
|
+
|
|
119
|
+
console.log(`🔀 GitHub PR #${pr.number}: "${pr.title}" by ${pr.user.login}`);
|
|
120
|
+
|
|
121
|
+
// Fetch the PR diff to find changed API files
|
|
122
|
+
const files = await fetchPRFiles(
|
|
123
|
+
repo.full_name,
|
|
124
|
+
pr.number,
|
|
125
|
+
config.github.token
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const apiFiles = files.filter((f) => containsApiCode(f.patch || ""));
|
|
129
|
+
|
|
130
|
+
if (apiFiles.length === 0) {
|
|
131
|
+
console.log(`ℹ️ PR #${pr.number} — no API code changes detected`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(
|
|
136
|
+
`📋 PR #${pr.number} — ${apiFiles.length} API file(s) changed:`,
|
|
137
|
+
apiFiles.map((f) => f.filename).join(", ")
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const event = {
|
|
141
|
+
prNumber: pr.number,
|
|
142
|
+
prTitle: pr.title,
|
|
143
|
+
prUrl: pr.html_url,
|
|
144
|
+
branch: pr.head.ref,
|
|
145
|
+
author: pr.user.login,
|
|
146
|
+
authorEmail: pr.user.email || null,
|
|
147
|
+
repoFullName: repo.full_name,
|
|
148
|
+
files: apiFiles,
|
|
149
|
+
combinedPatch: apiFiles.map((f) => f.patch || "").join("\n\n"),
|
|
150
|
+
timestamp: new Date().toISOString(),
|
|
151
|
+
source: "github_pr",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
for (const cb of webhookCallbacks) {
|
|
155
|
+
try {
|
|
156
|
+
await cb(event);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error("❌ GitHub PR callback error:", err.message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Handle GitHub push events (direct commits).
|
|
165
|
+
*/
|
|
166
|
+
async function handleGithubPush(payload) {
|
|
167
|
+
// Skip branch deletions and non-main branches if configured
|
|
168
|
+
if (payload.deleted) return;
|
|
169
|
+
|
|
170
|
+
const commits = payload.commits || [];
|
|
171
|
+
const repo = payload.repository;
|
|
172
|
+
const pusher = payload.pusher;
|
|
173
|
+
|
|
174
|
+
// Collect all modified/added files across commits
|
|
175
|
+
const changedFiles = new Set();
|
|
176
|
+
for (const commit of commits) {
|
|
177
|
+
[...(commit.added || []), ...(commit.modified || [])].forEach((f) =>
|
|
178
|
+
changedFiles.add(f)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const apiFiles = [...changedFiles].filter((filename) =>
|
|
183
|
+
hasApiExtension(filename)
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (apiFiles.length === 0) return;
|
|
187
|
+
|
|
188
|
+
console.log(
|
|
189
|
+
`📤 GitHub Push by ${pusher.name} — ${apiFiles.length} API file(s) changed`
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const event = {
|
|
193
|
+
prNumber: null,
|
|
194
|
+
prTitle: `Push to ${payload.ref}`,
|
|
195
|
+
prUrl: payload.compare,
|
|
196
|
+
branch: payload.ref.replace("refs/heads/", ""),
|
|
197
|
+
author: pusher.name,
|
|
198
|
+
authorEmail: pusher.email || null,
|
|
199
|
+
repoFullName: repo.full_name,
|
|
200
|
+
files: apiFiles.map((f) => ({ filename: f, patch: "" })),
|
|
201
|
+
combinedPatch: "",
|
|
202
|
+
timestamp: new Date().toISOString(),
|
|
203
|
+
source: "github_push",
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
for (const cb of webhookCallbacks) {
|
|
207
|
+
try {
|
|
208
|
+
await cb(event);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error("❌ GitHub push callback error:", err.message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Handle GitLab Merge Request events.
|
|
217
|
+
*/
|
|
218
|
+
async function handleGitlabMR(payload) {
|
|
219
|
+
const attrs = payload.object_attributes;
|
|
220
|
+
if (!["open", "update"].includes(attrs?.action)) return;
|
|
221
|
+
|
|
222
|
+
console.log(`🔀 GitLab MR #${attrs.iid}: "${attrs.title}" by ${payload.user?.name}`);
|
|
223
|
+
|
|
224
|
+
const event = {
|
|
225
|
+
prNumber: attrs.iid,
|
|
226
|
+
prTitle: attrs.title,
|
|
227
|
+
prUrl: attrs.url,
|
|
228
|
+
branch: attrs.source_branch,
|
|
229
|
+
author: payload.user?.name,
|
|
230
|
+
authorEmail: payload.user?.email || null,
|
|
231
|
+
repoFullName: payload.project?.path_with_namespace,
|
|
232
|
+
files: [],
|
|
233
|
+
combinedPatch: "",
|
|
234
|
+
timestamp: new Date().toISOString(),
|
|
235
|
+
source: "gitlab_mr",
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
for (const cb of webhookCallbacks) {
|
|
239
|
+
try {
|
|
240
|
+
await cb(event);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error("❌ GitLab MR callback error:", err.message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Fetch files changed in a GitHub PR using the GitHub API.
|
|
249
|
+
*/
|
|
250
|
+
async function fetchPRFiles(repoFullName, prNumber, token) {
|
|
251
|
+
if (!token) {
|
|
252
|
+
console.warn("⚠️ No GITHUB_TOKEN — cannot fetch PR files");
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const response = await fetch(
|
|
258
|
+
`https://api.github.com/repos/${repoFullName}/pulls/${prNumber}/files`,
|
|
259
|
+
{
|
|
260
|
+
headers: {
|
|
261
|
+
Authorization: `Bearer ${token}`,
|
|
262
|
+
Accept: "application/vnd.github.v3+json",
|
|
263
|
+
"User-Agent": "kickload-watcher-mcp",
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (!response.ok) {
|
|
269
|
+
console.warn(`⚠️ GitHub API ${response.status} for PR #${prNumber} files`);
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return await response.json();
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.warn("⚠️ GitHub API fetch failed:", err.message);
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Verify GitHub webhook signature (HMAC-SHA256).
|
|
282
|
+
*/
|
|
283
|
+
function verifyGithubSignature(body, signature, secret) {
|
|
284
|
+
if (!signature) return false;
|
|
285
|
+
const expected = `sha256=${crypto
|
|
286
|
+
.createHmac("sha256", secret)
|
|
287
|
+
.update(body)
|
|
288
|
+
.digest("hex")}`;
|
|
289
|
+
try {
|
|
290
|
+
return crypto.timingSafeEqual(
|
|
291
|
+
Buffer.from(signature),
|
|
292
|
+
Buffer.from(expected)
|
|
293
|
+
);
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Quick check if a file path suggests it contains API routes.
|
|
301
|
+
*/
|
|
302
|
+
function hasApiExtension(filename) {
|
|
303
|
+
const apiExts = [".js", ".ts", ".py", ".go", ".java", ".rb", ".php", ".cs"];
|
|
304
|
+
const ext = filename.split(".").pop();
|
|
305
|
+
return apiExts.includes(`.${ext}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Check if a patch/diff contains API code.
|
|
310
|
+
*/
|
|
311
|
+
function containsApiCode(patch) {
|
|
312
|
+
const patterns = [
|
|
313
|
+
/\+.*app\.(get|post|put|patch|delete)\s*\(/i,
|
|
314
|
+
/\+.*router\.(get|post|put|patch|delete)\s*\(/i,
|
|
315
|
+
/\+.*@app\.route\s*\(/i,
|
|
316
|
+
/\+.*@router\.(get|post|put|patch|delete)\s*\(/i,
|
|
317
|
+
/\+.*r\.(GET|POST|PUT|PATCH|DELETE)\s*\(/,
|
|
318
|
+
/\+.*@(Get|Post|Put|Delete|Patch)Mapping\s*\(/,
|
|
319
|
+
];
|
|
320
|
+
return patterns.some((p) => p.test(patch));
|
|
321
|
+
}
|