@capraconsulting/cals-cli 3.13.0 → 3.14.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.
Files changed (49) hide show
  1. package/README.md +4 -16
  2. package/lib/cals-cli.mjs +599 -2461
  3. package/lib/cals-cli.mjs.map +1 -1
  4. package/lib/cli/reporter.d.ts +5 -6
  5. package/lib/cli/util.d.ts +1 -6
  6. package/lib/config.d.ts +0 -2
  7. package/lib/definition/index.d.ts +1 -1
  8. package/lib/definition/types.d.ts +0 -52
  9. package/lib/github/index.d.ts +0 -2
  10. package/lib/github/service.d.ts +2 -67
  11. package/lib/github/types.d.ts +0 -63
  12. package/lib/github/util.d.ts +0 -1
  13. package/lib/index.d.ts +0 -10
  14. package/lib/index.es.js +11 -1391
  15. package/lib/index.es.js.map +1 -1
  16. package/lib/index.js +11 -1391
  17. package/lib/index.js.map +1 -1
  18. package/package.json +10 -23
  19. package/lib/cli/commands/definition/dump-setup.d.ts +0 -3
  20. package/lib/cli/commands/definition/util.d.ts +0 -6
  21. package/lib/cli/commands/definition/util.test.d.ts +0 -1
  22. package/lib/cli/commands/definition/validate.d.ts +0 -3
  23. package/lib/cli/commands/definition.d.ts +0 -3
  24. package/lib/cli/commands/delete-cache.d.ts +0 -3
  25. package/lib/cli/commands/getting-started.d.ts +0 -3
  26. package/lib/cli/commands/github/analyze-directory.d.ts +0 -3
  27. package/lib/cli/commands/github/configure.d.ts +0 -3
  28. package/lib/cli/commands/github/list-pull-requests-stats.d.ts +0 -3
  29. package/lib/cli/commands/github/list-webhooks.d.ts +0 -3
  30. package/lib/cli/commands/github/util.d.ts +0 -3
  31. package/lib/cli/commands/snyk/report.d.ts +0 -3
  32. package/lib/cli/commands/snyk/set-token.d.ts +0 -3
  33. package/lib/cli/commands/snyk/sync.d.ts +0 -3
  34. package/lib/cli/commands/snyk.d.ts +0 -3
  35. package/lib/github/changeset/changeset.d.ts +0 -21
  36. package/lib/github/changeset/execute.d.ts +0 -10
  37. package/lib/github/changeset/types.d.ts +0 -88
  38. package/lib/snyk/index.d.ts +0 -3
  39. package/lib/snyk/service.d.ts +0 -30
  40. package/lib/snyk/token.d.ts +0 -11
  41. package/lib/snyk/types.d.ts +0 -62
  42. package/lib/snyk/util.d.ts +0 -3
  43. package/lib/snyk/util.test.d.ts +0 -1
  44. package/lib/sonarcloud/index.d.ts +0 -3
  45. package/lib/sonarcloud/service.d.ts +0 -33
  46. package/lib/sonarcloud/token.d.ts +0 -8
  47. package/lib/testing/executor.d.ts +0 -25
  48. package/lib/testing/index.d.ts +0 -2
  49. package/lib/testing/lib.d.ts +0 -63
package/lib/cals-cli.mjs CHANGED
@@ -1,2215 +1,448 @@
1
1
  #!/usr/bin/env node
2
- import process$2 from 'node:process';
3
- import semver from 'semver';
2
+ import * as process from 'node:process';
3
+ import process__default from 'node:process';
4
4
  import yargs from 'yargs';
5
5
  import { hideBin } from 'yargs/helpers';
6
6
  import fs from 'node:fs';
7
- import yaml from 'js-yaml';
8
- import pMap from 'p-map';
9
- import AJV from 'ajv';
7
+ import path from 'node:path';
10
8
  import { Buffer } from 'node:buffer';
11
9
  import { performance } from 'node:perf_hooks';
12
10
  import { Octokit } from '@octokit/rest';
13
- import fetch from 'node-fetch';
14
11
  import pLimit from 'p-limit';
15
- import * as process$1 from 'process';
16
12
  import keytar from 'keytar';
17
- import { deprecate } from 'node:util';
18
- import path from 'node:path';
19
- import https from 'node:https';
20
13
  import os from 'node:os';
21
14
  import cachedir from 'cachedir';
22
15
  import readline from 'node:readline';
23
16
  import chalk from 'chalk';
24
- import { sprintf } from 'sprintf-js';
25
- import { read } from 'read';
26
17
  import { findUp } from 'find-up';
18
+ import yaml from 'js-yaml';
19
+ import AJV from 'ajv';
27
20
  import { execa } from 'execa';
28
21
 
29
- var version = "3.13.0";
22
+ var version = "3.14.0";
30
23
  var engines = {
31
24
  node: ">=22.14.0"
32
25
  };
33
26
 
34
- function groupBy(array, iteratee) {
35
- return array.reduce((result, item) => {
36
- const key = iteratee(item);
37
- if (!result[key]) {
38
- result[key] = [];
27
+ class GitHubTokenCliProvider {
28
+ keyringService = "cals";
29
+ keyringAccount = "github-token";
30
+ async getToken() {
31
+ if (process__default.env.CALS_GITHUB_TOKEN) {
32
+ return process__default.env.CALS_GITHUB_TOKEN;
33
+ }
34
+ const result = await keytar.getPassword(this.keyringService, this.keyringAccount);
35
+ if (result == null) {
36
+ process__default.stderr.write("No token found. Register using `cals github set-token`\n");
37
+ return undefined;
39
38
  }
40
- result[key].push(item);
41
39
  return result;
42
- }, {});
40
+ }
41
+ async markInvalid() {
42
+ await keytar.deletePassword(this.keyringService, this.keyringAccount);
43
+ }
44
+ async setToken(value) {
45
+ await keytar.setPassword(this.keyringService, this.keyringAccount, value);
46
+ }
43
47
  }
44
- function uniq(array) {
45
- return Array.from(new Set(array));
48
+
49
+ class GitHubService {
50
+ octokit;
51
+ cache;
52
+ tokenProvider;
53
+ semaphore;
54
+ constructor(props) {
55
+ this.octokit = props.octokit;
56
+ this.cache = props.cache;
57
+ this.tokenProvider = props.tokenProvider;
58
+ // Control concurrency to GitHub API at service level so we
59
+ // can maximize concurrency all other places.
60
+ this.semaphore = pLimit(6);
61
+ this.octokit.hook.wrap("request", async (request, options) => {
62
+ if (options.method !== "GET") {
63
+ return this.semaphore(() => request(options));
64
+ }
65
+ // Try to cache ETag for GET requests to save on rate limiting.
66
+ // Hits on ETag does not count towards rate limiting.
67
+ const rest = {
68
+ ...options,
69
+ };
70
+ delete rest.method;
71
+ delete rest.baseUrl;
72
+ delete rest.headers;
73
+ delete rest.mediaType;
74
+ delete rest.request;
75
+ // Build a key that is used to identify this request.
76
+ const key = Buffer.from(JSON.stringify(rest)).toString("base64");
77
+ const cacheItem = this.cache.retrieveJson(key);
78
+ if (cacheItem !== undefined) {
79
+ // Copying doesn't work, seems we need to mutate this.
80
+ options.headers["If-None-Match"] = cacheItem.data.etag;
81
+ }
82
+ const getResponse = async (allowRetry = true) => {
83
+ try {
84
+ return await request(options);
85
+ }
86
+ catch (e) {
87
+ // Handle no change in ETag.
88
+ if (e.status === 304) {
89
+ return undefined;
90
+ }
91
+ // GitHub seems to throw a lot of 502 errors.
92
+ // Let's give it a few seconds and retry one time.
93
+ if (e.status === 502 && allowRetry) {
94
+ await new Promise((resolve) => setTimeout(resolve, 2000));
95
+ return await getResponse(false);
96
+ }
97
+ throw e;
98
+ }
99
+ };
100
+ const response = await this.semaphore(async () => {
101
+ return getResponse();
102
+ });
103
+ if (response === undefined) {
104
+ // Undefined is returned for cached data.
105
+ if (cacheItem === undefined) {
106
+ throw new Error("Missing expected cache item");
107
+ }
108
+ // Use previous value.
109
+ return cacheItem.data.data;
110
+ }
111
+ // New value. Store Etag.
112
+ if (response.headers.etag) {
113
+ this.cache.storeJson(key, {
114
+ etag: response.headers.etag,
115
+ data: response,
116
+ });
117
+ }
118
+ return response;
119
+ });
120
+ }
121
+ async runGraphqlQuery(query) {
122
+ const token = await this.tokenProvider.getToken();
123
+ if (token === undefined) {
124
+ throw new Error("Missing token for GitHub");
125
+ }
126
+ const url = "https://api.github.com/graphql";
127
+ const headers = {
128
+ Authorization: `Bearer ${token}`,
129
+ };
130
+ let requestDuration = -1;
131
+ const response = await this.semaphore(() => {
132
+ const requestStart = performance.now();
133
+ const result = fetch(url, {
134
+ method: "POST",
135
+ headers,
136
+ body: JSON.stringify({ query }),
137
+ });
138
+ requestDuration = performance.now() - requestStart;
139
+ return result;
140
+ });
141
+ if (response.status === 401) {
142
+ process.stderr.write("Unauthorized\n");
143
+ await this.tokenProvider.markInvalid();
144
+ }
145
+ // If you get 502 after 10s, it is a timeout.
146
+ if (response.status === 502) {
147
+ throw new Error(`Response from Github likely timed out (10s max) after elapsed ${requestDuration}ms with status ${response.status}: ${await response.text()}`);
148
+ }
149
+ if (!response.ok) {
150
+ throw new Error(`Response from GitHub not OK (${response.status}): ${await response.text()}`);
151
+ }
152
+ const json = (await response.json());
153
+ if (json.errors) {
154
+ throw new Error(`Error from GitHub GraphQL API: ${JSON.stringify(json.errors)}`);
155
+ }
156
+ if (json.data == null) {
157
+ throw new Error("No data received from GitHub GraphQL API (unknown reason)");
158
+ }
159
+ return json.data;
160
+ }
161
+ async getOrgRepoList({ org }) {
162
+ const getQuery = (after) => `{
163
+ organization(login: "${org}") {
164
+ repositories(first: 100${after === null ? "" : `, after: "${after}"`}) {
165
+ totalCount
166
+ pageInfo {
167
+ hasNextPage
168
+ endCursor
169
+ }
170
+ nodes {
171
+ name
172
+ owner {
173
+ login
174
+ }
175
+ defaultBranchRef {
176
+ name
177
+ }
178
+ createdAt
179
+ updatedAt
180
+ isArchived
181
+ sshUrl
182
+ repositoryTopics(first: 100) {
183
+ edges {
184
+ node {
185
+ topic {
186
+ name
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }`;
195
+ return this.cache.json(`repos-${org}`, async () => {
196
+ const repos = [];
197
+ let after = null;
198
+ while (true) {
199
+ const query = getQuery(after);
200
+ const res = await this.runGraphqlQuery(query);
201
+ if (res.organization == null) {
202
+ throw new Error("Missing organization");
203
+ }
204
+ if (res.organization.repositories.nodes == null) {
205
+ throw new Error("Missing organization nodes");
206
+ }
207
+ repos.push(...res.organization.repositories.nodes);
208
+ if (!res.organization.repositories.pageInfo.hasNextPage) {
209
+ break;
210
+ }
211
+ after = res.organization.repositories.pageInfo.endCursor;
212
+ }
213
+ return repos.sort((a, b) => a.name.localeCompare(b.name));
214
+ });
215
+ }
46
216
  }
47
- function sortBy(arr, getKey) {
48
- return [...arr].sort((a, b) => getKey(a).localeCompare(getKey(b)));
217
+ async function createOctokit(tokenProvider) {
218
+ return new Octokit({
219
+ auth: await tokenProvider.getToken(),
220
+ });
49
221
  }
50
- function sumBy(array, iteratee) {
51
- return array.reduce((sum, item) => sum + iteratee(item), 0);
222
+ async function createGitHubService(props) {
223
+ const tokenProvider = props.tokenProvider ?? new GitHubTokenCliProvider();
224
+ return new GitHubService({
225
+ octokit: await createOctokit(tokenProvider),
226
+ cache: props.cache,
227
+ tokenProvider,
228
+ });
52
229
  }
53
230
 
54
- var type = "object";
55
- var properties = {
56
- snyk: {
57
- type: "object",
58
- properties: {
59
- accountId: {
60
- type: "string"
61
- }
62
- },
63
- required: [
64
- "accountId"
65
- ]
66
- },
67
- github: {
68
- type: "object",
69
- properties: {
70
- users: {
71
- type: "array",
72
- items: {
73
- $ref: "#/definitions/User"
74
- }
75
- },
76
- teams: {
77
- type: "array",
78
- items: {
79
- type: "object",
80
- properties: {
81
- organization: {
82
- type: "string"
83
- },
84
- teams: {
85
- type: "array",
86
- items: {
87
- $ref: "#/definitions/Team"
88
- }
89
- }
90
- },
91
- required: [
92
- "organization",
93
- "teams"
94
- ]
95
- }
96
- }
97
- },
98
- required: [
99
- "teams",
100
- "users"
101
- ]
102
- },
103
- projects: {
104
- type: "array",
105
- items: {
106
- $ref: "#/definitions/Project"
107
- }
108
- }
109
- };
110
- var required = [
111
- "github",
112
- "projects"
113
- ];
114
- var definitions = {
115
- User: {
116
- anyOf: [
117
- {
118
- $ref: "#/definitions/UserBot"
119
- },
120
- {
121
- $ref: "#/definitions/UserEmployee"
122
- },
123
- {
124
- $ref: "#/definitions/UserExternal"
125
- }
126
- ]
127
- },
128
- UserBot: {
129
- type: "object",
130
- properties: {
131
- type: {
132
- type: "string",
133
- "const": "bot"
134
- },
135
- login: {
136
- type: "string"
137
- },
138
- name: {
139
- type: "string"
140
- }
141
- },
142
- required: [
143
- "login",
144
- "name",
145
- "type"
146
- ]
147
- },
148
- UserEmployee: {
149
- type: "object",
150
- properties: {
151
- type: {
152
- type: "string",
153
- "const": "employee"
154
- },
155
- login: {
156
- type: "string"
157
- },
158
- capraUsername: {
159
- type: "string"
160
- },
161
- name: {
162
- type: "string"
163
- }
164
- },
165
- required: [
166
- "capraUsername",
167
- "login",
168
- "name",
169
- "type"
170
- ]
171
- },
172
- UserExternal: {
173
- type: "object",
174
- properties: {
175
- type: {
176
- type: "string",
177
- "const": "external"
178
- },
179
- login: {
180
- type: "string"
181
- },
182
- name: {
183
- type: "string"
184
- }
185
- },
186
- required: [
187
- "login",
188
- "name",
189
- "type"
190
- ]
191
- },
192
- Team: {
193
- type: "object",
194
- properties: {
195
- name: {
196
- type: "string"
197
- },
198
- members: {
199
- type: "array",
200
- items: {
201
- type: "string"
202
- }
203
- }
204
- },
205
- required: [
206
- "members",
207
- "name"
208
- ]
209
- },
210
- Project: {
211
- type: "object",
212
- properties: {
213
- name: {
214
- type: "string"
215
- },
216
- github: {
217
- type: "array",
218
- items: {
219
- type: "object",
220
- properties: {
221
- organization: {
222
- type: "string"
223
- },
224
- repos: {
225
- type: "array",
226
- items: {
227
- $ref: "#/definitions/DefinitionRepo"
228
- }
229
- },
230
- teams: {
231
- type: "array",
232
- items: {
233
- $ref: "#/definitions/RepoTeam"
234
- }
235
- }
236
- },
237
- required: [
238
- "organization"
239
- ]
240
- }
241
- },
242
- tags: {
243
- type: "array",
244
- items: {
245
- type: "string"
246
- }
247
- },
248
- responsible: {
249
- description: "Some external-defined entity being responsible for the project.",
250
- type: "string"
251
- }
252
- },
253
- required: [
254
- "github",
255
- "name"
256
- ]
257
- },
258
- DefinitionRepo: {
259
- type: "object",
260
- properties: {
261
- name: {
262
- type: "string"
263
- },
264
- previousNames: {
265
- type: "array",
266
- items: {
267
- $ref: "#/definitions/DefinitionRepoPreviousName"
268
- }
269
- },
270
- archived: {
271
- type: "boolean"
272
- },
273
- issues: {
274
- type: "boolean"
275
- },
276
- wiki: {
277
- type: "boolean"
278
- },
279
- teams: {
280
- type: "array",
281
- items: {
282
- $ref: "#/definitions/RepoTeam"
283
- }
284
- },
285
- snyk: {
286
- type: "boolean"
287
- },
288
- "public": {
289
- type: "boolean"
290
- },
291
- responsible: {
292
- description: "Some external-defined entity being responsible for the repository.\n\nWill override the project-defined responsible.",
293
- type: "string"
294
- }
295
- },
296
- required: [
297
- "name"
298
- ]
299
- },
300
- DefinitionRepoPreviousName: {
301
- type: "object",
302
- properties: {
303
- name: {
304
- type: "string"
305
- },
306
- project: {
307
- type: "string"
308
- }
309
- },
310
- required: [
311
- "name",
312
- "project"
313
- ]
314
- },
315
- RepoTeam: {
316
- type: "object",
317
- properties: {
318
- name: {
319
- type: "string"
320
- },
321
- permission: {
322
- $ref: "#/definitions/Permission"
323
- }
324
- },
325
- required: [
326
- "name",
327
- "permission"
328
- ]
329
- },
330
- Permission: {
331
- "enum": [
332
- "admin",
333
- "pull",
334
- "push"
335
- ],
336
- type: "string"
337
- }
338
- };
339
- var $schema = "http://json-schema.org/draft-07/schema#";
340
- var schema = {
341
- type: type,
342
- properties: properties,
343
- required: required,
344
- definitions: definitions,
345
- $schema: $schema
346
- };
347
-
348
- function getTeamId(org, teamName) {
349
- return `${org}/${teamName}`;
350
- }
351
- function getRepoId(orgName, repoName) {
352
- return `${orgName}/${repoName}`;
353
- }
354
- function checkAgainstSchema(value) {
355
- const ajv = new AJV({ allErrors: true });
356
- const valid = ajv.validate(schema, value);
357
- return valid
358
- ? { definition: value }
359
- : { error: ajv.errorsText() ?? "Unknown error" };
360
- }
361
- function requireValidDefinition(definition) {
362
- // Verify no duplicates in users and extract known logins.
363
- const loginList = definition.github.users.reduce((acc, user) => {
364
- if (acc.includes(user.login)) {
365
- throw new Error(`Duplicate login: ${user.login}`);
366
- }
367
- return [...acc, user.login];
368
- }, []);
369
- // Verify no duplicates in teams and extract team names.
370
- const teamIdList = definition.github.teams.reduce((acc, orgTeams) => {
371
- return orgTeams.teams.reduce((acc1, team) => {
372
- const id = getTeamId(orgTeams.organization, team.name);
373
- if (acc1.includes(id)) {
374
- throw new Error(`Duplicate team: ${id}`);
375
- }
376
- return [...acc1, id];
377
- }, acc);
378
- }, []);
379
- // Verify team members exists as users.
380
- definition.github.teams
381
- .flatMap((it) => it.teams)
382
- .forEach((team) => {
383
- team.members.forEach((login) => {
384
- if (!loginList.includes(login)) {
385
- throw new Error(`Team member ${login} in team ${team.name} is not registered in user list`);
386
- }
387
- });
388
- });
389
- // Verify no duplicates in project names.
390
- definition.projects.reduce((acc, project) => {
391
- if (acc.includes(project.name)) {
392
- throw new Error(`Duplicate project: ${project.name}`);
393
- }
394
- return [...acc, project.name];
395
- }, []);
396
- definition.projects.forEach((project) => {
397
- project.github.forEach((org) => {
398
- (org.teams || []).forEach((team) => {
399
- const id = getTeamId(org.organization, team.name);
400
- if (!teamIdList.includes(id)) {
401
- throw new Error(`Project team ${id} in project ${project.name} is not registered in team list`);
402
- }
403
- }) // Verify repo teams exists as teams.
404
- ;
405
- (org.repos || []).forEach((repo) => {
406
- (repo.teams || []).forEach((team) => {
407
- const id = getTeamId(org.organization, team.name);
408
- if (!teamIdList.includes(id)) {
409
- throw new Error(`Repo team ${id} for repo ${repo.name} in project ${project.name} is not registered in team list`);
410
- }
411
- });
412
- });
413
- });
414
- });
415
- // Verify no duplicates in repos.
416
- definition.projects
417
- .flatMap((project) => project.github.flatMap((org) => (org.repos || []).map((repo) => getRepoId(org.organization, repo.name))))
418
- .reduce((acc, repoName) => {
419
- if (acc.includes(repoName)) {
420
- throw new Error(`Duplicate repo: ${repoName}`);
421
- }
422
- return [...acc, repoName];
423
- }, []);
424
- }
425
- class DefinitionFile {
426
- path;
427
- constructor(path) {
428
- this.path = path;
429
- }
430
- async getContents() {
431
- return new Promise((resolve, reject) => fs.readFile(this.path, "utf-8", (err, data) => {
432
- if (err)
433
- reject(err);
434
- else
435
- resolve(data);
436
- }));
437
- }
438
- async getDefinition() {
439
- return parseDefinition(await this.getContents());
440
- }
441
- }
442
- function parseDefinition(value) {
443
- const result = checkAgainstSchema(yaml.load(value));
444
- if ("error" in result) {
445
- throw new Error(`Definition content invalid: ${result.error}`);
446
- }
447
- requireValidDefinition(result.definition);
448
- return result.definition;
449
- }
450
- function getRepos(definition) {
451
- return definition.projects.flatMap((project) => project.github.flatMap((org) => (org.repos || []).map((repo) => ({
452
- id: getRepoId(org.organization, repo.name),
453
- orgName: org.organization,
454
- project,
455
- repo,
456
- }))));
457
- }
458
- function getGitHubOrgs(definition) {
459
- const githubOrganizations = definition.projects.flatMap((project) => project.github.map((it) => it.organization));
460
- return uniq(githubOrganizations);
461
- }
462
-
463
- class GitHubTokenCliProvider {
464
- keyringService = "cals";
465
- keyringAccount = "github-token";
466
- async getToken() {
467
- if (process$2.env.CALS_GITHUB_TOKEN) {
468
- return process$2.env.CALS_GITHUB_TOKEN;
469
- }
470
- const result = await keytar.getPassword(this.keyringService, this.keyringAccount);
471
- if (result == null) {
472
- process$2.stderr.write("No token found. Register using `cals github set-token`\n");
473
- return undefined;
474
- }
475
- return result;
476
- }
477
- async markInvalid() {
478
- await keytar.deletePassword(this.keyringService, this.keyringAccount);
479
- }
480
- async setToken(value) {
481
- await keytar.setPassword(this.keyringService, this.keyringAccount, value);
482
- }
483
- }
484
-
485
- function getGroup(repo) {
486
- const projectTopics = [];
487
- let isInfra = false;
488
- repo.repositoryTopics.edges.forEach((edge) => {
489
- const name = edge.node.topic.name;
490
- if (name.startsWith("customer-")) {
491
- projectTopics.push(name.substring(9));
492
- }
493
- if (name.startsWith("project-")) {
494
- projectTopics.push(name.substring(8));
495
- }
496
- if (name === "infrastructure") {
497
- isInfra = true;
498
- }
499
- });
500
- if (projectTopics.length > 1) {
501
- console.warn(`Repo ${repo.name} has multiple project groups: ${projectTopics.join(", ")}. Picking first`);
502
- }
503
- if (projectTopics.length > 0) {
504
- return projectTopics[0];
505
- }
506
- if (isInfra) {
507
- return "infrastructure";
508
- }
509
- return null;
510
- }
511
- function ifnull(a, other) {
512
- return a === null ? other : a;
513
- }
514
- function getGroupedRepos(repos) {
515
- return Object.values(repos.reduce((acc, repo) => {
516
- const group = ifnull(getGroup(repo), "(unknown)");
517
- const value = acc[group] || { name: group, items: [] };
518
- return {
519
- ...acc,
520
- [group]: {
521
- ...value,
522
- items: [...value.items, repo],
523
- },
524
- };
525
- }, {})).sort((a, b) => a.name.localeCompare(b.name));
526
- }
527
- function includesTopic(repo, topic) {
528
- return repo.repositoryTopics.edges.some((it) => it.node.topic.name === topic);
529
- }
530
- async function undefinedForNotFound(value) {
531
- try {
532
- return await value;
533
- }
534
- catch (e) {
535
- if (e.name === "HttpError" && e.status === 404) {
536
- return undefined;
537
- }
538
- throw e;
539
- }
540
- }
541
-
542
- class GitHubService {
543
- config;
544
- octokit;
545
- cache;
546
- tokenProvider;
547
- semaphore;
548
- constructor(props) {
549
- this.config = props.config;
550
- this.octokit = props.octokit;
551
- this.cache = props.cache;
552
- this.tokenProvider = props.tokenProvider;
553
- // Control concurrency to GitHub API at service level so we
554
- // can maximize concurrency all other places.
555
- this.semaphore = pLimit(6);
556
- this.octokit.hook.wrap("request", async (request, options) => {
557
- this._requestCount++;
558
- if (options.method !== "GET") {
559
- return this.semaphore(() => request(options));
560
- }
561
- // Try to cache ETag for GET requests to save on rate limiting.
562
- // Hits on ETag does not count towards rate limiting.
563
- const rest = {
564
- ...options,
565
- };
566
- delete rest.method;
567
- delete rest.baseUrl;
568
- delete rest.headers;
569
- delete rest.mediaType;
570
- delete rest.request;
571
- // Build a key that is used to identify this request.
572
- const key = Buffer.from(JSON.stringify(rest)).toString("base64");
573
- const cacheItem = this.cache.retrieveJson(key);
574
- if (cacheItem !== undefined) {
575
- // Copying doesn't work, seems we need to mutate this.
576
- options.headers["If-None-Match"] = cacheItem.data.etag;
577
- }
578
- const getResponse = async (allowRetry = true) => {
579
- try {
580
- return await request(options);
581
- }
582
- catch (e) {
583
- // Handle no change in ETag.
584
- if (e.status === 304) {
585
- return undefined;
586
- }
587
- // GitHub seems to throw a lot of 502 errors.
588
- // Let's give it a few seconds and retry one time.
589
- if (e.status === 502 && allowRetry) {
590
- await new Promise((resolve) => setTimeout(resolve, 2000));
591
- return await getResponse(false);
592
- }
593
- throw e;
594
- }
595
- };
596
- const response = await this.semaphore(async () => {
597
- return getResponse();
598
- });
599
- if (response === undefined) {
600
- // Undefined is returned for cached data.
601
- if (cacheItem === undefined) {
602
- throw new Error("Missing expected cache item");
603
- }
604
- // Use previous value.
605
- return cacheItem.data.data;
606
- }
607
- // New value. Store Etag.
608
- if (response.headers.etag) {
609
- this.cache.storeJson(key, {
610
- etag: response.headers.etag,
611
- data: response,
612
- });
613
- }
614
- return response;
615
- });
616
- }
617
- _requestCount = 0;
618
- get requestCount() {
619
- return this._requestCount;
620
- }
621
- async runGraphqlQuery(query) {
622
- const token = await this.tokenProvider.getToken();
623
- if (token === undefined) {
624
- throw new Error("Missing token for GitHub");
625
- }
626
- const url = "https://api.github.com/graphql";
627
- const headers = {
628
- Authorization: `Bearer ${token}`,
629
- };
630
- let requestDuration = -1;
631
- const response = await this.semaphore(() => {
632
- const requestStart = performance.now();
633
- const result = fetch(url, {
634
- method: "POST",
635
- headers,
636
- body: JSON.stringify({ query }),
637
- agent: this.config.agent,
638
- });
639
- requestDuration = performance.now() - requestStart;
640
- return result;
641
- });
642
- if (response.status === 401) {
643
- process$1.stderr.write("Unauthorized\n");
644
- await this.tokenProvider.markInvalid();
645
- }
646
- // If you get 502 after 10s, it is a timeout.
647
- if (response.status === 502) {
648
- throw new Error(`Response from Github likely timed out (10s max) after elapsed ${requestDuration}ms with status ${response.status}: ${await response.text()}`);
649
- }
650
- if (!response.ok) {
651
- throw new Error(`Response from GitHub not OK (${response.status}): ${await response.text()}`);
652
- }
653
- const json = (await response.json());
654
- if (json.errors) {
655
- throw new Error(`Error from GitHub GraphQL API: ${JSON.stringify(json.errors)}`);
656
- }
657
- if (json.data == null) {
658
- throw new Error("No data received from GitHub GraphQL API (unknown reason)");
659
- }
660
- return json.data;
661
- }
662
- async getOrgRepoList({ org }) {
663
- const getQuery = (after) => `{
664
- organization(login: "${org}") {
665
- repositories(first: 100${after === null ? "" : `, after: "${after}"`}) {
666
- totalCount
667
- pageInfo {
668
- hasNextPage
669
- endCursor
670
- }
671
- nodes {
672
- name
673
- owner {
674
- login
675
- }
676
- defaultBranchRef {
677
- name
678
- }
679
- createdAt
680
- updatedAt
681
- isArchived
682
- sshUrl
683
- repositoryTopics(first: 100) {
684
- edges {
685
- node {
686
- topic {
687
- name
688
- }
689
- }
690
- }
691
- }
692
- }
693
- }
694
- }
695
- }`;
696
- return this.cache.json(`repos-${org}`, async () => {
697
- const repos = [];
698
- let after = null;
699
- while (true) {
700
- const query = getQuery(after);
701
- const res = await this.runGraphqlQuery(query);
702
- if (res.organization == null) {
703
- throw new Error("Missing organization");
704
- }
705
- if (res.organization.repositories.nodes == null) {
706
- throw new Error("Missing organization nodes");
707
- }
708
- repos.push(...res.organization.repositories.nodes);
709
- if (!res.organization.repositories.pageInfo.hasNextPage) {
710
- break;
711
- }
712
- after = res.organization.repositories.pageInfo.endCursor;
713
- }
714
- return repos.sort((a, b) => a.name.localeCompare(b.name));
715
- });
716
- }
717
- async getOrgMembersList(org) {
718
- const options = this.octokit.orgs.listMembers.endpoint.merge({
719
- org,
720
- });
721
- return ((await undefinedForNotFound(this.octokit.paginate(options))) || []);
722
- }
723
- async getOrgMembersInvitedList(org) {
724
- const options = this.octokit.orgs.listPendingInvitations.endpoint.merge({
725
- org,
726
- });
727
- return ((await undefinedForNotFound(this.octokit.paginate(options))) || []);
728
- }
729
- async getOrgMembersListIncludingInvited(org) {
730
- return [
731
- ...(await this.getOrgMembersList(org)).map((it) => ({
732
- type: "member",
733
- login: it.login,
734
- data: it,
735
- })),
736
- ...(await this.getOrgMembersInvitedList(org)).map((it) => ({
737
- type: "invited",
738
- // TODO: Fix ?? case properly
739
- login: it.login ?? "invalid",
740
- data: it,
741
- })),
742
- ];
743
- }
744
- async getRepository(owner, repo) {
745
- return this.cache.json(`get-repository-${owner}-${repo}`, async () => {
746
- const response = await undefinedForNotFound(this.octokit.repos.get({
747
- owner,
748
- repo,
749
- }));
750
- return response === undefined ? undefined : response.data;
751
- });
752
- }
753
- async getRepositoryTeamsList(repo) {
754
- return this.cache.json(`repository-teams-list-${repo.id}`, async () => {
755
- const options = this.octokit.repos.listTeams.endpoint.merge({
756
- owner: repo.owner.login,
757
- repo: repo.name,
758
- });
759
- return ((await undefinedForNotFound(this.octokit.paginate(options))) || []);
760
- });
761
- }
762
- async getRepositoryHooks(owner, repo) {
763
- return this.cache.json(`repository-hooks-${owner}-${repo}`, async () => {
764
- const options = this.octokit.repos.listWebhooks.endpoint.merge({
765
- owner,
766
- repo,
767
- });
768
- return ((await undefinedForNotFound(this.octokit.paginate(options))) || []);
769
- });
770
- }
771
- async getOrg(org) {
772
- const orgResponse = await this.octokit.orgs.get({
773
- org,
774
- });
775
- return orgResponse.data;
776
- }
777
- async getTeamList(org) {
778
- return this.cache.json(`team-list-${org.login}`, async () => {
779
- const options = this.octokit.teams.list.endpoint.merge({
780
- org: org.login,
781
- });
782
- return (await this.octokit.paginate(options));
783
- });
784
- }
785
- async getTeamMemberList(org, team) {
786
- return this.cache.json(`team-member-list-${team.id}`, async () => {
787
- const options = this.octokit.teams.listMembersInOrg.endpoint.merge({
788
- org: org.login,
789
- team_slug: team.slug,
790
- });
791
- return (await this.octokit.paginate(options));
792
- });
793
- }
794
- async getTeamMemberInvitedList(org, team) {
795
- return this.cache.json(`team-member-invited-list-${team.id}`, async () => {
796
- const options = this.octokit.teams.listPendingInvitationsInOrg.endpoint.merge({
797
- org: org.login,
798
- team_slug: team.slug,
799
- });
800
- return (await this.octokit.paginate(options));
801
- });
802
- }
803
- async getTeamMemberListIncludingInvited(org, team) {
804
- return [
805
- ...(await this.getTeamMemberList(org, team)).map((it) => ({
806
- type: "member",
807
- login: it.login,
808
- data: it,
809
- })),
810
- ...(await this.getTeamMemberInvitedList(org, team)).map((it) => ({
811
- type: "invited",
812
- // TODO: Fix ?? case properly
813
- login: it.login ?? "invalid",
814
- data: it,
815
- })),
816
- ];
817
- }
818
- async getSearchedPullRequestList(owner) {
819
- // NOTE: Changes to this must by synced with SearchedPullRequestListQueryResult.
820
- const getQuery = (after) => `{
821
- search(
822
- query: "is:open is:pr user:${owner} owner:${owner} archived:false",
823
- type: ISSUE,
824
- first: 50${after === null
825
- ? ""
826
- : `,
827
- after: "${after}"`}
828
- ) {
829
- pageInfo {
830
- hasNextPage
831
- endCursor
832
- }
833
- edges {
834
- node {
835
- __typename
836
- ... on PullRequest {
837
- number
838
- baseRepository {
839
- name
840
- owner {
841
- login
842
- }
843
- defaultBranchRef {
844
- name
845
- }
846
- }
847
- author {
848
- login
849
- }
850
- title
851
- commits(first: 3) {
852
- nodes {
853
- commit {
854
- messageHeadline
855
- }
856
- }
857
- }
858
- createdAt
859
- updatedAt
860
- }
861
- }
862
- }
863
- }
864
- }`;
865
- const pulls = [];
866
- let after = null;
867
- while (true) {
868
- const query = getQuery(after);
869
- const res = await this.runGraphqlQuery(query);
870
- pulls.push(...res.search.edges.map((it) => it.node));
871
- if (!res.search.pageInfo.hasNextPage) {
872
- break;
873
- }
874
- after = res.search.pageInfo.endCursor;
875
- }
876
- return pulls.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
877
- }
878
- async getHasVulnerabilityAlertsEnabled(owner, repo) {
879
- try {
880
- const response = await this.octokit.repos.checkVulnerabilityAlerts({
881
- owner: owner,
882
- repo: repo,
883
- });
884
- if (response.status !== 204) {
885
- console.log(response);
886
- throw new Error("Unknown response - see previous log line");
887
- }
888
- return true;
889
- }
890
- catch (e) {
891
- if (e.status === 404) {
892
- return false;
893
- }
894
- throw e;
895
- }
896
- }
897
- async enableVulnerabilityAlerts(owner, repo) {
898
- await this.octokit.repos.enableVulnerabilityAlerts({
899
- owner: owner,
900
- repo: repo,
901
- });
902
- }
903
- /**
904
- * Get the vulnerability alerts for a repository.
905
- */
906
- async getVulnerabilityAlerts(owner, repo) {
907
- // NOTE: Changes to this must by synced with VulnerabilityAlertsQueryResult.
908
- const getQuery = (after) => `{
909
- repository(owner: "${owner}", name: "${repo}") {
910
- vulnerabilityAlerts(first: 100${after === null ? "" : `, after: "${after}"`}) {
911
- pageInfo {
912
- hasNextPage
913
- endCursor
914
- }
915
- edges {
916
- node {
917
- state
918
- dismissReason
919
- vulnerableManifestFilename
920
- vulnerableManifestPath
921
- vulnerableRequirements
922
- securityAdvisory {
923
- description
924
- identifiers { type value }
925
- references { url }
926
- severity
927
- }
928
- securityVulnerability {
929
- package { name ecosystem }
930
- firstPatchedVersion { identifier }
931
- vulnerableVersionRange
932
- }
933
- }
934
- }
935
- }
936
- }
937
- }`;
938
- return this.cache.json(`vulnerability-alerts-${owner}-${repo}`, async () => {
939
- const result = [];
940
- let after = null;
941
- while (true) {
942
- const query = getQuery(after);
943
- const res = await this.runGraphqlQuery(query);
944
- result.push(...(res.repository?.vulnerabilityAlerts.edges?.map((it) => it.node) ?? []));
945
- if (!res.repository?.vulnerabilityAlerts.pageInfo.hasNextPage) {
946
- break;
947
- }
948
- after = res.repository?.vulnerabilityAlerts.pageInfo.endCursor;
949
- }
950
- return result;
951
- });
952
- }
953
- /**
954
- * Get the Renovate Dependency Dashboard issue.
955
- */
956
- async getRenovateDependencyDashboardIssue(owner, repo) {
957
- // NOTE: Changes to this must by synced with RenovateDependencyDashboardIssueQueryResult.
958
- const getQuery = (after) => `{
959
- repository(owner: "${owner}", name: "${repo}") {
960
- issues(
961
- orderBy: {field: UPDATED_AT, direction: DESC},
962
- filterBy: {createdBy: "renovate[bot]"},
963
- states: [OPEN],
964
- first: 100${after === null ? "" : `, after: "${after}"`}
965
- ) {
966
- pageInfo {
967
- hasNextPage
968
- endCursor
969
- }
970
- edges {
971
- node {
972
- number
973
- state
974
- title
975
- body
976
- userContentEdits(first: 5) {
977
- nodes {
978
- createdAt
979
- editor {
980
- login
981
- }
982
- }
983
- }
984
- }
985
- }
986
- }
987
- }
988
- }`;
989
- const issues = await this.cache.json(`renovate-bot-issues-${owner}-${repo}`, async () => {
990
- const result = [];
991
- let after = null;
992
- while (true) {
993
- const query = getQuery(after);
994
- const res = await this.runGraphqlQuery(query);
995
- const nodes = res.repository?.issues.edges?.map((it) => it.node) ?? [];
996
- result.push(...nodes
997
- .filter((it) => it.title === "Dependency Dashboard")
998
- .map((it) => ({
999
- number: it.number,
1000
- body: it.body,
1001
- lastUpdatedByRenovate: it.userContentEdits?.nodes?.filter((it) => it.editor?.login === "renovate")?.[0]?.createdAt ?? null,
1002
- })));
1003
- if (!res.repository?.issues.pageInfo.hasNextPage) {
1004
- break;
1005
- }
1006
- after = res.repository?.issues.pageInfo.endCursor;
1007
- }
1008
- return result;
1009
- });
1010
- if (issues.length == 0) {
1011
- return undefined;
1012
- }
1013
- return issues[0];
1014
- }
1015
- }
1016
- async function createOctokit(config, tokenProvider) {
1017
- return new Octokit({
1018
- auth: await tokenProvider.getToken(),
1019
- request: {
1020
- agent: config.agent,
1021
- },
1022
- });
1023
- }
1024
- async function createGitHubService(props) {
1025
- const tokenProvider = props.tokenProvider ?? new GitHubTokenCliProvider();
1026
- return new GitHubService({
1027
- config: props.config,
1028
- octokit: await createOctokit(props.config, tokenProvider),
1029
- cache: props.cache,
1030
- tokenProvider,
1031
- });
1032
- }
1033
-
1034
- class SnykTokenCliProvider {
1035
- keyringService = "cals";
1036
- keyringAccount = "snyk-token";
1037
- async getToken() {
1038
- if (process$2.env.CALS_SNYK_TOKEN) {
1039
- return process$2.env.CALS_SNYK_TOKEN;
1040
- }
1041
- const result = await keytar.getPassword(this.keyringService, this.keyringAccount);
1042
- if (result == null) {
1043
- process$2.stderr.write("No token found. Register using `cals snyk set-token`\n");
1044
- return undefined;
1045
- }
1046
- return result;
1047
- }
1048
- async markInvalid() {
1049
- await keytar.deletePassword(this.keyringService, this.keyringAccount);
1050
- }
1051
- async setToken(value) {
1052
- await keytar.setPassword(this.keyringService, this.keyringAccount, value);
1053
- }
1054
- }
1055
-
1056
- class SnykService {
1057
- config;
1058
- tokenProvider;
1059
- constructor(props) {
1060
- this.config = props.config;
1061
- this.tokenProvider = props.tokenProvider;
1062
- }
1063
- async getProjects(definition) {
1064
- const snykAccountId = definition.snyk?.accountId;
1065
- if (snykAccountId === undefined) {
1066
- return [];
1067
- }
1068
- return this.getProjectsByAccountId(snykAccountId);
1069
- }
1070
- async getProjectsByAccountId(snykAccountId,
1071
- /**
1072
- * The slug name of a Snyk organization.
1073
- *
1074
- * NOTE: This is only used to construct the browsable URL for a given project, and is not being used
1075
- * in API calls to Snyk.
1076
- *
1077
- * @default - the slug corresponding to Lifligs Snyk organization ("it").
1078
- */
1079
- snykOrgSlugId) {
1080
- const token = await this.tokenProvider.getToken();
1081
- if (token === undefined) {
1082
- throw new Error("Missing token for Snyk");
1083
- }
1084
- let backportedProjects = [];
1085
- const snykRestApiVersion = "2025-11-05";
1086
- let nextUrl = `/rest/orgs/${encodeURIComponent(snykAccountId)}/projects?version=${snykRestApiVersion}&meta.latest_dependency_total=true&meta.latest_issue_counts=true&limit=100`;
1087
- /* The Snyk REST API only allows us to retrieve 100 projects at a time.
1088
- * The "links.next" value in the response gives us a pointer to the next 100 results.
1089
- * We continue calling the Snyk API and retrieving more projects until links.next is null
1090
- * */
1091
- while (nextUrl) {
1092
- const response = await fetch(`https://api.snyk.io${nextUrl}`, {
1093
- method: "GET",
1094
- headers: {
1095
- Accept: "application/json",
1096
- Authorization: `token ${token}`,
1097
- },
1098
- agent: this.config.agent,
1099
- });
1100
- if (response.status === 401) {
1101
- process$2.stderr.write("Unauthorized - removing token\n");
1102
- await this.tokenProvider.markInvalid();
1103
- }
1104
- if (!response.ok) {
1105
- throw new Error(`Response from Snyk not OK (${response.status}): ${JSON.stringify(response)}`);
1106
- }
1107
- // Check if the Sunset header is present in the response
1108
- const sunsetHeader = response.headers.get("Sunset") || response.headers.get("sunset");
1109
- if (sunsetHeader) {
1110
- console.warn(`Snyk endpoint with version ${snykRestApiVersion} has been marked as deprecated with deprecation date ${sunsetHeader}`);
1111
- }
1112
- const jsonResponse = (await response.json());
1113
- /* We transform the data to a standard format that we used for data from Snyk API v1 in order for
1114
- the data to be backover compatible with existing consuments */
1115
- backportedProjects = [
1116
- ...backportedProjects,
1117
- ...jsonResponse.data.map((project) => {
1118
- return {
1119
- id: project.id,
1120
- name: project.attributes.name,
1121
- type: project.attributes.type,
1122
- created: project.attributes.created,
1123
- origin: project.attributes.origin,
1124
- testFrequency: project.attributes.settings.recurring_tests.frequency,
1125
- isMonitored: project.attributes.status === "active",
1126
- totalDependencies: project.meta.latest_dependency_total.total,
1127
- issueCountsBySeverity: project.meta.latest_issue_counts,
1128
- lastTestedDate: project.meta.latest_dependency_total.updated_at,
1129
- browseUrl: `https://app.snyk.io/org/${snykOrgSlugId ?? "it"}/project/${project.id}`,
1130
- };
1131
- }),
1132
- ];
1133
- /* Update nextUrl with pointer to the next page of results based
1134
- * on the "links.next" field in the JSON response */
1135
- nextUrl = jsonResponse.links.next;
1136
- }
1137
- return backportedProjects;
1138
- }
1139
- }
1140
- function createSnykService(props) {
1141
- return new SnykService({
1142
- config: props.config,
1143
- tokenProvider: props.tokenProvider ?? new SnykTokenCliProvider(),
1144
- });
1145
- }
1146
-
1147
- function getGitHubRepo(snykProject) {
1148
- if (snykProject.origin === "github") {
1149
- const match = /^([^/]+)\/([^:]+)(:(.+))?$/.exec(snykProject.name);
1150
- if (match === null) {
1151
- throw Error(`Could not extract components from Snyk project name: ${snykProject.name} (id: ${snykProject.id})`);
1152
- }
1153
- return {
1154
- owner: match[1],
1155
- name: match[2],
1156
- };
1157
- }
1158
- if (snykProject.origin === "cli" && snykProject.remoteRepoUrl != null) {
1159
- // The remoteRepoUrl can be overridden when using the CLI, so don't
1160
- // fail if we cannot extract the value.
1161
- const match = /github.com\/([^/]+)\/(.+)\.git$/.exec(snykProject.remoteRepoUrl);
1162
- if (match === null) {
1163
- return undefined;
1164
- }
1165
- return {
1166
- owner: match[1],
1167
- name: match[2],
1168
- };
1169
- }
1170
- return undefined;
1171
- }
1172
- function getGitHubRepoId(repo) {
1173
- return repo ? `${repo.owner}/${repo.name}` : undefined;
1174
- }
1175
-
1176
- class CacheProvider {
1177
- constructor(config) {
1178
- this.config = config;
1179
- }
1180
- mustValidate = false;
1181
- config;
1182
- defaultCacheTime = 1800;
1183
- /**
1184
- * Retrieve cache if existent, ignoring the time.
1185
- *
1186
- * The caller is responsible for handling proper validation,
1187
- */
1188
- retrieveJson(cachekey) {
1189
- const cachefile = path.join(this.config.cacheDir, `${cachekey}.json`);
1190
- if (!fs.existsSync(cachefile)) {
1191
- return undefined;
1192
- }
1193
- const data = fs.readFileSync(cachefile, "utf-8");
1194
- return {
1195
- cacheTime: fs.statSync(cachefile).mtime.getTime(),
1196
- data: (data === "undefined" ? undefined : JSON.parse(data)),
1197
- };
1198
- }
1199
- /**
1200
- * Save data to cache.
1201
- */
1202
- storeJson(cachekey, data) {
1203
- const cachefile = path.join(this.config.cacheDir, `${cachekey}.json`);
1204
- if (!fs.existsSync(this.config.cacheDir)) {
1205
- fs.mkdirSync(this.config.cacheDir, { recursive: true });
1206
- }
1207
- fs.writeFileSync(cachefile, data === undefined ? "undefined" : JSON.stringify(data));
1208
- }
1209
- async json(cachekey, block, cachetime = this.defaultCacheTime) {
1210
- const cacheItem = this.mustValidate
1211
- ? undefined
1212
- : this.retrieveJson(cachekey);
1213
- const expire = new Date(Date.now() - cachetime * 1000).getTime();
1214
- if (cacheItem !== undefined && cacheItem.cacheTime > expire) {
1215
- return cacheItem.data;
1216
- }
1217
- const result = await block();
1218
- this.storeJson(cachekey, result);
1219
- return result;
1220
- }
1221
- /**
1222
- * Delete all cached data.
1223
- */
1224
- cleanup() {
1225
- fs.rmSync(this.config.cacheDir, { recursive: true, force: true });
1226
- }
1227
- }
1228
-
1229
- class Config {
1230
- cwd = path.resolve(process$2.cwd());
1231
- configFile = path.join(os.homedir(), ".cals-config.json");
1232
- cacheDir = cachedir("cals-cli");
1233
- agent = new https.Agent({
1234
- keepAlive: true,
1235
- });
1236
- configCached = undefined;
1237
- get config() {
1238
- const existingConfig = this.configCached;
1239
- if (existingConfig !== undefined) {
1240
- return existingConfig;
1241
- }
1242
- const config = this.readConfig();
1243
- this.configCached = config;
1244
- return config;
1245
- }
1246
- readConfig() {
1247
- if (!fs.existsSync(this.configFile)) {
1248
- return {};
1249
- }
1250
- try {
1251
- return JSON.parse(fs.readFileSync(this.configFile, "utf-8"));
1252
- }
1253
- catch (e) {
1254
- console.error("Failed", e);
1255
- throw new Error("Failed to read config");
1256
- }
1257
- }
1258
- getConfig(key) {
1259
- return this.config[key];
1260
- }
1261
- requireConfig(key) {
1262
- const result = this.config[key];
1263
- if (result === undefined) {
1264
- throw Error(`Configuration for ${key} missing. Add manually to ${this.configFile}`);
1265
- }
1266
- return result;
1267
- }
1268
- updateConfig(key, value) {
1269
- const updatedConfig = {
1270
- ...this.readConfig(),
1271
- [key]: value, // undefined will remove
1272
- };
1273
- fs.writeFileSync(this.configFile, JSON.stringify(updatedConfig, null, " "));
1274
- this.configCached = updatedConfig;
1275
- }
1276
- }
1277
-
1278
- const CLEAR_WHOLE_LINE = 0;
1279
- function clearLine(stdout) {
1280
- readline.clearLine(stdout, CLEAR_WHOLE_LINE);
1281
- readline.cursorTo(stdout, 0);
1282
- }
1283
- class Reporter {
1284
- constructor(opts = {}) {
1285
- this.nonInteractive = !!opts.nonInteractive;
1286
- this.isVerbose = !!opts.verbose;
1287
- }
1288
- stdout = process$2.stdout;
1289
- stderr = process$2.stderr;
1290
- nonInteractive;
1291
- isVerbose;
1292
- format = chalk;
1293
- error(msg) {
1294
- clearLine(this.stderr);
1295
- this.stderr.write(`${this.format.red("error")} ${msg}\n`);
1296
- }
1297
- log(msg) {
1298
- clearLine(this.stdout);
1299
- this.stdout.write(`${msg}\n`);
1300
- }
1301
- warn(msg) {
1302
- clearLine(this.stderr);
1303
- this.stderr.write(`${this.format.yellow("warning")} ${msg}\n`);
1304
- }
1305
- info(msg) {
1306
- clearLine(this.stdout);
1307
- this.stdout.write(`${this.format.blue("info")} ${msg}\n`);
1308
- }
1309
- }
1310
-
1311
- function createReporter(argv) {
1312
- return new Reporter({
1313
- verbose: !!argv.verbose,
1314
- nonInteractive: !!argv.nonInteractive,
1315
- });
1316
- }
1317
- function createCacheProvider(config, argv) {
1318
- const cache = new CacheProvider(config);
1319
- // --validate-cache
1320
- if (argv.validateCache === true) {
1321
- cache.mustValidate = true;
1322
- }
1323
- // old option: --no-cache
1324
- if (argv.cache === false) {
1325
- deprecate(() => {
1326
- cache.mustValidate = true;
1327
- }, "The --no-cache option is deprecated. See new --validate-cache option")();
1328
- }
1329
- return cache;
1330
- }
1331
- function createConfig() {
1332
- return new Config();
1333
- }
1334
- const definitionFileOptionName = "definition-file";
1335
- const definitionFileOptionValue = {
1336
- describe: "Path to definition file, which should be the latest resources.yaml file from https://github.com/capralifecycle/resources-definition",
1337
- demandOption: true,
1338
- type: "string",
1339
- };
1340
- function getDefinitionFile(argv) {
1341
- if (argv.definitionFile === undefined) {
1342
- throw Error("Missing --definition-file option");
1343
- }
1344
- const definitionFile = argv.definitionFile;
1345
- if (!fs.existsSync(definitionFile)) {
1346
- throw Error(`The file ${definitionFile} does not exist`);
1347
- }
1348
- return new DefinitionFile(definitionFile);
1349
- }
1350
-
1351
- async function reportRateLimit(reporter, github, block) {
1352
- reporter.info(`Rate limit: ${(await github.octokit.rateLimit.get()).data.rate.remaining}`);
1353
- await block();
1354
- reporter.info(`Rate limit: ${(await github.octokit.rateLimit.get()).data.rate.remaining}`);
1355
- }
1356
-
1357
- /**
1358
- * Reorder a list to preserve same order as it had previously.
1359
- *
1360
- * Not a very pretty algorithm but it works for us.
1361
- */
1362
- function reorderListToSimilarAsBefore(oldList, updatedList, selector, insertLast = false) {
1363
- let result = [];
1364
- // Keep items present in old list.
1365
- let remaining1 = [...updatedList];
1366
- for (const old of oldList) {
1367
- let found = false;
1368
- for (const it of remaining1) {
1369
- if (selector(it) === selector(old)) {
1370
- found = true;
1371
- result.push(it);
1372
- }
1373
- }
1374
- if (found) {
1375
- remaining1 = remaining1.filter((it) => selector(old) !== selector(it));
1376
- }
1377
- }
1378
- const remaining = updatedList.filter((updated) => !result.some((it) => selector(it) == selector(updated)));
1379
- if (insertLast) {
1380
- result.push(...remaining);
1381
- }
1382
- else {
1383
- // Insert remaining at first position by ordering.
1384
- for (const it of remaining) {
1385
- let found = false;
1386
- for (let i = 0; i < result.length; i++) {
1387
- if (selector(result[i]).localeCompare(selector(it)) > 0) {
1388
- found = true;
1389
- result = [...result.slice(0, i), it, ...result.slice(i)];
1390
- break;
1391
- }
1392
- }
1393
- if (!found) {
1394
- result.push(it);
1395
- }
1396
- }
1397
- }
1398
- return result;
1399
- }
1400
-
1401
- async function getReposFromGitHub(github, orgs) {
1402
- return (await pMap(orgs, async (org) => {
1403
- const repos = await github.getOrgRepoList({ org: org.login });
1404
- return pMap(repos, async (repo) => {
1405
- const detailedRepo = await github.getRepository(repo.owner.login, repo.name);
1406
- if (detailedRepo === undefined) {
1407
- throw Error(`Repo not found: ${repo.owner.login}/${repo.name}`);
1408
- }
1409
- return {
1410
- basic: repo,
1411
- repository: detailedRepo,
1412
- teams: await github.getRepositoryTeamsList(detailedRepo),
1413
- };
1414
- });
1415
- })).flat();
1416
- }
1417
- async function getTeams(github, orgs) {
1418
- const intermediate = await pMap(orgs, async (org) => {
1419
- const teams = await github.getTeamList(org);
1420
- return {
1421
- org,
1422
- teams: await pMap(teams, async (team) => ({
1423
- team,
1424
- users: await github.getTeamMemberListIncludingInvited(org, team),
1425
- })),
1426
- };
1427
- });
1428
- // Transform output.
1429
- return intermediate.reduce((prev, cur) => {
1430
- prev[cur.org.login] = cur.teams;
1431
- return prev;
1432
- }, {});
1433
- }
1434
- function getCommonTeams(ownerRepos) {
1435
- return ownerRepos.length === 0
1436
- ? []
1437
- : ownerRepos[0].teams.filter((team) => ownerRepos.every((repo) => repo.teams.some((otherTeam) => otherTeam.name === team.name &&
1438
- otherTeam.permission === team.permission)));
1439
- }
1440
- function getSpecificTeams(teams, commonTeams) {
1441
- return teams.filter((team) => !commonTeams.some((it) => it.name === team.name && it.permission === team.permission));
1442
- }
1443
- function getFormattedTeams(oldTeams, teams) {
1444
- const result = teams.length === 0
1445
- ? undefined
1446
- : teams.map((it) => ({
1447
- name: it.name,
1448
- permission: it.permission,
1449
- }));
1450
- return result
1451
- ? reorderListToSimilarAsBefore(oldTeams ?? [], result, (it) => it.name)
1452
- : undefined;
1453
- }
1454
- async function getOrgs(github, orgs) {
1455
- return pMap(orgs, (it) => github.getOrg(it));
1456
- }
1457
- function removeDuplicates(items, selector) {
1458
- const ids = [];
1459
- const result = [];
1460
- for (const item of items) {
1461
- const id = selector(item);
1462
- if (!ids.includes(id)) {
1463
- result.push(item);
1464
- ids.push(id);
1465
- }
1466
- }
1467
- return result;
1468
- }
1469
- async function getMembers(github, orgs) {
1470
- return removeDuplicates((await pMap(orgs, (org) => github.getOrgMembersListIncludingInvited(org.login)))
1471
- .flat()
1472
- .map((it) => it.login), (it) => it);
1473
- }
1474
- async function getSnykRepos(snyk, definition) {
1475
- return (await snyk.getProjects(definition))
1476
- .map((it) => getGitHubRepo(it))
1477
- .filter((it) => it !== undefined)
1478
- .map((it) => getRepoId(it.owner, it.name));
1479
- }
1480
- async function getProjects(github, orgs, definition, snyk) {
1481
- const snykReposPromise = getSnykRepos(snyk, definition);
1482
- const repos = await getReposFromGitHub(github, orgs);
1483
- const snykRepos = await snykReposPromise;
1484
- const definitionRepos = Object.fromEntries(getRepos(definition).map((repo) => [repo.id, repo]));
1485
- const projectGroups = Object.values(repos.reduce((acc, cur) => {
1486
- const org = cur.repository.owner.login;
1487
- const repoId = getRepoId(org, cur.repository.name);
1488
- const projectName = definitionRepos[repoId]?.project?.name ?? "Unknown";
1489
- const project = acc[projectName] || {
1490
- name: projectName,
1491
- definition: definitionRepos[repoId]?.project,
1492
- repos: [],
1493
- };
1494
- return {
1495
- ...acc,
1496
- [projectName]: {
1497
- ...project,
1498
- repos: {
1499
- ...project.repos,
1500
- [org]: [...(project.repos[org] || []), cur],
1501
- },
1502
- },
1503
- };
1504
- }, {}));
1505
- const projects = projectGroups.map((project) => {
1506
- const github = Object.entries(project.repos).map(([org, list]) => {
1507
- const commonTeams = getCommonTeams(list);
1508
- const oldOrg = project.definition?.github?.find((it) => it.organization == org);
1509
- const repos = list.map((repo) => {
1510
- const repoId = getRepoId(repo.basic.owner.login, repo.basic.name);
1511
- const definitionRepo = definitionRepos[repoId];
1512
- const result = {
1513
- name: repo.basic.name,
1514
- previousNames: definitionRepo?.repo.previousNames,
1515
- archived: repo.repository.archived ? true : undefined,
1516
- issues: repo.repository.has_issues ? undefined : false,
1517
- wiki: repo.repository.has_wiki ? undefined : false,
1518
- teams: getFormattedTeams(definitionRepo?.repo?.teams ?? [], getSpecificTeams(repo.teams, commonTeams)),
1519
- snyk: snykRepos.includes(repoId) ? true : undefined,
1520
- public: repo.repository.private ? undefined : true,
1521
- responsible: definitionRepo?.repo.responsible,
1522
- };
1523
- // Try to preserve property order.
1524
- return Object.fromEntries(reorderListToSimilarAsBefore(definitionRepo ? Object.entries(definitionRepo.repo) : [], Object.entries(result), (it) => it[0], true));
1525
- });
1526
- const teams = getFormattedTeams(oldOrg?.teams ?? [], commonTeams);
1527
- return {
1528
- organization: org,
1529
- teams: teams,
1530
- repos: reorderListToSimilarAsBefore(oldOrg?.repos ?? [], repos, (it) => it.name),
1531
- };
1532
- });
1533
- return {
1534
- name: project.name,
1535
- github: reorderListToSimilarAsBefore(project.definition?.github ?? [], github, (it) => it.organization),
1536
- };
1537
- });
1538
- return reorderListToSimilarAsBefore(definition.projects, projects, (it) => it.name);
1539
- }
1540
- function buildGitHubTeamsList(definition, list) {
1541
- const result = Object.entries(list).map(([org, teams]) => ({
1542
- organization: org,
1543
- teams: teams.map((team) => ({
1544
- name: team.team.name,
1545
- members: team.users
1546
- .map((it) => it.login)
1547
- .sort((a, b) => a.localeCompare(b)),
1548
- })),
1549
- }));
1550
- return reorderListToSimilarAsBefore(definition.github.teams, result, (it) => it.organization);
1551
- }
1552
- function buildGitHubUsersList(definition, members) {
1553
- const result = members.map((memberLogin) => definition.github.users.find((user) => user.login === memberLogin) || {
1554
- type: "external",
1555
- login: memberLogin,
1556
- // TODO: Fetch name from GitHub?
1557
- name: "*Unknown*",
1558
- });
1559
- return reorderListToSimilarAsBefore(definition.github.users, result, (it) => it.login);
1560
- }
1561
- async function dumpSetup(_config, reporter, github, snyk, outfile, definitionFile) {
1562
- reporter.info("Fetching data. This might take some time");
1563
- const definition = await definitionFile.getDefinition();
1564
- const orgs = await getOrgs(github, getGitHubOrgs(definition));
1565
- const teams = getTeams(github, orgs);
1566
- const members = getMembers(github, orgs);
1567
- const projects = getProjects(github, orgs, definition, snyk);
1568
- const generatedDefinition = {
1569
- snyk: definition.snyk,
1570
- github: {
1571
- users: buildGitHubUsersList(definition, await members),
1572
- teams: buildGitHubTeamsList(definition, await teams),
1573
- },
1574
- projects: await projects,
1575
- };
1576
- // TODO: An earlier version we had preserved comments by using yawn-yaml
1577
- // package. However it often produced invalid yaml, so we have removed
1578
- // it. We might want to revisit it to preserve comments.
1579
- const doc = yaml.load(await definitionFile.getContents());
1580
- doc.snyk = generatedDefinition.snyk;
1581
- doc.projects = generatedDefinition.projects;
1582
- doc.github = generatedDefinition.github;
1583
- // Convert to/from plain JSON so that undefined elements are removed.
1584
- fs.writeFileSync(outfile, yaml.dump(JSON.parse(JSON.stringify(doc))));
1585
- reporter.info(`Saved to ${outfile}`);
1586
- reporter.info(`Number of GitHub requests: ${github.requestCount}`);
1587
- }
1588
- const command$h = {
1589
- command: "dump-setup",
1590
- describe: "Dump active setup as YAML. Will be formated same as the definition file.",
1591
- builder: (yargs) => yargs
1592
- .positional("outfile", {
1593
- type: "string",
1594
- })
1595
- .option(definitionFileOptionName, definitionFileOptionValue)
1596
- .demandOption("outfile"),
1597
- handler: async (argv) => {
1598
- const reporter = createReporter(argv);
1599
- const config = createConfig();
1600
- const github = await createGitHubService({
1601
- config,
1602
- cache: createCacheProvider(config, argv),
1603
- });
1604
- const snyk = createSnykService({ config });
1605
- await reportRateLimit(reporter, github, () => dumpSetup(config, reporter, github, snyk, argv.outfile, getDefinitionFile(argv)));
1606
- },
1607
- };
1608
-
1609
- const command$g = {
1610
- command: "validate",
1611
- describe: "Validate definition file.",
1612
- builder: (yargs) => yargs.option(definitionFileOptionName, definitionFileOptionValue),
1613
- handler: async (argv) => {
1614
- const reporter = createReporter(argv);
1615
- await getDefinitionFile(argv).getDefinition();
1616
- reporter.info("Valid!");
1617
- },
1618
- };
1619
-
1620
- const command$f = {
1621
- command: "definition",
1622
- describe: "CALS definition file management",
1623
- builder: (yargs) => yargs
1624
- .command(command$h)
1625
- .command(command$g)
1626
- .demandCommand()
1627
- .usage("cals definition"),
1628
- handler: () => {
1629
- yargs(hideBin(process$2.argv)).showHelp();
1630
- },
1631
- };
1632
-
1633
- const command$e = {
1634
- command: "delete-cache",
1635
- describe: "Delete cached data",
1636
- handler: (argv) => {
1637
- const config = createConfig();
1638
- const cache = createCacheProvider(config, argv);
1639
- const reporter = createReporter(argv);
1640
- cache.cleanup();
1641
- reporter.info("Cache deleted");
1642
- },
1643
- };
1644
-
1645
- const command$d = {
1646
- command: "getting-started",
1647
- describe: "Getting started",
1648
- handler: (argv) => {
1649
- const reporter = createReporter(argv);
1650
- reporter.log("For getting started, see https://liflig.atlassian.net/wiki/x/E8MNAQ");
1651
- },
1652
- };
1653
-
1654
- async function analyzeDirectory(reporter, config, github, org) {
1655
- const repos = await github.getOrgRepoList({ org });
1656
- const reposDict = repos.reduce((acc, cur) => ({ ...acc, [cur.name]: cur }), {});
1657
- const dirs = fs
1658
- .readdirSync(config.cwd)
1659
- .filter((it) => fs.statSync(path.join(config.cwd, it)).isDirectory())
1660
- // Skip hidden folders
1661
- .filter((it) => !it.startsWith("."))
1662
- .sort((a, b) => a.localeCompare(b));
1663
- const stats = {
1664
- unknown: 0,
1665
- archived: 0,
1666
- ok: 0,
1667
- };
1668
- dirs.forEach((it) => {
1669
- if (!(it in reposDict)) {
1670
- reporter.warn(sprintf("%-30s <-- Not found in repository list (maybe changed name?)", it));
1671
- stats.unknown++;
1672
- return;
1673
- }
1674
- if (reposDict[it].isArchived) {
1675
- reporter.info(sprintf("%-30s <-- Archived", it));
1676
- stats.archived++;
1677
- return;
1678
- }
1679
- stats.ok += 1;
1680
- });
1681
- reporter.info(sprintf("Stats: unknown=%d archived=%d ok=%d", stats.unknown, stats.archived, stats.ok));
1682
- reporter.info("Use `cals github generate-clone-commands` to check for repositories not checked out");
1683
- }
1684
- const command$c = {
1685
- command: "analyze-directory",
1686
- describe: "Analyze directory for git repos",
1687
- builder: (yargs) => yargs.options("org", {
1688
- required: true,
1689
- describe: "Specify GitHub organization",
1690
- type: "string",
1691
- }),
1692
- handler: async (argv) => {
1693
- const config = createConfig();
1694
- const github = await createGitHubService({
1695
- config,
1696
- cache: createCacheProvider(config, argv),
1697
- });
1698
- const reporter = createReporter(argv);
1699
- return analyzeDirectory(reporter, config, github, argv.org);
1700
- },
1701
- };
1702
-
1703
- function getChangedRepoAttribs(definitionRepo, actualRepo) {
1704
- const attribs = [];
1705
- const archived = definitionRepo.archived || false;
1706
- if (archived !== actualRepo.archived) {
1707
- attribs.push({
1708
- archived,
1709
- });
1710
- }
1711
- const issues = definitionRepo.issues ?? true;
1712
- if (issues !== actualRepo.has_issues && !actualRepo.archived) {
1713
- attribs.push({
1714
- issues,
1715
- });
1716
- }
1717
- const wiki = definitionRepo.wiki ?? true;
1718
- if (wiki !== actualRepo.has_wiki && !actualRepo.archived) {
1719
- attribs.push({
1720
- wiki,
1721
- });
1722
- }
1723
- const isPrivate = definitionRepo.public !== true;
1724
- if (isPrivate !== actualRepo.private) {
1725
- attribs.push({
1726
- private: isPrivate,
1727
- });
1728
- }
1729
- return attribs;
1730
- }
1731
- /**
1732
- * Get teams from both the project level and the repository level
1733
- * and ensure that repository level override project level.
1734
- */
1735
- function getExpectedTeams(projectTeams, repoTeams) {
1736
- return [
1737
- ...repoTeams,
1738
- ...projectTeams.filter((it) => !repoTeams.find((repoTeam) => repoTeam.name === it.name)),
1739
- ];
1740
- }
1741
- async function getRepoTeamChanges({ github, org, projectRepo, repo, }) {
1742
- const changes = [];
1743
- const expectedTeams = getExpectedTeams(org.teams ?? [], projectRepo.teams ?? []);
1744
- const existingTeams = await github.getRepositoryTeamsList(repo);
1745
- // Check for teams to be added / modified.
1746
- for (const repoteam of expectedTeams) {
1747
- const found = existingTeams.find((it) => repoteam.name === it.name);
1748
- if (found !== undefined) {
1749
- if (found.permission !== repoteam.permission) {
1750
- changes.push({
1751
- type: "repo-team-permission",
1752
- org: org.organization,
1753
- repo: repo.name,
1754
- team: found.name,
1755
- permission: repoteam.permission,
1756
- current: {
1757
- permission: found.permission,
1758
- },
1759
- });
1760
- }
1761
- }
1762
- else {
1763
- changes.push({
1764
- type: "repo-team-add",
1765
- org: org.organization,
1766
- repo: repo.name,
1767
- team: repoteam.name,
1768
- permission: repoteam.permission,
1769
- });
1770
- }
1771
- }
1772
- // Check for teams that should not be registered.
1773
- for (const team of existingTeams) {
1774
- if (!expectedTeams.some((it) => team.name === it.name)) {
1775
- changes.push({
1776
- type: "repo-team-remove",
1777
- org: org.organization,
1778
- repo: repo.name,
1779
- team: team.name,
1780
- });
1781
- }
1782
- }
1783
- return changes;
1784
- }
1785
- async function getProjectRepoChanges({ github, org, projectRepo, }) {
1786
- const changes = [];
1787
- const repo = await github.getRepository(org.organization, projectRepo.name);
1788
- if (repo === undefined) {
1789
- changes.push({
1790
- type: "repo-create",
1791
- org: org.organization,
1792
- repo: projectRepo.name,
1793
- });
1794
- return changes;
1795
- }
1796
- const attribs = getChangedRepoAttribs(projectRepo, repo);
1797
- if (attribs.length > 0) {
1798
- changes.push({
1799
- type: "repo-update",
1800
- org: org.organization,
1801
- repo: repo.name,
1802
- attribs,
1803
- });
1804
- }
1805
- changes.push(...(await getRepoTeamChanges({
1806
- github,
1807
- org,
1808
- projectRepo,
1809
- repo,
1810
- })));
1811
- return changes;
1812
- }
1813
- /**
1814
- * Generate change set items for projects.
1815
- */
1816
- async function createChangeSetItemsForProjects(github, definition, limitToOrg) {
1817
- const changes = [];
1818
- const orgs = definition.projects
1819
- .flatMap((it) => it.github)
1820
- .filter((org) => limitToOrg === undefined || limitToOrg === org.organization);
1821
- changes.push(...(await pMap(orgs, async (org) => pMap(org.repos || [], (projectRepo) => getProjectRepoChanges({
1822
- github,
1823
- org,
1824
- projectRepo,
1825
- }))))
1826
- .flat()
1827
- .flat());
1828
- return changes;
1829
- }
1830
- /**
1831
- * Get user list based on team memberships in an organization.
1832
- */
1833
- function getUsersForOrg(definition, org) {
1834
- const teams = definition.github.teams.find((it) => it.organization == org);
1835
- if (teams === undefined)
1836
- return [];
1837
- const memberLogins = new Set(teams.teams.flatMap((it) => it.members));
1838
- return definition.github.users.filter((user) => memberLogins.has(user.login));
1839
- }
1840
- /**
1841
- * Generate change set items for organization members.
1842
- */
1843
- async function createChangeSetItemsForMembers(github, definition, org) {
1844
- const changes = [];
1845
- const users = getUsersForOrg(definition, org.login);
1846
- const usersLogins = users.map((it) => it.login);
1847
- const foundLogins = [];
1848
- const members = await github.getOrgMembersListIncludingInvited(org.login);
1849
- members.forEach((user) => {
1850
- if (usersLogins.includes(user.login)) {
1851
- foundLogins.push(user.login);
231
+ function getGroup(repo) {
232
+ const projectTopics = [];
233
+ let isInfra = false;
234
+ repo.repositoryTopics.edges.forEach((edge) => {
235
+ const name = edge.node.topic.name;
236
+ if (name.startsWith("customer-")) {
237
+ projectTopics.push(name.substring(9));
1852
238
  }
1853
- else {
1854
- changes.push({
1855
- type: "member-remove",
1856
- org: org.login,
1857
- user: user.login,
1858
- });
239
+ if (name.startsWith("project-")) {
240
+ projectTopics.push(name.substring(8));
241
+ }
242
+ if (name === "infrastructure") {
243
+ isInfra = true;
1859
244
  }
1860
245
  });
1861
- for (const user of users.filter((it) => !foundLogins.includes(it.login))) {
1862
- changes.push({
1863
- type: "member-add",
1864
- org: org.login,
1865
- user: user.login,
1866
- });
246
+ if (projectTopics.length > 1) {
247
+ console.warn(`Repo ${repo.name} has multiple project groups: ${projectTopics.join(", ")}. Picking first`);
248
+ }
249
+ if (projectTopics.length > 0) {
250
+ return projectTopics[0];
251
+ }
252
+ if (isInfra) {
253
+ return "infrastructure";
1867
254
  }
1868
- return changes;
255
+ return null;
1869
256
  }
1870
- /**
1871
- * Generate change set items for organization teams.
1872
- */
1873
- async function createChangeSetItemsForTeams(github, definition, org) {
1874
- const changes = [];
1875
- const teams = (definition.github.teams.find((it) => it.organization === org.login) || {
1876
- teams: [],
1877
- }).teams;
1878
- const actualTeams = await github.getTeamList(org);
1879
- const actualTeamNames = actualTeams.map((it) => it.name);
1880
- const wantedTeamNames = teams.map((it) => it.name);
1881
- actualTeams
1882
- .filter((it) => !wantedTeamNames.includes(it.name))
1883
- .forEach((it) => {
1884
- changes.push({
1885
- type: "team-remove",
1886
- org: org.login,
1887
- team: it.name,
1888
- });
1889
- });
1890
- teams
1891
- .filter((it) => !actualTeamNames.includes(it.name))
1892
- .forEach((team) => {
1893
- changes.push({
1894
- type: "team-add",
1895
- org: org.login,
1896
- team: team.name,
1897
- });
1898
- // Must add all members when creating new team.
1899
- for (const member of team.members) {
1900
- changes.push({
1901
- type: "team-member-add",
1902
- org: org.login,
1903
- team: team.name,
1904
- user: member,
1905
- // TODO: Allow to specify maintainers?
1906
- role: "member",
1907
- });
1908
- }
1909
- });
1910
- const overlappingTeams = actualTeams.filter((it) => wantedTeamNames.includes(it.name));
1911
- await pMap(overlappingTeams, async (actualTeam) => {
1912
- const wantedTeam = teams.find((it) => it.name === actualTeam.name);
1913
- const actualMembers = await github.getTeamMemberListIncludingInvited(org, actualTeam);
1914
- actualMembers
1915
- .filter((it) => !wantedTeam.members.includes(it.login))
1916
- .forEach((it) => {
1917
- changes.push({
1918
- type: "team-member-remove",
1919
- org: org.login,
1920
- team: actualTeam.name,
1921
- user: it.login,
1922
- });
1923
- });
1924
- const actualMembersNames = actualMembers.map((it) => it.login);
1925
- wantedTeam.members
1926
- .filter((it) => !actualMembersNames.includes(it))
1927
- .forEach((it) => {
1928
- changes.push({
1929
- type: "team-member-add",
1930
- org: org.login,
1931
- team: actualTeam.name,
1932
- user: it,
1933
- // TODO: Allow to specify maintainers?
1934
- role: "member",
1935
- });
1936
- });
1937
- // TODO: team-member-permission (member/maintainer)
1938
- });
1939
- return changes;
257
+ function ifnull(a, other) {
258
+ return a === null ? other : a;
1940
259
  }
1941
- /**
1942
- * Remove redundant change set items due to effects by other
1943
- * change set items.
1944
- */
1945
- function cleanupChangeSetItems(items) {
1946
- const hasTeamRemove = ({ org, team }) => items.some((it) => it.type === "team-remove" && it.org === org && it.team === team);
1947
- const hasMemberRemove = ({ org }) => items.some((it) => it.type === "member-remove" && it.org === org);
1948
- return items.filter((item) => !((item.type === "team-member-remove" && hasTeamRemove(item)) ||
1949
- (item.type === "repo-team-remove" && hasTeamRemove(item)) ||
1950
- (item.type === "team-member-remove" && hasMemberRemove(item))));
260
+ function getGroupedRepos(repos) {
261
+ return Object.values(repos.reduce((acc, repo) => {
262
+ const group = ifnull(getGroup(repo), "(unknown)");
263
+ const value = acc[group] || { name: group, items: [] };
264
+ return {
265
+ ...acc,
266
+ [group]: {
267
+ ...value,
268
+ items: [...value.items, repo],
269
+ },
270
+ };
271
+ }, {})).sort((a, b) => a.name.localeCompare(b.name));
272
+ }
273
+ function includesTopic(repo, topic) {
274
+ return repo.repositoryTopics.edges.some((it) => it.node.topic.name === topic);
1951
275
  }
1952
276
 
1953
- function buildLookup(github) {
1954
- // We operate using the Octokit SDK, so cache the objects to avoid
1955
- // excessive lookups to the API for them.
1956
- const orgCache = {};
1957
- const orgTeamListCache = {};
1958
- async function getOrg(orgName) {
1959
- if (!(orgName in orgCache)) {
1960
- orgCache[orgName] = await github.getOrg(orgName);
1961
- }
1962
- return orgCache[orgName];
277
+ class CacheProvider {
278
+ constructor(config) {
279
+ this.config = config;
1963
280
  }
1964
- async function getOrgTeamList(orgName) {
1965
- if (!(orgName in orgTeamListCache)) {
1966
- const org = await getOrg(orgName);
1967
- orgTeamListCache[orgName] = await github.getTeamList(org);
281
+ mustValidate = false;
282
+ config;
283
+ defaultCacheTime = 1800;
284
+ /**
285
+ * Retrieve cache if existent, ignoring the time.
286
+ *
287
+ * The caller is responsible for handling proper validation,
288
+ */
289
+ retrieveJson(cachekey) {
290
+ const cachefile = path.join(this.config.cacheDir, `${cachekey}.json`);
291
+ if (!fs.existsSync(cachefile)) {
292
+ return undefined;
1968
293
  }
1969
- return orgTeamListCache[orgName];
294
+ const data = fs.readFileSync(cachefile, "utf-8");
295
+ return {
296
+ cacheTime: fs.statSync(cachefile).mtime.getTime(),
297
+ data: (data === "undefined" ? undefined : JSON.parse(data)),
298
+ };
1970
299
  }
1971
- async function getOrgTeam(orgName, teamName) {
1972
- const teams = await getOrgTeamList(orgName);
1973
- const team = teams.find((it) => it.name === teamName);
1974
- if (team === undefined) {
1975
- throw new Error(`Team ${orgName}/${teamName} not found`);
300
+ /**
301
+ * Save data to cache.
302
+ */
303
+ storeJson(cachekey, data) {
304
+ const cachefile = path.join(this.config.cacheDir, `${cachekey}.json`);
305
+ if (!fs.existsSync(this.config.cacheDir)) {
306
+ fs.mkdirSync(this.config.cacheDir, { recursive: true });
1976
307
  }
1977
- return team;
308
+ fs.writeFileSync(cachefile, data === undefined ? "undefined" : JSON.stringify(data));
1978
309
  }
1979
- return {
1980
- getOrgTeam,
1981
- };
1982
- }
1983
- const notImplementedChangeSetItems = [
1984
- "repo-create",
1985
- ];
1986
- function isNotImplementedChangeSetItem(changeItem) {
1987
- return notImplementedChangeSetItems.includes(changeItem.type);
1988
- }
1989
- /**
1990
- * Execute a change set item.
1991
- */
1992
- async function executeChangeSetItem(github, changeItem, reporter, lookup) {
1993
- // We return to ensure all code paths are followed during compiling.
1994
- // If a change item type is missing we will get a compile error.
1995
- if (isNotImplementedChangeSetItem(changeItem)) {
1996
- reporter.warn("Not currently implemented - do it manually");
1997
- return true;
1998
- }
1999
- switch (changeItem.type) {
2000
- case "member-remove":
2001
- await github.octokit.orgs.removeMembershipForUser({
2002
- org: changeItem.org,
2003
- username: changeItem.user,
2004
- });
2005
- return true;
2006
- case "member-add":
2007
- await github.octokit.orgs.setMembershipForUser({
2008
- org: changeItem.org,
2009
- username: changeItem.user,
2010
- role: "member",
2011
- });
2012
- return true;
2013
- case "team-remove":
2014
- await github.octokit.teams.deleteInOrg({
2015
- org: changeItem.org,
2016
- team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team))
2017
- .slug,
2018
- });
2019
- return true;
2020
- case "team-add":
2021
- await github.octokit.teams.create({
2022
- org: changeItem.org,
2023
- name: changeItem.team,
2024
- privacy: "closed",
2025
- });
2026
- return true;
2027
- case "team-member-permission":
2028
- await github.octokit.teams.addOrUpdateMembershipForUserInOrg({
2029
- org: changeItem.org,
2030
- team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team))
2031
- .slug,
2032
- username: changeItem.user,
2033
- role: changeItem.role,
2034
- });
2035
- return true;
2036
- case "team-member-remove":
2037
- await github.octokit.teams.removeMembershipForUserInOrg({
2038
- org: changeItem.org,
2039
- team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team))
2040
- .slug,
2041
- username: changeItem.user,
2042
- });
2043
- return true;
2044
- case "team-member-add":
2045
- await github.octokit.teams.addOrUpdateMembershipForUserInOrg({
2046
- org: changeItem.org,
2047
- team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team))
2048
- .slug,
2049
- username: changeItem.user,
2050
- });
2051
- return true;
2052
- case "repo-update": {
2053
- const upd = {
2054
- owner: changeItem.org,
2055
- repo: changeItem.repo,
2056
- };
2057
- for (const attrib of changeItem.attribs) {
2058
- if ("archived" in attrib) {
2059
- upd.archived = attrib.archived;
2060
- }
2061
- else if ("issues" in attrib) {
2062
- upd.has_issues = attrib.issues;
2063
- }
2064
- else if ("wiki" in attrib) {
2065
- upd.has_wiki = attrib.wiki;
2066
- }
2067
- else if ("private" in attrib) {
2068
- upd.private = attrib.private;
2069
- }
2070
- }
2071
- await github.octokit.repos.update(upd);
2072
- return true;
310
+ async json(cachekey, block, cachetime = this.defaultCacheTime) {
311
+ const cacheItem = this.mustValidate
312
+ ? undefined
313
+ : this.retrieveJson(cachekey);
314
+ const expire = new Date(Date.now() - cachetime * 1000).getTime();
315
+ if (cacheItem !== undefined && cacheItem.cacheTime > expire) {
316
+ return cacheItem.data;
2073
317
  }
2074
- case "repo-team-remove":
2075
- await github.octokit.teams.removeRepoInOrg({
2076
- org: changeItem.org,
2077
- owner: changeItem.org,
2078
- repo: changeItem.repo,
2079
- team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team))
2080
- .slug,
2081
- });
2082
- return true;
2083
- case "repo-team-add":
2084
- case "repo-team-permission":
2085
- await github.octokit.teams.addOrUpdateRepoPermissionsInOrg({
2086
- org: changeItem.org,
2087
- owner: changeItem.org,
2088
- repo: changeItem.repo,
2089
- team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team))
2090
- .slug,
2091
- permission: changeItem.permission,
2092
- });
2093
- return true;
318
+ const result = await block();
319
+ this.storeJson(cachekey, result);
320
+ return result;
2094
321
  }
2095
- }
2096
- /**
2097
- * Execute a change set.
2098
- */
2099
- async function executeChangeSet(github, changes, reporter) {
2100
- const lookup = buildLookup(github);
2101
- for (const changeItem of changes) {
2102
- reporter.info(`Executing ${JSON.stringify(changeItem)}`);
2103
- await executeChangeSetItem(github, changeItem, reporter, lookup);
322
+ /**
323
+ * Delete all cached data.
324
+ */
325
+ cleanup() {
326
+ fs.rmSync(this.config.cacheDir, { recursive: true, force: true });
2104
327
  }
2105
328
  }
2106
329
 
2107
- function createOrgGetter(github) {
2108
- const orgs = {};
2109
- // Use a semaphore for each orgName to restrict multiple
2110
- // concurrent requests of the same org.
2111
- const semaphores = {};
2112
- function getSemaphore(orgName) {
2113
- if (!(orgName in semaphores)) {
2114
- semaphores[orgName] = pLimit(1);
330
+ class Config {
331
+ cwd = path.resolve(process__default.cwd());
332
+ configFile = path.join(os.homedir(), ".cals-config.json");
333
+ cacheDir = cachedir("cals-cli");
334
+ configCached = undefined;
335
+ get config() {
336
+ const existingConfig = this.configCached;
337
+ if (existingConfig !== undefined) {
338
+ return existingConfig;
2115
339
  }
2116
- return semaphores[orgName];
2117
- }
2118
- return async (orgName) => await getSemaphore(orgName)(async () => {
2119
- if (!(orgName in orgs)) {
2120
- const org = await github.getOrg(orgName);
2121
- orgs[orgName] = {
2122
- org,
2123
- teams: await github.getTeamList(org),
2124
- };
340
+ const config = this.readConfig();
341
+ this.configCached = config;
342
+ return config;
343
+ }
344
+ readConfig() {
345
+ if (!fs.existsSync(this.configFile)) {
346
+ return {};
2125
347
  }
2126
- return orgs[orgName];
2127
- });
2128
- }
2129
- async function process(reporter, github, definition, getOrg, execute, limitToOrg) {
2130
- let changes = [];
2131
- for (const orgName of getGitHubOrgs(definition)) {
2132
- if (limitToOrg !== undefined && limitToOrg !== orgName) {
2133
- continue;
348
+ try {
349
+ return JSON.parse(fs.readFileSync(this.configFile, "utf-8"));
2134
350
  }
2135
- const org = (await getOrg(orgName)).org;
2136
- changes = [
2137
- ...changes,
2138
- ...(await createChangeSetItemsForMembers(github, definition, org)),
2139
- ];
2140
- changes = [
2141
- ...changes,
2142
- ...(await createChangeSetItemsForTeams(github, definition, org)),
2143
- ];
2144
- }
2145
- changes = [
2146
- ...changes,
2147
- ...(await createChangeSetItemsForProjects(github, definition, limitToOrg)),
2148
- ];
2149
- changes = cleanupChangeSetItems(changes);
2150
- const ignored = changes.filter(isNotImplementedChangeSetItem);
2151
- changes = changes.filter((it) => !ignored.includes(it));
2152
- if (ignored.length > 0) {
2153
- reporter.info("Not implemented:");
2154
- for (const change of ignored) {
2155
- reporter.info(` - ${JSON.stringify(change)}`);
351
+ catch (e) {
352
+ console.error("Failed", e);
353
+ throw new Error("Failed to read config");
2156
354
  }
2157
355
  }
2158
- if (changes.length === 0) {
2159
- reporter.info("No actions to be performed");
356
+ getConfig(key) {
357
+ return this.config[key];
2160
358
  }
2161
- else {
2162
- reporter.info("To be performed:");
2163
- for (const change of changes) {
2164
- reporter.info(` - ${JSON.stringify(change)}`);
359
+ requireConfig(key) {
360
+ const result = this.config[key];
361
+ if (result === undefined) {
362
+ throw Error(`Configuration for ${key} missing. Add manually to ${this.configFile}`);
2165
363
  }
364
+ return result;
2166
365
  }
2167
- if (execute && changes.length > 0) {
2168
- const answer = await read({
2169
- prompt: "Confirm you want to execute the changes [y/N]: ",
2170
- timeout: 60000,
2171
- });
2172
- if (answer.toLowerCase() === "y") {
2173
- reporter.info("Executing changes");
2174
- await executeChangeSet(github, changes, reporter);
2175
- }
2176
- else {
2177
- reporter.info("Skipping");
2178
- }
366
+ updateConfig(key, value) {
367
+ const updatedConfig = {
368
+ ...this.readConfig(),
369
+ [key]: value, // undefined will remove
370
+ };
371
+ fs.writeFileSync(this.configFile, JSON.stringify(updatedConfig, null, " "));
372
+ this.configCached = updatedConfig;
2179
373
  }
2180
- reporter.info(`Number of GitHub requests: ${github.requestCount}`);
2181
374
  }
2182
- const command$b = {
2183
- command: "configure",
2184
- describe: "Configure CALS GitHub resources",
2185
- builder: (yargs) => yargs
2186
- .options("execute", {
2187
- describe: "Execute the detected changes",
2188
- type: "boolean",
2189
- })
2190
- .options("org", {
2191
- describe: "Filter resources by GitHub organization",
2192
- type: "string",
2193
- })
2194
- .option(definitionFileOptionName, definitionFileOptionValue),
2195
- handler: async (argv) => {
2196
- const reporter = createReporter(argv);
2197
- const config = createConfig();
2198
- const github = await createGitHubService({
2199
- config,
2200
- cache: createCacheProvider(config, argv),
2201
- });
2202
- const definition = await getDefinitionFile(argv).getDefinition();
2203
- await reportRateLimit(reporter, github, async () => {
2204
- const orgGetter = createOrgGetter(github);
2205
- await process(reporter, github, definition, orgGetter, !!argv.execute, argv.org);
375
+
376
+ const CLEAR_WHOLE_LINE = 0;
377
+ function clearLine(stdout) {
378
+ readline.clearLine(stdout, CLEAR_WHOLE_LINE);
379
+ readline.cursorTo(stdout, 0);
380
+ }
381
+ async function readInput(options) {
382
+ const rl = readline.createInterface({
383
+ input: process__default.stdin,
384
+ output: process__default.stdout,
385
+ });
386
+ if (options.silent) {
387
+ rl._writeToOutput = () => { };
388
+ }
389
+ return new Promise((resolve, reject) => {
390
+ const timer = options.timeout
391
+ ? setTimeout(() => {
392
+ rl.close();
393
+ reject(new Error("Input timed out"));
394
+ }, options.timeout)
395
+ : null;
396
+ rl.question(options.prompt, (answer) => {
397
+ if (timer)
398
+ clearTimeout(timer);
399
+ rl.close();
400
+ if (options.silent) {
401
+ process__default.stdout.write("\n");
402
+ }
403
+ resolve(answer);
2206
404
  });
2207
- },
2208
- };
405
+ });
406
+ }
407
+ class Reporter {
408
+ stdout = process__default.stdout;
409
+ stderr = process__default.stderr;
410
+ format = chalk;
411
+ error(msg) {
412
+ clearLine(this.stderr);
413
+ this.stderr.write(`${this.format.red("error")} ${msg}\n`);
414
+ }
415
+ log(msg) {
416
+ clearLine(this.stdout);
417
+ this.stdout.write(`${msg}\n`);
418
+ }
419
+ warn(msg) {
420
+ clearLine(this.stderr);
421
+ this.stderr.write(`${this.format.yellow("warning")} ${msg}\n`);
422
+ }
423
+ info(msg) {
424
+ clearLine(this.stdout);
425
+ this.stdout.write(`${this.format.blue("info")} ${msg}\n`);
426
+ }
427
+ }
428
+
429
+ function createReporter() {
430
+ return new Reporter();
431
+ }
432
+ function createCacheProvider(config, argv) {
433
+ const cache = new CacheProvider(config);
434
+ if (argv.validateCache === true) {
435
+ cache.mustValidate = true;
436
+ }
437
+ return cache;
438
+ }
439
+ function createConfig() {
440
+ return new Config();
441
+ }
2209
442
 
2210
443
  async function generateCloneCommands({ reporter, config, github, org, ...opt }) {
2211
444
  if (!opt.listGroups && !opt.all && opt.group === undefined) {
2212
- yargs(hideBin(process$2.argv)).showHelp();
445
+ yargs(hideBin(process__default.argv)).showHelp();
2213
446
  return;
2214
447
  }
2215
448
  const repos = await github.getOrgRepoList({ org });
@@ -2233,11 +466,11 @@ async function generateCloneCommands({ reporter, config, github, org, ...opt })
2233
466
  .forEach((repo) => {
2234
467
  // The output of this is used to pipe into e.g. bash.
2235
468
  // We cannot use reporter.log as it adds additional characters.
2236
- process$2.stdout.write(sprintf('[ ! -e "%s" ] && git clone %s\n', repo.name, repo.sshUrl));
469
+ process__default.stdout.write(`[ ! -e "${repo.name}" ] && git clone ${repo.sshUrl}\n`);
2237
470
  });
2238
471
  });
2239
472
  }
2240
- const command$a = {
473
+ const command$4 = {
2241
474
  command: "generate-clone-commands",
2242
475
  describe: "Generate shell commands to clone GitHub repos for an organization",
2243
476
  builder: (yargs) => yargs
@@ -2280,75 +513,19 @@ const command$a = {
2280
513
  handler: async (argv) => {
2281
514
  const config = createConfig();
2282
515
  return generateCloneCommands({
2283
- reporter: createReporter(argv),
2284
- config,
2285
- github: await createGitHubService({
2286
- config,
2287
- cache: createCacheProvider(config, argv),
2288
- }),
2289
- all: !!argv.all,
2290
- listGroups: !!argv["list-groups"],
2291
- includeArchived: !!argv["include-archived"],
2292
- name: argv.name,
2293
- topic: argv.topic,
2294
- excludeExisting: !!argv["exclude-existing"],
2295
- group: argv.group,
2296
- org: argv.org,
2297
- });
2298
- },
2299
- };
2300
-
2301
- async function listPullRequestsStats({ reporter, github, }) {
2302
- // This is only an initial attempt to get some insights into
2303
- // open pull requests. Feel free to change.
2304
- const pulls = await github.getSearchedPullRequestList("capralifecycle");
2305
- const cutoffOld = new Date(Date.now() - 86400 * 1000 * 60);
2306
- const categories = pulls
2307
- .reduce((acc, cur) => {
2308
- const key = `${cur.baseRepository.owner.login}/${cur.baseRepository.name}`;
2309
- const old = new Date(cur.createdAt) < cutoffOld;
2310
- const snyk = cur.title.includes("[Snyk]");
2311
- // Cheat by mutating.
2312
- let t = acc.find((it) => it.key === key);
2313
- if (t === undefined) {
2314
- t = {
2315
- key,
2316
- old: [],
2317
- oldSnyk: [],
2318
- recent: [],
2319
- recentSnyk: [],
2320
- };
2321
- acc.push(t);
2322
- }
2323
- t[snyk ? (old ? "oldSnyk" : "recentSnyk") : old ? "old" : "recent"].push(cur);
2324
- return acc;
2325
- }, [])
2326
- .sort((a, b) => a.key.localeCompare(b.key));
2327
- if (categories.length === 0) {
2328
- reporter.log("No pull requests found");
2329
- }
2330
- else {
2331
- reporter.log("Pull requests stats:");
2332
- reporter.log("A pull request is considered old after 60 days");
2333
- reporter.log("");
2334
- reporter.log(sprintf("%-40s %12s %2s %12s %2s", "", "normal", "", "snyk", ""));
2335
- reporter.log(sprintf("%-40s %7s %7s %7s %7s", "Repo", "old", "recent", "old", "recent"));
2336
- categories.forEach((cat) => {
2337
- reporter.log(sprintf("%-40s %7s %7s %7s %7s", cat.key, cat.old.length === 0 ? "" : cat.old.length, cat.recent.length === 0 ? "" : cat.recent.length, cat.oldSnyk.length === 0 ? "" : cat.oldSnyk.length, cat.recentSnyk.length === 0 ? "" : cat.recentSnyk.length));
2338
- });
2339
- }
2340
- }
2341
- const command$9 = {
2342
- command: "list-pull-requests-stats",
2343
- describe: "List stats for pull requests with special filter",
2344
- handler: async (argv) => {
2345
- const config = createConfig();
2346
- await listPullRequestsStats({
2347
- reporter: createReporter(argv),
516
+ reporter: createReporter(),
517
+ config,
2348
518
  github: await createGitHubService({
2349
- config,
2350
519
  cache: createCacheProvider(config, argv),
2351
520
  }),
521
+ all: !!argv.all,
522
+ listGroups: !!argv["list-groups"],
523
+ includeArchived: !!argv["include-archived"],
524
+ name: argv.name,
525
+ topic: argv.topic,
526
+ excludeExisting: !!argv["exclude-existing"],
527
+ group: argv.group,
528
+ org: argv.org,
2352
529
  });
2353
530
  },
2354
531
  };
@@ -2377,7 +554,7 @@ async function listRepos({ reporter, github, includeArchived, name = undefined,
2377
554
  }
2378
555
  // All CSV output is done using direct stdout to avoid extra chars.
2379
556
  if (csv) {
2380
- process$2.stdout.write("reponame,group\n");
557
+ process__default.stdout.write("reponame,group\n");
2381
558
  }
2382
559
  getGroupedRepos(repos).forEach((group) => {
2383
560
  if (!csv && compact) {
@@ -2390,7 +567,7 @@ async function listRepos({ reporter, github, includeArchived, name = undefined,
2390
567
  group.items.forEach((repo) => {
2391
568
  if (csv) {
2392
569
  // We assume we have no repos or group names with a comma in its name.
2393
- process$2.stdout.write(`${repo.name},${group.name}\n`);
570
+ process__default.stdout.write(`${repo.name},${group.name}\n`);
2394
571
  return;
2395
572
  }
2396
573
  if (compact) {
@@ -2435,7 +612,7 @@ async function listRepos({ reporter, github, includeArchived, name = undefined,
2435
612
  });
2436
613
  }
2437
614
  }
2438
- const command$8 = {
615
+ const command$3 = {
2439
616
  command: "list-repos",
2440
617
  describe: "List Git repos for a GitHub organization",
2441
618
  builder: (yargs) => yargs
@@ -2470,9 +647,8 @@ const command$8 = {
2470
647
  handler: async (argv) => {
2471
648
  const config = createConfig();
2472
649
  await listRepos({
2473
- reporter: createReporter(argv),
650
+ reporter: createReporter(),
2474
651
  github: await createGitHubService({
2475
- config,
2476
652
  cache: createCacheProvider(config, argv),
2477
653
  }),
2478
654
  includeArchived: !!argv["include-archived"],
@@ -2485,57 +661,11 @@ const command$8 = {
2485
661
  },
2486
662
  };
2487
663
 
2488
- const e = encodeURIComponent;
2489
- async function listWebhooks(reporter, _cache, github, org) {
2490
- const repos = (await github.getOrgRepoList({ org })).filter((it) => !it.isArchived);
2491
- for (const repo of repos) {
2492
- reporter.log("");
2493
- reporter.log(`${repo.name}: https://github.com/capralifecycle/${e(repo.name)}/settings/hooks`);
2494
- const hooks = await github.getRepositoryHooks(repo.owner.login, repo.name);
2495
- for (const hook of hooks) {
2496
- if (hook.config.url === undefined ||
2497
- !hook.config.url.includes("jenkins")) {
2498
- continue;
2499
- }
2500
- switch (hook.name) {
2501
- case "web":
2502
- reporter.log(sprintf(" web: %s (%s) (%s)", hook.config.url, hook.last_response.code, hook.events.join(", ")));
2503
- break;
2504
- case "jenkinsgit":
2505
- reporter.log(sprintf(" jenkinsgit: %s (%s) (%s)",
2506
- // This is undocumented.
2507
- hook.config.jenkins_url, hook.last_response.code, hook.events.join(", ")));
2508
- break;
2509
- case "docker":
2510
- reporter.log(sprintf(" docker (%s) (%s)", hook.last_response.code, hook.events.join(", ")));
2511
- break;
2512
- default:
2513
- reporter.log(` ${hook.name}: <unknown type>`);
2514
- reporter.log(JSON.stringify(hook));
2515
- }
2516
- }
2517
- }
2518
- }
2519
- const command$7 = {
2520
- command: "list-webhooks",
2521
- describe: "List webhooks for repositories in for a GitHub organization",
2522
- builder: (yargs) => yargs.options("org", {
2523
- required: true,
2524
- describe: "Specify GitHub organization",
2525
- type: "string",
2526
- }),
2527
- handler: async (argv) => {
2528
- const config = createConfig();
2529
- const cacheProvider = createCacheProvider(config, argv);
2530
- await listWebhooks(createReporter(argv), cacheProvider, await createGitHubService({ config, cache: cacheProvider }), argv.org);
2531
- },
2532
- };
2533
-
2534
- async function setToken$1({ reporter, token, tokenProvider, }) {
664
+ async function setToken({ reporter, token, tokenProvider, }) {
2535
665
  if (token === undefined) {
2536
666
  reporter.info("Need API token to talk to GitHub");
2537
667
  reporter.info("https://github.com/settings/tokens/new?scopes=repo:status,read:repo_hook");
2538
- const inputToken = await read({
668
+ const inputToken = await readInput({
2539
669
  prompt: "Enter new GitHub API token: ",
2540
670
  silent: true,
2541
671
  });
@@ -2544,21 +674,179 @@ async function setToken$1({ reporter, token, tokenProvider, }) {
2544
674
  await tokenProvider.setToken(token);
2545
675
  reporter.info("Token saved");
2546
676
  }
2547
- const command$6 = {
677
+ const command$2 = {
2548
678
  command: "set-token",
2549
679
  describe: "Set GitHub token for API calls",
2550
680
  builder: (yargs) => yargs.positional("token", {
2551
681
  describe: "Token. If not provided it will be requested as input. Can be generated at https://github.com/settings/tokens/new?scopes=repo:status,read:repo_hook",
2552
682
  }),
2553
683
  handler: async (argv) => {
2554
- await setToken$1({
2555
- reporter: createReporter(argv),
684
+ await setToken({
685
+ reporter: createReporter(),
2556
686
  token: argv.token,
2557
687
  tokenProvider: new GitHubTokenCliProvider(),
2558
688
  });
2559
689
  },
2560
690
  };
2561
691
 
692
+ var type = "object";
693
+ var properties = {
694
+ projects: {
695
+ type: "array",
696
+ items: {
697
+ $ref: "#/definitions/Project"
698
+ }
699
+ }
700
+ };
701
+ var required = [
702
+ "projects"
703
+ ];
704
+ var definitions = {
705
+ Project: {
706
+ type: "object",
707
+ properties: {
708
+ name: {
709
+ type: "string"
710
+ },
711
+ github: {
712
+ type: "array",
713
+ items: {
714
+ type: "object",
715
+ properties: {
716
+ organization: {
717
+ type: "string"
718
+ },
719
+ repos: {
720
+ type: "array",
721
+ items: {
722
+ $ref: "#/definitions/DefinitionRepo"
723
+ }
724
+ }
725
+ },
726
+ required: [
727
+ "organization"
728
+ ]
729
+ }
730
+ },
731
+ tags: {
732
+ type: "array",
733
+ items: {
734
+ type: "string"
735
+ }
736
+ }
737
+ },
738
+ required: [
739
+ "github",
740
+ "name"
741
+ ]
742
+ },
743
+ DefinitionRepo: {
744
+ type: "object",
745
+ properties: {
746
+ name: {
747
+ type: "string"
748
+ },
749
+ previousNames: {
750
+ type: "array",
751
+ items: {
752
+ $ref: "#/definitions/DefinitionRepoPreviousName"
753
+ }
754
+ },
755
+ archived: {
756
+ type: "boolean"
757
+ }
758
+ },
759
+ required: [
760
+ "name"
761
+ ]
762
+ },
763
+ DefinitionRepoPreviousName: {
764
+ type: "object",
765
+ properties: {
766
+ name: {
767
+ type: "string"
768
+ },
769
+ project: {
770
+ type: "string"
771
+ }
772
+ },
773
+ required: [
774
+ "name",
775
+ "project"
776
+ ]
777
+ }
778
+ };
779
+ var $schema = "http://json-schema.org/draft-07/schema#";
780
+ var schema = {
781
+ type: type,
782
+ properties: properties,
783
+ required: required,
784
+ definitions: definitions,
785
+ $schema: $schema
786
+ };
787
+
788
+ function getRepoId(orgName, repoName) {
789
+ return `${orgName}/${repoName}`;
790
+ }
791
+ function checkAgainstSchema(value) {
792
+ const ajv = new AJV({ allErrors: true });
793
+ const valid = ajv.validate(schema, value);
794
+ return valid
795
+ ? { definition: value }
796
+ : { error: ajv.errorsText() ?? "Unknown error" };
797
+ }
798
+ function requireValidDefinition(definition) {
799
+ // Verify no duplicates in project names.
800
+ definition.projects.reduce((acc, project) => {
801
+ if (acc.includes(project.name)) {
802
+ throw new Error(`Duplicate project: ${project.name}`);
803
+ }
804
+ return [...acc, project.name];
805
+ }, []);
806
+ // Verify no duplicates in repos.
807
+ definition.projects
808
+ .flatMap((project) => project.github.flatMap((org) => (org.repos || []).map((repo) => getRepoId(org.organization, repo.name))))
809
+ .reduce((acc, repoName) => {
810
+ if (acc.includes(repoName)) {
811
+ throw new Error(`Duplicate repo: ${repoName}`);
812
+ }
813
+ return [...acc, repoName];
814
+ }, []);
815
+ }
816
+ class DefinitionFile {
817
+ path;
818
+ constructor(path) {
819
+ this.path = path;
820
+ }
821
+ async getContents() {
822
+ return new Promise((resolve, reject) => fs.readFile(this.path, "utf-8", (err, data) => {
823
+ if (err)
824
+ reject(err);
825
+ else
826
+ resolve(data);
827
+ }));
828
+ }
829
+ async getDefinition() {
830
+ return parseDefinition(await this.getContents());
831
+ }
832
+ }
833
+ function parseDefinition(value) {
834
+ const result = checkAgainstSchema(yaml.load(value));
835
+ if ("error" in result) {
836
+ throw new Error(`Definition content invalid: ${result.error}`);
837
+ }
838
+ requireValidDefinition(result.definition);
839
+ return result.definition;
840
+ }
841
+ function getRepos(definition) {
842
+ return definition.projects.flatMap((project) => project.github.flatMap((org) => (org.repos || []).map((repo) => ({
843
+ id: getRepoId(org.organization, repo.name),
844
+ orgName: org.organization,
845
+ project,
846
+ repo,
847
+ }))));
848
+ }
849
+
2562
850
  function wasUpdated(output) {
2563
851
  return output.startsWith("Updating ");
2564
852
  }
@@ -2887,7 +1175,7 @@ async function getExpectedRepos(reporter, github, cals, rootdir) {
2887
1175
  };
2888
1176
  }
2889
1177
  async function getInput(prompt) {
2890
- return read({
1178
+ return readInput({
2891
1179
  prompt,
2892
1180
  timeout: 60000,
2893
1181
  });
@@ -2912,7 +1200,7 @@ async function askMoveConfirm() {
2912
1200
  return false;
2913
1201
  }
2914
1202
  }
2915
- async function sync$1({ reporter, github, cals, rootdir, askClone, askMove, }) {
1203
+ async function sync({ reporter, github, cals, rootdir, askClone, askMove, }) {
2916
1204
  const { expectedRepos, definitionRepo } = await getExpectedRepos(reporter, github, cals, rootdir);
2917
1205
  const unknownDirs = [];
2918
1206
  const foundRepos = [];
@@ -2954,7 +1242,7 @@ async function sync$1({ reporter, github, cals, rootdir, askClone, askMove, }) {
2954
1242
  for (const it of archivedRepos) {
2955
1243
  reporter.info(` ${it.actualRelpath}`);
2956
1244
  }
2957
- const thisDirName = path.basename(process$2.cwd());
1245
+ const thisDirName = path.basename(process__default.cwd());
2958
1246
  const archiveDir = `../${thisDirName}-archive`;
2959
1247
  const hasArchiveDir = fs.existsSync(archiveDir);
2960
1248
  if (hasArchiveDir) {
@@ -3036,7 +1324,7 @@ async function loadCalsManifest(config, reporter) {
3036
1324
  const p = await findUp(CALS_YAML, { cwd: config.cwd });
3037
1325
  if (p === undefined) {
3038
1326
  reporter.error(`File ${CALS_YAML} not found. See help`);
3039
- process$2.exitCode = 1;
1327
+ process__default.exitCode = 1;
3040
1328
  return null;
3041
1329
  }
3042
1330
  // TODO: Verify file has expected contents.
@@ -3050,7 +1338,7 @@ async function loadCalsManifest(config, reporter) {
3050
1338
  cals,
3051
1339
  };
3052
1340
  }
3053
- const command$5 = {
1341
+ const command$1 = {
3054
1342
  command: "sync",
3055
1343
  describe: "Sync repositories for working directory",
3056
1344
  builder: (yargs) => yargs
@@ -3100,15 +1388,14 @@ will be stored there.`),
3100
1388
  handler: async (argv) => {
3101
1389
  const config = createConfig();
3102
1390
  const github = await createGitHubService({
3103
- config,
3104
1391
  cache: createCacheProvider(config, argv),
3105
1392
  });
3106
- const reporter = createReporter(argv);
1393
+ const reporter = createReporter();
3107
1394
  const manifest = await loadCalsManifest(config, reporter);
3108
1395
  if (manifest === null)
3109
1396
  return;
3110
1397
  const { dir, cals } = manifest;
3111
- return sync$1({
1398
+ return sync({
3112
1399
  reporter,
3113
1400
  github,
3114
1401
  cals,
@@ -3119,18 +1406,14 @@ will be stored there.`),
3119
1406
  },
3120
1407
  };
3121
1408
 
3122
- const command$4 = {
1409
+ const command = {
3123
1410
  command: "github",
3124
1411
  describe: "Integration with GitHub",
3125
1412
  builder: (yargs) => yargs
3126
- .command(command$c)
3127
- .command(command$b)
3128
- .command(command$a)
3129
- .command(command$9)
3130
- .command(command$8)
3131
- .command(command$7)
3132
- .command(command$6)
3133
- .command(command$5)
1413
+ .command(command$4)
1414
+ .command(command$3)
1415
+ .command(command$2)
1416
+ .command(command$1)
3134
1417
  .demandCommand()
3135
1418
  .usage(`cals github
3136
1419
 
@@ -3146,188 +1429,43 @@ Notes:
3146
1429
  And for a specific project:
3147
1430
  $ cals github generate-clone-commands --org capralifecycle -x buildtools | bash
3148
1431
 
3149
- Keeping up to date with removed/renamed repos:
3150
- $ cals github analyze-directory --org capralifecycle
3151
-
3152
1432
  Some responses are cached for some time. Use the --validate-cache
3153
- option to avoid stale cache. The cache can also be cleared with
3154
- the "cals delete-cache" command.`),
1433
+ option to avoid stale cache.`),
3155
1434
  handler: () => {
3156
- yargs(hideBin(process$2.argv)).showHelp();
1435
+ yargs(hideBin(process__default.argv)).showHelp();
3157
1436
  },
3158
1437
  };
3159
1438
 
3160
- function totalSeverityCount(project) {
3161
- return ((project.issueCountsBySeverity.critical ?? 0) +
3162
- project.issueCountsBySeverity.high +
3163
- project.issueCountsBySeverity.medium +
3164
- project.issueCountsBySeverity.low);
3165
- }
3166
- function buildStatsLine(stats) {
3167
- function item(num, str) {
3168
- return num === 0 ? " ".repeat(str.length + 4) : sprintf("%3d %s", num, str);
3169
- }
3170
- return sprintf("%s %s %s %s", item(stats.critical ?? 0, "critical"), item(stats.high, "high"), item(stats.medium, "medium"), item(stats.low, "low"));
3171
- }
3172
- async function report({ reporter, snyk, definitionFile, }) {
3173
- const definition = await definitionFile.getDefinition();
3174
- const reposWithIssues = (await snyk.getProjects(definition)).filter((it) => totalSeverityCount(it) > 0);
3175
- const definitionRepos = getRepos(definition);
3176
- function getProject(p) {
3177
- const id = getGitHubRepoId(getGitHubRepo(p));
3178
- const def = id === undefined ? undefined : definitionRepos.find((it) => it.id === id);
3179
- return def === undefined ? undefined : def.project;
3180
- }
3181
- const enhancedRepos = reposWithIssues.map((repo) => ({
3182
- repo,
3183
- project: getProject(repo),
3184
- }));
3185
- function getProjectName(project) {
3186
- return project ? project.name : "unknown project";
3187
- }
3188
- const byProjects = sortBy(Object.values(groupBy(enhancedRepos, (it) => it.project ? it.project.name : "unknown")), (it) => getProjectName(it[0].project));
3189
- if (byProjects.length === 0) {
3190
- reporter.info("No issues found");
3191
- }
3192
- else {
3193
- reporter.info(sprintf("%-70s %s", "Total count", buildStatsLine({
3194
- critical: sumBy(reposWithIssues, (it) => it.issueCountsBySeverity.critical ?? 0),
3195
- high: sumBy(reposWithIssues, (it) => it.issueCountsBySeverity.high),
3196
- medium: sumBy(reposWithIssues, (it) => it.issueCountsBySeverity.medium),
3197
- low: sumBy(reposWithIssues, (it) => it.issueCountsBySeverity.low),
3198
- })));
3199
- reporter.info("Issues by project:");
3200
- byProjects.forEach((repos) => {
3201
- const project = repos[0].project;
3202
- const totalCount = {
3203
- critical: sumBy(repos, (it) => it.repo.issueCountsBySeverity.critical ?? 0),
3204
- high: sumBy(repos, (it) => it.repo.issueCountsBySeverity.high),
3205
- medium: sumBy(repos, (it) => it.repo.issueCountsBySeverity.medium),
3206
- low: sumBy(repos, (it) => it.repo.issueCountsBySeverity.low),
3207
- };
3208
- reporter.info("");
3209
- reporter.info(sprintf("%-70s %s", getProjectName(project), buildStatsLine(totalCount)));
3210
- for (const { repo } of repos) {
3211
- reporter.info(sprintf(" %-68s %s", repo.name, buildStatsLine(repo.issueCountsBySeverity)));
3212
- }
3213
- });
3214
- }
3215
- }
3216
- const command$3 = {
3217
- command: "report",
3218
- describe: "Report Snyk projects status",
3219
- builder: (yargs) => yargs.option(definitionFileOptionName, definitionFileOptionValue),
3220
- handler: async (argv) => report({
3221
- reporter: createReporter(argv),
3222
- snyk: createSnykService({ config: createConfig() }),
3223
- definitionFile: getDefinitionFile(argv),
3224
- }),
3225
- };
3226
-
3227
- async function setToken({ reporter, token, tokenProvider, }) {
3228
- if (token === undefined) {
3229
- reporter.info("Need API token to talk to Snyk");
3230
- reporter.info("See https://app.snyk.io/account");
3231
- // noinspection UnnecessaryLocalVariableJS
3232
- const inputToken = await read({
3233
- prompt: "Enter new Snyk API token: ",
3234
- silent: true,
3235
- });
3236
- token = inputToken;
3237
- }
3238
- await tokenProvider.setToken(token);
3239
- reporter.info("Token saved");
3240
- }
3241
- const command$2 = {
3242
- command: "set-token",
3243
- describe: "Set Snyk token for API calls",
3244
- builder: (yargs) => yargs.positional("token", {
3245
- describe: "Token. If not provided it will be requested as input",
3246
- }),
3247
- handler: async (argv) => setToken({
3248
- reporter: createReporter(argv),
3249
- token: argv.token,
3250
- tokenProvider: new SnykTokenCliProvider(),
3251
- }),
3252
- };
3253
-
3254
- async function sync({ reporter, snyk, definitionFile, }) {
3255
- const definition = await definitionFile.getDefinition();
3256
- const knownRepos = (await snyk.getProjects(definition))
3257
- .map((it) => getGitHubRepo(it))
3258
- .filter((it) => it !== undefined);
3259
- const allReposWithSnyk = getRepos(definition).filter((it) => it.repo.snyk === true);
3260
- const allReposWithSnykStr = allReposWithSnyk.map((it) => getRepoId(it.orgName, it.repo.name));
3261
- const missingInSnyk = allReposWithSnyk.filter((it) => !knownRepos.some((r) => r.owner === it.orgName && r.name === it.repo.name));
3262
- const extraInSnyk = knownRepos.filter((it) => !allReposWithSnykStr.includes(`${it.owner}/${it.name}`));
3263
- if (missingInSnyk.length === 0) {
3264
- reporter.info("All seems fine");
3265
- }
3266
- else {
3267
- missingInSnyk.forEach((it) => {
3268
- reporter.info(`Not in Snyk: ${it.project.name} / ${it.repo.name}`);
3269
- });
3270
- extraInSnyk.forEach((it) => {
3271
- reporter.info(`Should not be in Snyk? ${it.owner}/${it.name}`);
3272
- });
1439
+ function parseVersion(v) {
1440
+ return v
1441
+ .replace(/^[^\d]*/, "")
1442
+ .split(".")
1443
+ .map(Number);
1444
+ }
1445
+ function satisfiesMinVersion(current, required) {
1446
+ const cur = parseVersion(current);
1447
+ const req = parseVersion(required.replace(/^>=?\s*/, ""));
1448
+ for (let i = 0; i < 3; i++) {
1449
+ if ((cur[i] ?? 0) > (req[i] ?? 0))
1450
+ return true;
1451
+ if ((cur[i] ?? 0) < (req[i] ?? 0))
1452
+ return false;
3273
1453
  }
1454
+ return true;
3274
1455
  }
3275
- const command$1 = {
3276
- command: "sync",
3277
- describe: "Sync Snyk projects (currently only reports, no automation)",
3278
- builder: (yargs) => yargs.option(definitionFileOptionName, definitionFileOptionValue),
3279
- handler: async (argv) => sync({
3280
- reporter: createReporter(argv),
3281
- snyk: createSnykService({ config: createConfig() }),
3282
- definitionFile: getDefinitionFile(argv),
3283
- }),
3284
- };
3285
-
3286
- const command = {
3287
- command: "snyk",
3288
- describe: "Integration with Snyk",
3289
- builder: (yargs) => yargs
3290
- .command(command$2)
3291
- .command(command$3)
3292
- .command(command$1)
3293
- .demandCommand()
3294
- .usage(`cals snyk
3295
-
3296
- Notes:
3297
- Before doing anything against Snyk you need to configure a token
3298
- used for authentication. The following command will ask for a token
3299
- and provide a link to generate one:
3300
- $ cals snyk set-token`),
3301
- handler: () => {
3302
- yargs(hideBin(process$2.argv)).showHelp();
3303
- },
3304
- };
3305
-
3306
1456
  async function main() {
3307
- if (!semver.satisfies(process$2.version, engines.node)) {
3308
- console.error(`Required node version ${engines.node} not satisfied with current version ${process$2.version}.`);
3309
- process$2.exit(1);
1457
+ if (!satisfiesMinVersion(process__default.version, engines.node)) {
1458
+ console.error(`Required node version ${engines.node} not satisfied with current version ${process__default.version}.`);
1459
+ process__default.exit(1);
3310
1460
  }
3311
- await yargs(hideBin(process$2.argv))
3312
- .usage(`cals-cli v${version} (build: ${"2026-01-12T15:34:12+0000"})`)
1461
+ await yargs(hideBin(process__default.argv))
1462
+ .usage(`cals-cli v${version} (build: ${"2026-01-30T14:13:10.419Z"})`)
3313
1463
  .scriptName("cals")
3314
1464
  .locale("en")
3315
1465
  .help("help")
3316
- .command(command$e)
3317
- .command(command$f)
3318
- .command(command$4)
3319
- .command(command$d)
3320
1466
  .command(command)
3321
1467
  .version(version)
3322
1468
  .demandCommand()
3323
- .option("non-interactive", {
3324
- describe: "Non-interactive mode",
3325
- type: "boolean",
3326
- })
3327
- .option("verbose", {
3328
- describe: "Verbose output",
3329
- type: "boolean",
3330
- })
3331
1469
  .option("validate-cache", {
3332
1470
  describe: "Only read from cache if validated against server",
3333
1471
  type: "boolean",