@capraconsulting/cals-cli 3.13.1 → 3.15.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 (51) hide show
  1. package/README.md +42 -33
  2. package/lib/cals-cli.mjs +714 -2497
  3. package/lib/cals-cli.mjs.map +1 -1
  4. package/lib/cli/commands/{github/sync.d.ts → sync.d.ts} +1 -1
  5. package/lib/cli/reporter.d.ts +5 -6
  6. package/lib/cli/util.d.ts +1 -6
  7. package/lib/config.d.ts +0 -2
  8. package/lib/definition/index.d.ts +1 -1
  9. package/lib/definition/types.d.ts +0 -52
  10. package/lib/github/index.d.ts +0 -2
  11. package/lib/github/service.d.ts +2 -67
  12. package/lib/github/types.d.ts +0 -63
  13. package/lib/github/util.d.ts +0 -1
  14. package/lib/index.d.ts +0 -9
  15. package/lib/index.es.js +8 -1323
  16. package/lib/index.es.js.map +1 -1
  17. package/lib/index.js +8 -1323
  18. package/lib/index.js.map +1 -1
  19. package/package.json +7 -19
  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/github/analyze-directory.d.ts +0 -3
  23. package/lib/cli/commands/github/configure.d.ts +0 -3
  24. package/lib/cli/commands/github/generate-clone-commands.d.ts +0 -3
  25. package/lib/cli/commands/github/list-pull-requests-stats.d.ts +0 -3
  26. package/lib/cli/commands/github/list-repos.d.ts +0 -3
  27. package/lib/cli/commands/github/list-webhooks.d.ts +0 -3
  28. package/lib/cli/commands/github/set-token.d.ts +0 -3
  29. package/lib/cli/commands/github/util.d.ts +0 -3
  30. package/lib/cli/commands/github.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/testing/executor.d.ts +0 -25
  45. package/lib/testing/index.d.ts +0 -2
  46. package/lib/testing/lib.d.ts +0 -63
  47. /package/lib/cli/commands/{definition/dump-setup.d.ts → auth.d.ts} +0 -0
  48. /package/lib/cli/commands/{definition.d.ts → clone.d.ts} +0 -0
  49. /package/lib/cli/commands/{definition/validate.d.ts → groups.d.ts} +0 -0
  50. /package/lib/cli/commands/{delete-cache.d.ts → repos.d.ts} +0 -0
  51. /package/lib/cli/commands/{getting-started.d.ts → topics.d.ts} +0 -0
package/lib/cals-cli.mjs CHANGED
@@ -1,1046 +1,39 @@
1
1
  #!/usr/bin/env node
2
- import * as process$1 from 'node:process';
2
+ import * as process from 'node:process';
3
3
  import process__default from 'node:process';
4
- import semver from 'semver';
5
4
  import yargs from 'yargs';
6
5
  import { hideBin } from 'yargs/helpers';
6
+ import keytar from 'keytar';
7
+ import readline from 'node:readline';
8
+ import chalk from 'chalk';
7
9
  import fs from 'node:fs';
8
- import yaml from 'js-yaml';
9
- import pMap from 'p-map';
10
- import AJV from 'ajv';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import cachedir from 'cachedir';
11
13
  import { Buffer } from 'node:buffer';
12
14
  import { performance } from 'node:perf_hooks';
13
15
  import { Octokit } from '@octokit/rest';
14
- import fetch from 'node-fetch';
15
16
  import pLimit from 'p-limit';
16
- import keytar from 'keytar';
17
- import { deprecate } from 'node:util';
18
- import path from 'node:path';
19
- import https from 'node:https';
20
- import os from 'node:os';
21
- import cachedir from 'cachedir';
22
- import readline from 'node:readline';
23
- 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.1";
22
+ var version = "3.15.0";
30
23
  var engines = {
31
- node: ">=22.14.0"
32
- };
33
-
34
- function groupBy(array, iteratee) {
35
- return array.reduce((result, item) => {
36
- const key = iteratee(item);
37
- if (!result[key]) {
38
- result[key] = [];
39
- }
40
- result[key].push(item);
41
- return result;
42
- }, {});
43
- }
44
- function uniq(array) {
45
- return Array.from(new Set(array));
46
- }
47
- function sortBy(arr, getKey) {
48
- return [...arr].sort((a, b) => getKey(a).localeCompare(getKey(b)));
49
- }
50
- function sumBy(array, iteratee) {
51
- return array.reduce((sum, item) => sum + iteratee(item), 0);
52
- }
53
-
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__default.env.CALS_GITHUB_TOKEN) {
468
- return process__default.env.CALS_GITHUB_TOKEN;
469
- }
470
- const result = await keytar.getPassword(this.keyringService, this.keyringAccount);
471
- if (result == null) {
472
- process__default.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
- }
24
+ node: ">=22.14.0"
25
+ };
1033
26
 
1034
- class SnykTokenCliProvider {
27
+ class GitHubTokenCliProvider {
1035
28
  keyringService = "cals";
1036
- keyringAccount = "snyk-token";
29
+ keyringAccount = "github-token";
1037
30
  async getToken() {
1038
- if (process__default.env.CALS_SNYK_TOKEN) {
1039
- return process__default.env.CALS_SNYK_TOKEN;
31
+ if (process__default.env.CALS_GITHUB_TOKEN) {
32
+ return process__default.env.CALS_GITHUB_TOKEN;
1040
33
  }
1041
34
  const result = await keytar.getPassword(this.keyringService, this.keyringAccount);
1042
35
  if (result == null) {
1043
- process__default.stderr.write("No token found. Register using `cals snyk set-token`\n");
36
+ process__default.stderr.write("No token found. Register using `cals github set-token`\n");
1044
37
  return undefined;
1045
38
  }
1046
39
  return result;
@@ -1053,124 +46,95 @@ class SnykTokenCliProvider {
1053
46
  }
1054
47
  }
1055
48
 
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__default.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
- }
49
+ const CLEAR_WHOLE_LINE = 0;
50
+ function clearLine(stdout) {
51
+ readline.clearLine(stdout, CLEAR_WHOLE_LINE);
52
+ readline.cursorTo(stdout, 0);
1139
53
  }
1140
- function createSnykService(props) {
1141
- return new SnykService({
1142
- config: props.config,
1143
- tokenProvider: props.tokenProvider ?? new SnykTokenCliProvider(),
54
+ async function readInput(options) {
55
+ process__default.stdout.write(options.prompt);
56
+ // For silent mode, read character by character with raw mode to hide input
57
+ if (options.silent && process__default.stdin.isTTY) {
58
+ return new Promise((resolve, reject) => {
59
+ let input = "";
60
+ process__default.stdin.setRawMode(true);
61
+ process__default.stdin.resume();
62
+ process__default.stdin.setEncoding("utf8");
63
+ const timer = options.timeout
64
+ ? setTimeout(() => {
65
+ cleanup();
66
+ reject(new Error("Input timed out"));
67
+ }, options.timeout)
68
+ : null;
69
+ const cleanup = () => {
70
+ process__default.stdin.setRawMode(false);
71
+ process__default.stdin.pause();
72
+ process__default.stdin.removeListener("data", onData);
73
+ if (timer)
74
+ clearTimeout(timer);
75
+ };
76
+ const onData = (char) => {
77
+ if (char === "\r" || char === "\n") {
78
+ cleanup();
79
+ process__default.stdout.write("\n");
80
+ resolve(input);
81
+ }
82
+ else if (char === "\u0003") {
83
+ // Ctrl+C
84
+ cleanup();
85
+ process__default.exit(1);
86
+ }
87
+ else if (char === "\u007F" || char === "\b") {
88
+ // Backspace
89
+ input = input.slice(0, -1);
90
+ }
91
+ else {
92
+ input += char;
93
+ }
94
+ };
95
+ process__default.stdin.on("data", onData);
96
+ });
97
+ }
98
+ // Normal (non-silent) mode
99
+ const rl = readline.createInterface({
100
+ input: process__default.stdin,
101
+ output: process__default.stdout,
102
+ });
103
+ return new Promise((resolve, reject) => {
104
+ const timer = options.timeout
105
+ ? setTimeout(() => {
106
+ rl.close();
107
+ reject(new Error("Input timed out"));
108
+ }, options.timeout)
109
+ : null;
110
+ rl.question("", (answer) => {
111
+ if (timer)
112
+ clearTimeout(timer);
113
+ rl.close();
114
+ resolve(answer);
115
+ });
1144
116
  });
1145
117
  }
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
- };
118
+ class Reporter {
119
+ stdout = process__default.stdout;
120
+ stderr = process__default.stderr;
121
+ format = chalk;
122
+ error(msg) {
123
+ clearLine(this.stderr);
124
+ this.stderr.write(`${this.format.red("error")} ${msg}\n`);
1157
125
  }
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
- };
126
+ log(msg) {
127
+ clearLine(this.stdout);
128
+ this.stdout.write(`${msg}\n`);
129
+ }
130
+ warn(msg) {
131
+ clearLine(this.stderr);
132
+ this.stderr.write(`${this.format.yellow("warning")} ${msg}\n`);
133
+ }
134
+ info(msg) {
135
+ clearLine(this.stdout);
136
+ this.stdout.write(`${this.format.blue("info")} ${msg}\n`);
1169
137
  }
1170
- return undefined;
1171
- }
1172
- function getGitHubRepoId(repo) {
1173
- return repo ? `${repo.owner}/${repo.name}` : undefined;
1174
138
  }
1175
139
 
1176
140
  class CacheProvider {
@@ -1230,9 +194,6 @@ class Config {
1230
194
  cwd = path.resolve(process__default.cwd());
1231
195
  configFile = path.join(os.homedir(), ".cals-config.json");
1232
196
  cacheDir = cachedir("cals-cli");
1233
- agent = new https.Agent({
1234
- keepAlive: true,
1235
- });
1236
197
  configCached = undefined;
1237
198
  get config() {
1238
199
  const existingConfig = this.configCached;
@@ -1275,987 +236,316 @@ class Config {
1275
236
  }
1276
237
  }
1277
238
 
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__default.stdout;
1289
- stderr = process__default.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
- });
239
+ function createReporter() {
240
+ return new Reporter();
1316
241
  }
1317
242
  function createCacheProvider(config, argv) {
1318
243
  const cache = new CacheProvider(config);
1319
- // --validate-cache
1320
- if (argv.validateCache === true) {
244
+ if (argv.noCache === true) {
1321
245
  cache.mustValidate = true;
1322
246
  }
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
247
  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__default.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);
1852
- }
1853
- else {
1854
- changes.push({
1855
- type: "member-remove",
1856
- org: org.login,
1857
- user: user.login,
1858
- });
1859
- }
1860
- });
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,
248
+ }
249
+ function createConfig() {
250
+ return new Config();
251
+ }
252
+
253
+ async function authenticate({ reporter, token, tokenProvider, }) {
254
+ if (token === undefined) {
255
+ reporter.info("Need API token to talk to GitHub");
256
+ reporter.info("https://github.com/settings/tokens/new?scopes=repo:status,read:repo_hook");
257
+ const inputToken = await readInput({
258
+ prompt: "Enter GitHub token: ",
259
+ silent: true,
1866
260
  });
261
+ token = inputToken;
1867
262
  }
1868
- return changes;
263
+ await tokenProvider.setToken(token);
264
+ reporter.info("Token saved to keychain");
1869
265
  }
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,
266
+ const command$5 = {
267
+ command: "auth [token]",
268
+ describe: "Authenticate with GitHub",
269
+ builder: (yargs) => yargs.positional("token", {
270
+ describe: "GitHub token (prompted if not provided)",
271
+ type: "string",
272
+ }),
273
+ handler: async (argv) => {
274
+ await authenticate({
275
+ reporter: createReporter(),
276
+ token: argv.token,
277
+ tokenProvider: new GitHubTokenCliProvider(),
1897
278
  });
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,
279
+ },
280
+ };
281
+
282
+ class GitHubService {
283
+ octokit;
284
+ cache;
285
+ tokenProvider;
286
+ semaphore;
287
+ constructor(props) {
288
+ this.octokit = props.octokit;
289
+ this.cache = props.cache;
290
+ this.tokenProvider = props.tokenProvider;
291
+ // Control concurrency to GitHub API at service level so we
292
+ // can maximize concurrency all other places.
293
+ this.semaphore = pLimit(6);
294
+ this.octokit.hook.wrap("request", async (request, options) => {
295
+ if (options.method !== "GET") {
296
+ return this.semaphore(() => request(options));
297
+ }
298
+ // Try to cache ETag for GET requests to save on rate limiting.
299
+ // Hits on ETag does not count towards rate limiting.
300
+ const rest = {
301
+ ...options,
302
+ };
303
+ delete rest.method;
304
+ delete rest.baseUrl;
305
+ delete rest.headers;
306
+ delete rest.mediaType;
307
+ delete rest.request;
308
+ // Build a key that is used to identify this request.
309
+ const key = Buffer.from(JSON.stringify(rest)).toString("base64");
310
+ const cacheItem = this.cache.retrieveJson(key);
311
+ if (cacheItem !== undefined) {
312
+ // Copying doesn't work, seems we need to mutate this.
313
+ options.headers["If-None-Match"] = cacheItem.data.etag;
314
+ }
315
+ const getResponse = async (allowRetry = true) => {
316
+ try {
317
+ return await request(options);
318
+ }
319
+ catch (e) {
320
+ // Handle no change in ETag.
321
+ if (e.status === 304) {
322
+ return undefined;
323
+ }
324
+ // GitHub seems to throw a lot of 502 errors.
325
+ // Let's give it a few seconds and retry one time.
326
+ if (e.status === 502 && allowRetry) {
327
+ await new Promise((resolve) => setTimeout(resolve, 2000));
328
+ return await getResponse(false);
329
+ }
330
+ throw e;
331
+ }
332
+ };
333
+ const response = await this.semaphore(async () => {
334
+ return getResponse();
1922
335
  });
336
+ if (response === undefined) {
337
+ // Undefined is returned for cached data.
338
+ if (cacheItem === undefined) {
339
+ throw new Error("Missing expected cache item");
340
+ }
341
+ // Use previous value.
342
+ return cacheItem.data.data;
343
+ }
344
+ // New value. Store Etag.
345
+ if (response.headers.etag) {
346
+ this.cache.storeJson(key, {
347
+ etag: response.headers.etag,
348
+ data: response,
349
+ });
350
+ }
351
+ return response;
1923
352
  });
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",
353
+ }
354
+ async runGraphqlQuery(query) {
355
+ const token = await this.tokenProvider.getToken();
356
+ if (token === undefined) {
357
+ throw new Error("Missing token for GitHub");
358
+ }
359
+ const url = "https://api.github.com/graphql";
360
+ const headers = {
361
+ Authorization: `Bearer ${token}`,
362
+ };
363
+ let requestDuration = -1;
364
+ const response = await this.semaphore(() => {
365
+ const requestStart = performance.now();
366
+ const result = fetch(url, {
367
+ method: "POST",
368
+ headers,
369
+ body: JSON.stringify({ query }),
1935
370
  });
371
+ requestDuration = performance.now() - requestStart;
372
+ return result;
1936
373
  });
1937
- // TODO: team-member-permission (member/maintainer)
1938
- });
1939
- return changes;
1940
- }
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))));
1951
- }
1952
-
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);
374
+ if (response.status === 401) {
375
+ process.stderr.write("Unauthorized\n");
376
+ await this.tokenProvider.markInvalid();
1961
377
  }
1962
- return orgCache[orgName];
1963
- }
1964
- async function getOrgTeamList(orgName) {
1965
- if (!(orgName in orgTeamListCache)) {
1966
- const org = await getOrg(orgName);
1967
- orgTeamListCache[orgName] = await github.getTeamList(org);
378
+ // If you get 502 after 10s, it is a timeout.
379
+ if (response.status === 502) {
380
+ throw new Error(`Response from Github likely timed out (10s max) after elapsed ${requestDuration}ms with status ${response.status}: ${await response.text()}`);
1968
381
  }
1969
- return orgTeamListCache[orgName];
382
+ if (!response.ok) {
383
+ throw new Error(`Response from GitHub not OK (${response.status}): ${await response.text()}`);
384
+ }
385
+ const json = (await response.json());
386
+ if (json.errors) {
387
+ throw new Error(`Error from GitHub GraphQL API: ${JSON.stringify(json.errors)}`);
388
+ }
389
+ if (json.data == null) {
390
+ throw new Error("No data received from GitHub GraphQL API (unknown reason)");
391
+ }
392
+ return json.data;
1970
393
  }
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`);
394
+ async getOrgRepoList({ org }) {
395
+ const getQuery = (after) => `{
396
+ organization(login: "${org}") {
397
+ repositories(first: 100${after === null ? "" : `, after: "${after}"`}) {
398
+ totalCount
399
+ pageInfo {
400
+ hasNextPage
401
+ endCursor
402
+ }
403
+ nodes {
404
+ name
405
+ owner {
406
+ login
407
+ }
408
+ defaultBranchRef {
409
+ name
410
+ }
411
+ createdAt
412
+ updatedAt
413
+ isArchived
414
+ sshUrl
415
+ repositoryTopics(first: 100) {
416
+ edges {
417
+ node {
418
+ topic {
419
+ name
420
+ }
421
+ }
422
+ }
1976
423
  }
1977
- return team;
424
+ }
1978
425
  }
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;
426
+ }
427
+ }`;
428
+ return this.cache.json(`repos-${org}`, async () => {
429
+ const repos = [];
430
+ let after = null;
431
+ while (true) {
432
+ const query = getQuery(after);
433
+ const res = await this.runGraphqlQuery(query);
434
+ if (res.organization == null) {
435
+ throw new Error("Missing organization");
2063
436
  }
2064
- else if ("wiki" in attrib) {
2065
- upd.has_wiki = attrib.wiki;
437
+ if (res.organization.repositories.nodes == null) {
438
+ throw new Error("Missing organization nodes");
2066
439
  }
2067
- else if ("private" in attrib) {
2068
- upd.private = attrib.private;
440
+ repos.push(...res.organization.repositories.nodes);
441
+ if (!res.organization.repositories.pageInfo.hasNextPage) {
442
+ break;
2069
443
  }
444
+ after = res.organization.repositories.pageInfo.endCursor;
2070
445
  }
2071
- await github.octokit.repos.update(upd);
2072
- return true;
2073
- }
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;
446
+ return repos.sort((a, b) => a.name.localeCompare(b.name));
447
+ });
2094
448
  }
2095
449
  }
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);
2104
- }
450
+ async function createOctokit(tokenProvider) {
451
+ return new Octokit({
452
+ auth: await tokenProvider.getToken(),
453
+ });
2105
454
  }
2106
-
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);
2115
- }
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
- };
2125
- }
2126
- return orgs[orgName];
455
+ async function createGitHubService(props) {
456
+ const tokenProvider = props.tokenProvider ?? new GitHubTokenCliProvider();
457
+ return new GitHubService({
458
+ octokit: await createOctokit(tokenProvider),
459
+ cache: props.cache,
460
+ tokenProvider,
2127
461
  });
2128
462
  }
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;
463
+
464
+ function getGroup(repo) {
465
+ const projectTopics = [];
466
+ let isInfra = false;
467
+ repo.repositoryTopics.edges.forEach((edge) => {
468
+ const name = edge.node.topic.name;
469
+ if (name.startsWith("customer-")) {
470
+ projectTopics.push(name.substring(9));
2134
471
  }
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)}`);
472
+ if (name.startsWith("project-")) {
473
+ projectTopics.push(name.substring(8));
2156
474
  }
2157
- }
2158
- if (changes.length === 0) {
2159
- reporter.info("No actions to be performed");
2160
- }
2161
- else {
2162
- reporter.info("To be performed:");
2163
- for (const change of changes) {
2164
- reporter.info(` - ${JSON.stringify(change)}`);
475
+ if (name === "infrastructure") {
476
+ isInfra = true;
2165
477
  }
478
+ });
479
+ if (projectTopics.length > 1) {
480
+ console.warn(`Repo ${repo.name} has multiple project groups: ${projectTopics.join(", ")}. Picking first`);
2166
481
  }
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
- }
482
+ if (projectTopics.length > 0) {
483
+ return projectTopics[0];
484
+ }
485
+ if (isInfra) {
486
+ return "infrastructure";
2179
487
  }
2180
- reporter.info(`Number of GitHub requests: ${github.requestCount}`);
488
+ return null;
489
+ }
490
+ function ifnull(a, other) {
491
+ return a === null ? other : a;
492
+ }
493
+ function getGroupedRepos(repos) {
494
+ return Object.values(repos.reduce((acc, repo) => {
495
+ const group = ifnull(getGroup(repo), "(unknown)");
496
+ const value = acc[group] || { name: group, items: [] };
497
+ return {
498
+ ...acc,
499
+ [group]: {
500
+ ...value,
501
+ items: [...value.items, repo],
502
+ },
503
+ };
504
+ }, {})).sort((a, b) => a.name.localeCompare(b.name));
505
+ }
506
+ function includesTopic(repo, topic) {
507
+ return repo.repositoryTopics.edges.some((it) => it.node.topic.name === topic);
2181
508
  }
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);
2206
- });
2207
- },
2208
- };
2209
509
 
2210
- async function generateCloneCommands({ reporter, config, github, org, ...opt }) {
2211
- if (!opt.listGroups && !opt.all && opt.group === undefined) {
510
+ async function generateCloneCommands({ config, github, org, ...opt }) {
511
+ if (!opt.all && opt.group === undefined) {
2212
512
  yargs(hideBin(process__default.argv)).showHelp();
2213
513
  return;
2214
514
  }
2215
515
  const repos = await github.getOrgRepoList({ org });
2216
516
  const groups = getGroupedRepos(repos);
2217
- if (opt.listGroups) {
2218
- groups.forEach((it) => {
2219
- reporter.log(it.name);
2220
- });
2221
- return;
2222
- }
2223
- groups.forEach((group) => {
517
+ for (const group of groups) {
2224
518
  if (opt.group !== undefined && opt.group !== group.name) {
2225
- return;
519
+ continue;
2226
520
  }
2227
521
  group.items
2228
522
  .filter((it) => opt.includeArchived || !it.isArchived)
2229
523
  .filter((it) => opt.name === undefined || it.name.includes(opt.name))
2230
524
  .filter((it) => opt.topic === undefined || includesTopic(it, opt.topic))
2231
- .filter((it) => !opt.excludeExisting ||
2232
- !fs.existsSync(path.resolve(config.cwd, it.name)))
525
+ .filter((it) => !opt.skipCloned || !fs.existsSync(path.resolve(config.cwd, it.name)))
2233
526
  .forEach((repo) => {
2234
527
  // The output of this is used to pipe into e.g. bash.
2235
- // We cannot use reporter.log as it adds additional characters.
2236
- process__default.stdout.write(sprintf('[ ! -e "%s" ] && git clone %s\n', repo.name, repo.sshUrl));
528
+ process__default.stdout.write(`[ ! -e "${repo.name}" ] && git clone ${repo.sshUrl}\n`);
2237
529
  });
2238
- });
530
+ }
2239
531
  }
2240
- const command$a = {
2241
- command: "generate-clone-commands",
2242
- describe: "Generate shell commands to clone GitHub repos for an organization",
532
+ const command$4 = {
533
+ command: "clone [group]",
534
+ describe: "Generate git clone commands (pipe to bash to execute)",
2243
535
  builder: (yargs) => yargs
2244
536
  .positional("group", {
2245
- describe: "Group to generate commands for",
537
+ describe: "Clone only repos in this group",
538
+ type: "string",
2246
539
  })
2247
540
  .options("org", {
2248
- demandOption: true,
2249
- describe: "Specify GitHub organization",
541
+ alias: "o",
542
+ default: "capralifecycle",
543
+ requiresArg: true,
544
+ describe: "GitHub organization",
2250
545
  type: "string",
2251
546
  })
2252
547
  .option("all", {
2253
- describe: "Use all groups",
2254
- type: "boolean",
2255
- })
2256
- .option("list-groups", {
2257
- alias: "l",
2258
- describe: "List available groups",
548
+ describe: "Clone all repos",
2259
549
  type: "boolean",
2260
550
  })
2261
551
  .option("include-archived", {
@@ -2266,90 +556,58 @@ const command$a = {
2266
556
  .option("name", {
2267
557
  describe: "Filter to include the specified name",
2268
558
  type: "string",
559
+ requiresArg: true,
2269
560
  })
2270
561
  .option("topic", {
2271
562
  alias: "t",
2272
563
  describe: "Filter by specific topic",
2273
564
  type: "string",
565
+ requiresArg: true,
2274
566
  })
2275
- .option("exclude-existing", {
2276
- alias: "x",
2277
- describe: "Exclude if existing in working directory",
567
+ .option("skip-cloned", {
568
+ alias: "s",
569
+ describe: "Skip repos already cloned in working directory",
2278
570
  type: "boolean",
2279
571
  }),
2280
572
  handler: async (argv) => {
2281
573
  const config = createConfig();
2282
574
  return generateCloneCommands({
2283
- reporter: createReporter(argv),
2284
575
  config,
2285
576
  github: await createGitHubService({
2286
- config,
2287
577
  cache: createCacheProvider(config, argv),
2288
578
  }),
2289
579
  all: !!argv.all,
2290
- listGroups: !!argv["list-groups"],
2291
580
  includeArchived: !!argv["include-archived"],
2292
581
  name: argv.name,
2293
582
  topic: argv.topic,
2294
- excludeExisting: !!argv["exclude-existing"],
583
+ skipCloned: !!argv["skip-cloned"],
2295
584
  group: argv.group,
2296
585
  org: argv.org,
2297
586
  });
2298
587
  },
2299
588
  };
2300
589
 
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",
590
+ const command$3 = {
591
+ command: "groups",
592
+ describe: "List available repository groups in a GitHub organization",
593
+ builder: (yargs) => yargs.options("org", {
594
+ alias: "o",
595
+ default: "capralifecycle",
596
+ requiresArg: true,
597
+ describe: "GitHub organization",
598
+ type: "string",
599
+ }),
2344
600
  handler: async (argv) => {
2345
601
  const config = createConfig();
2346
- await listPullRequestsStats({
2347
- reporter: createReporter(argv),
2348
- github: await createGitHubService({
2349
- config,
2350
- cache: createCacheProvider(config, argv),
2351
- }),
602
+ const reporter = createReporter();
603
+ const github = await createGitHubService({
604
+ cache: createCacheProvider(config, argv),
2352
605
  });
606
+ const repos = await github.getOrgRepoList({ org: argv.org });
607
+ const groups = getGroupedRepos(repos);
608
+ for (const group of groups) {
609
+ reporter.log(group.name);
610
+ }
2353
611
  },
2354
612
  };
2355
613
 
@@ -2383,181 +641,268 @@ async function listRepos({ reporter, github, includeArchived, name = undefined,
2383
641
  if (!csv && compact) {
2384
642
  reporter.log(`${group.name}`);
2385
643
  }
2386
- else if (!csv) {
2387
- reporter.log("");
2388
- reporter.log(`======== ${group.name} ========`);
644
+ else if (!csv) {
645
+ reporter.log("");
646
+ reporter.log(`======== ${group.name} ========`);
647
+ }
648
+ group.items.forEach((repo) => {
649
+ if (csv) {
650
+ // We assume we have no repos or group names with a comma in its name.
651
+ process__default.stdout.write(`${repo.name},${group.name}\n`);
652
+ return;
653
+ }
654
+ if (compact) {
655
+ reporter.log(`- ${repo.name}`);
656
+ return;
657
+ }
658
+ reporter.log(`${repo.name}`);
659
+ reporter.log(`- Created: ${repo.createdAt}`);
660
+ reporter.log(`- Updated: ${repo.updatedAt}`);
661
+ if (repo.repositoryTopics.edges.length === 0) {
662
+ reporter.log("- Topics: (none)");
663
+ }
664
+ else {
665
+ reporter.log("- Topics:");
666
+ repo.repositoryTopics.edges.forEach((edge) => {
667
+ reporter.log(` - ${edge.node.topic.name}`);
668
+ });
669
+ }
670
+ });
671
+ });
672
+ if (csv) {
673
+ return;
674
+ }
675
+ reporter.log("");
676
+ reporter.log(`Total number of repos: ${repos.length}`);
677
+ const missingGroup = getReposMissingGroup(repos);
678
+ if (missingGroup.length > 0) {
679
+ reporter.log("");
680
+ reporter.log("Repos missing group/customer topic:");
681
+ missingGroup.forEach((repo) => {
682
+ reporter.log(`- ${repo.name}`);
683
+ });
684
+ reporter.log("Useful search query: https://github.com/capralifecycle?q=topics%3A0");
685
+ }
686
+ const days = 180;
687
+ const oldRepos = getOldRepos(repos, days);
688
+ if (oldRepos.length > 0) {
689
+ reporter.log("");
690
+ reporter.log(`Repositories not updated for ${days} days:`);
691
+ oldRepos.forEach((repo) => {
692
+ reporter.log(`- ${repo.name} - ${repo.updatedAt}`);
693
+ });
694
+ }
695
+ }
696
+ const command$2 = {
697
+ command: "repos",
698
+ describe: "List repositories in a GitHub organization",
699
+ builder: (yargs) => yargs
700
+ .options("org", {
701
+ alias: "o",
702
+ default: "capralifecycle",
703
+ requiresArg: true,
704
+ describe: "GitHub organization",
705
+ type: "string",
706
+ })
707
+ .option("include-archived", {
708
+ alias: "a",
709
+ describe: "Include archived repos",
710
+ type: "boolean",
711
+ })
712
+ .options("compact", {
713
+ alias: "c",
714
+ describe: "Compact output list",
715
+ type: "boolean",
716
+ })
717
+ .options("csv", {
718
+ describe: "Output as a CSV list that can be used for automation",
719
+ type: "boolean",
720
+ })
721
+ .option("name", {
722
+ describe: "Filter to include the specified name",
723
+ type: "string",
724
+ requiresArg: true,
725
+ })
726
+ .option("topic", {
727
+ alias: "t",
728
+ describe: "Filter by specific topic",
729
+ type: "string",
730
+ requiresArg: true,
731
+ }),
732
+ handler: async (argv) => {
733
+ const config = createConfig();
734
+ await listRepos({
735
+ reporter: createReporter(),
736
+ github: await createGitHubService({
737
+ cache: createCacheProvider(config, argv),
738
+ }),
739
+ includeArchived: !!argv["include-archived"],
740
+ name: argv.name,
741
+ topic: argv.topic,
742
+ compact: !!argv.compact,
743
+ csv: !!argv.csv,
744
+ org: argv.org,
745
+ });
746
+ },
747
+ };
748
+
749
+ var type = "object";
750
+ var properties = {
751
+ projects: {
752
+ type: "array",
753
+ items: {
754
+ $ref: "#/definitions/Project"
755
+ }
756
+ }
757
+ };
758
+ var required = [
759
+ "projects"
760
+ ];
761
+ var definitions = {
762
+ Project: {
763
+ type: "object",
764
+ properties: {
765
+ name: {
766
+ type: "string"
767
+ },
768
+ github: {
769
+ type: "array",
770
+ items: {
771
+ type: "object",
772
+ properties: {
773
+ organization: {
774
+ type: "string"
775
+ },
776
+ repos: {
777
+ type: "array",
778
+ items: {
779
+ $ref: "#/definitions/DefinitionRepo"
780
+ }
781
+ }
782
+ },
783
+ required: [
784
+ "organization"
785
+ ]
786
+ }
787
+ },
788
+ tags: {
789
+ type: "array",
790
+ items: {
791
+ type: "string"
792
+ }
793
+ }
794
+ },
795
+ required: [
796
+ "github",
797
+ "name"
798
+ ]
799
+ },
800
+ DefinitionRepo: {
801
+ type: "object",
802
+ properties: {
803
+ name: {
804
+ type: "string"
805
+ },
806
+ previousNames: {
807
+ type: "array",
808
+ items: {
809
+ $ref: "#/definitions/DefinitionRepoPreviousName"
810
+ }
811
+ },
812
+ archived: {
813
+ type: "boolean"
814
+ }
815
+ },
816
+ required: [
817
+ "name"
818
+ ]
819
+ },
820
+ DefinitionRepoPreviousName: {
821
+ type: "object",
822
+ properties: {
823
+ name: {
824
+ type: "string"
825
+ },
826
+ project: {
827
+ type: "string"
828
+ }
829
+ },
830
+ required: [
831
+ "name",
832
+ "project"
833
+ ]
834
+ }
835
+ };
836
+ var $schema = "http://json-schema.org/draft-07/schema#";
837
+ var schema = {
838
+ type: type,
839
+ properties: properties,
840
+ required: required,
841
+ definitions: definitions,
842
+ $schema: $schema
843
+ };
844
+
845
+ function getRepoId(orgName, repoName) {
846
+ return `${orgName}/${repoName}`;
847
+ }
848
+ function checkAgainstSchema(value) {
849
+ const ajv = new AJV({ allErrors: true });
850
+ const valid = ajv.validate(schema, value);
851
+ return valid
852
+ ? { definition: value }
853
+ : { error: ajv.errorsText() ?? "Unknown error" };
854
+ }
855
+ function requireValidDefinition(definition) {
856
+ // Verify no duplicates in project names.
857
+ definition.projects.reduce((acc, project) => {
858
+ if (acc.includes(project.name)) {
859
+ throw new Error(`Duplicate project: ${project.name}`);
860
+ }
861
+ return [...acc, project.name];
862
+ }, []);
863
+ // Verify no duplicates in repos.
864
+ definition.projects
865
+ .flatMap((project) => project.github.flatMap((org) => (org.repos || []).map((repo) => getRepoId(org.organization, repo.name))))
866
+ .reduce((acc, repoName) => {
867
+ if (acc.includes(repoName)) {
868
+ throw new Error(`Duplicate repo: ${repoName}`);
2389
869
  }
2390
- group.items.forEach((repo) => {
2391
- if (csv) {
2392
- // We assume we have no repos or group names with a comma in its name.
2393
- process__default.stdout.write(`${repo.name},${group.name}\n`);
2394
- return;
2395
- }
2396
- if (compact) {
2397
- reporter.log(`- ${repo.name}`);
2398
- return;
2399
- }
2400
- reporter.log(`${repo.name}`);
2401
- reporter.log(`- Created: ${repo.createdAt}`);
2402
- reporter.log(`- Updated: ${repo.updatedAt}`);
2403
- if (repo.repositoryTopics.edges.length === 0) {
2404
- reporter.log("- Topics: (none)");
2405
- }
2406
- else {
2407
- reporter.log("- Topics:");
2408
- repo.repositoryTopics.edges.forEach((edge) => {
2409
- reporter.log(` - ${edge.node.topic.name}`);
2410
- });
2411
- }
2412
- });
2413
- });
2414
- if (csv) {
2415
- return;
870
+ return [...acc, repoName];
871
+ }, []);
872
+ }
873
+ class DefinitionFile {
874
+ path;
875
+ constructor(path) {
876
+ this.path = path;
2416
877
  }
2417
- reporter.log("");
2418
- reporter.log(`Total number of repos: ${repos.length}`);
2419
- const missingGroup = getReposMissingGroup(repos);
2420
- if (missingGroup.length > 0) {
2421
- reporter.log("");
2422
- reporter.log("Repos missing group/customer topic:");
2423
- missingGroup.forEach((repo) => {
2424
- reporter.log(`- ${repo.name}`);
2425
- });
2426
- reporter.log("Useful search query: https://github.com/capralifecycle?q=topics%3A0");
878
+ async getContents() {
879
+ return new Promise((resolve, reject) => fs.readFile(this.path, "utf-8", (err, data) => {
880
+ if (err)
881
+ reject(err);
882
+ else
883
+ resolve(data);
884
+ }));
2427
885
  }
2428
- const days = 180;
2429
- const oldRepos = getOldRepos(repos, days);
2430
- if (oldRepos.length > 0) {
2431
- reporter.log("");
2432
- reporter.log(`Repositories not updated for ${days} days:`);
2433
- oldRepos.forEach((repo) => {
2434
- reporter.log(`- ${repo.name} - ${repo.updatedAt}`);
2435
- });
886
+ async getDefinition() {
887
+ return parseDefinition(await this.getContents());
2436
888
  }
2437
889
  }
2438
- const command$8 = {
2439
- command: "list-repos",
2440
- describe: "List Git repos for a GitHub organization",
2441
- builder: (yargs) => yargs
2442
- .options("org", {
2443
- required: true,
2444
- describe: "Specify GitHub organization",
2445
- type: "string",
2446
- })
2447
- .option("include-archived", {
2448
- alias: "a",
2449
- describe: "Include archived repos",
2450
- type: "boolean",
2451
- })
2452
- .options("compact", {
2453
- alias: "c",
2454
- describe: "Compact output list",
2455
- type: "boolean",
2456
- })
2457
- .options("csv", {
2458
- describe: "Output as a CSV list that can be used for automation",
2459
- type: "boolean",
2460
- })
2461
- .option("name", {
2462
- describe: "Filter to include the specified name",
2463
- type: "string",
2464
- })
2465
- .option("topic", {
2466
- alias: "t",
2467
- describe: "Filter by specific topic",
2468
- type: "string",
2469
- }),
2470
- handler: async (argv) => {
2471
- const config = createConfig();
2472
- await listRepos({
2473
- reporter: createReporter(argv),
2474
- github: await createGitHubService({
2475
- config,
2476
- cache: createCacheProvider(config, argv),
2477
- }),
2478
- includeArchived: !!argv["include-archived"],
2479
- name: argv.name,
2480
- topic: argv.topic,
2481
- compact: !!argv.compact,
2482
- csv: !!argv.csv,
2483
- org: argv.org,
2484
- });
2485
- },
2486
- };
2487
-
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
- }
890
+ function parseDefinition(value) {
891
+ const result = checkAgainstSchema(yaml.load(value));
892
+ if ("error" in result) {
893
+ throw new Error(`Definition content invalid: ${result.error}`);
2517
894
  }
895
+ requireValidDefinition(result.definition);
896
+ return result.definition;
2518
897
  }
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, }) {
2535
- if (token === undefined) {
2536
- reporter.info("Need API token to talk to GitHub");
2537
- reporter.info("https://github.com/settings/tokens/new?scopes=repo:status,read:repo_hook");
2538
- const inputToken = await read({
2539
- prompt: "Enter new GitHub API token: ",
2540
- silent: true,
2541
- });
2542
- token = inputToken;
2543
- }
2544
- await tokenProvider.setToken(token);
2545
- reporter.info("Token saved");
898
+ function getRepos(definition) {
899
+ return definition.projects.flatMap((project) => project.github.flatMap((org) => (org.repos || []).map((repo) => ({
900
+ id: getRepoId(org.organization, repo.name),
901
+ orgName: org.organization,
902
+ project,
903
+ repo,
904
+ }))));
2546
905
  }
2547
- const command$6 = {
2548
- command: "set-token",
2549
- describe: "Set GitHub token for API calls",
2550
- builder: (yargs) => yargs.positional("token", {
2551
- 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
- }),
2553
- handler: async (argv) => {
2554
- await setToken$1({
2555
- reporter: createReporter(argv),
2556
- token: argv.token,
2557
- tokenProvider: new GitHubTokenCliProvider(),
2558
- });
2559
- },
2560
- };
2561
906
 
2562
907
  function wasUpdated(output) {
2563
908
  return output.startsWith("Updating ");
@@ -2887,7 +1232,7 @@ async function getExpectedRepos(reporter, github, cals, rootdir) {
2887
1232
  };
2888
1233
  }
2889
1234
  async function getInput(prompt) {
2890
- return read({
1235
+ return readInput({
2891
1236
  prompt,
2892
1237
  timeout: 60000,
2893
1238
  });
@@ -2912,7 +1257,7 @@ async function askMoveConfirm() {
2912
1257
  return false;
2913
1258
  }
2914
1259
  }
2915
- async function sync$1({ reporter, github, cals, rootdir, askClone, askMove, }) {
1260
+ async function sync({ reporter, github, cals, rootdir, clone, move, }) {
2916
1261
  const { expectedRepos, definitionRepo } = await getExpectedRepos(reporter, github, cals, rootdir);
2917
1262
  const unknownDirs = [];
2918
1263
  const foundRepos = [];
@@ -2972,8 +1317,8 @@ async function sync$1({ reporter, github, cals, rootdir, askClone, askMove, }) {
2972
1317
  for (const it of movedRepos) {
2973
1318
  reporter.info(` ${it.actualRelpath} -> ${getRelpath(it)}`);
2974
1319
  }
2975
- if (!askMove) {
2976
- reporter.info("To move these repos on disk add --ask-move option");
1320
+ if (!move) {
1321
+ reporter.info("To move these repos on disk add --move option");
2977
1322
  }
2978
1323
  else {
2979
1324
  const shouldMove = await askMoveConfirm();
@@ -3005,8 +1350,8 @@ async function sync$1({ reporter, github, cals, rootdir, askClone, askMove, }) {
3005
1350
  for (const it of missingRepos) {
3006
1351
  reporter.info(` ${it.id}`);
3007
1352
  }
3008
- if (!askClone) {
3009
- reporter.info("To clone these repos add --ask-clone option for dialog");
1353
+ if (!clone) {
1354
+ reporter.info("To clone these repos add --clone option");
3010
1355
  }
3011
1356
  else {
3012
1357
  reporter.info("You must already have working credentials for GitHub set up for clone to work");
@@ -3050,20 +1395,21 @@ async function loadCalsManifest(config, reporter) {
3050
1395
  cals,
3051
1396
  };
3052
1397
  }
3053
- const command$5 = {
1398
+ const command$1 = {
3054
1399
  command: "sync",
3055
1400
  describe: "Sync repositories for working directory",
3056
1401
  builder: (yargs) => yargs
3057
- .option("ask-clone", {
1402
+ .option("clone", {
3058
1403
  alias: "c",
3059
- describe: "Ask to clone new missing repos",
1404
+ describe: "Prompt to clone missing repos",
3060
1405
  type: "boolean",
3061
1406
  })
3062
- .option("ask-move", {
3063
- describe: "Ask to actual move renamed repos",
1407
+ .option("move", {
1408
+ alias: "m",
1409
+ describe: "Prompt to move renamed repos",
3064
1410
  type: "boolean",
3065
1411
  })
3066
- .usage(`cals github sync
1412
+ .usage(`cals sync
3067
1413
 
3068
1414
  Synchronize all checked out GitHub repositories within the working directory
3069
1415
  grouped by the project in the resource definition file. The command can also
@@ -3100,238 +1446,109 @@ will be stored there.`),
3100
1446
  handler: async (argv) => {
3101
1447
  const config = createConfig();
3102
1448
  const github = await createGitHubService({
3103
- config,
3104
1449
  cache: createCacheProvider(config, argv),
3105
1450
  });
3106
- const reporter = createReporter(argv);
1451
+ const reporter = createReporter();
3107
1452
  const manifest = await loadCalsManifest(config, reporter);
3108
1453
  if (manifest === null)
3109
1454
  return;
3110
1455
  const { dir, cals } = manifest;
3111
- return sync$1({
1456
+ return sync({
3112
1457
  reporter,
3113
1458
  github,
3114
1459
  cals,
3115
1460
  rootdir: dir,
3116
- askClone: !!argv["ask-clone"],
3117
- askMove: !!argv["ask-move"],
1461
+ clone: !!argv.clone,
1462
+ move: !!argv.move,
3118
1463
  });
3119
1464
  },
3120
1465
  };
3121
1466
 
3122
- const command$4 = {
3123
- command: "github",
3124
- describe: "Integration with GitHub",
3125
- 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)
3134
- .demandCommand()
3135
- .usage(`cals github
3136
-
3137
- Notes:
3138
- Before doing anything against GitHub you need to configure a token
3139
- used for authentication. The following command will ask for a token
3140
- and provide a link to generate one:
3141
- $ cals github set-token
3142
-
3143
- Quick clone all repos:
3144
- $ cals github generate-clone-commands --org capralifecycle --all -x | bash
3145
-
3146
- And for a specific project:
3147
- $ cals github generate-clone-commands --org capralifecycle -x buildtools | bash
3148
-
3149
- Keeping up to date with removed/renamed repos:
3150
- $ cals github analyze-directory --org capralifecycle
3151
-
3152
- 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.`),
3155
- handler: () => {
3156
- yargs(hideBin(process__default.argv)).showHelp();
3157
- },
3158
- };
3159
-
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),
1467
+ const command = {
1468
+ command: "topics",
1469
+ describe: "List customer topics in a GitHub organization",
1470
+ builder: (yargs) => yargs.options("org", {
1471
+ alias: "o",
1472
+ default: "capralifecycle",
1473
+ requiresArg: true,
1474
+ describe: "GitHub organization",
1475
+ type: "string",
3224
1476
  }),
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,
1477
+ handler: async (argv) => {
1478
+ const config = createConfig();
1479
+ const reporter = createReporter();
1480
+ const github = await createGitHubService({
1481
+ cache: createCacheProvider(config, argv),
3235
1482
  });
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
- }),
1483
+ const repos = await github.getOrgRepoList({ org: argv.org });
1484
+ const topics = new Set();
1485
+ for (const repo of repos) {
1486
+ for (const edge of repo.repositoryTopics.edges) {
1487
+ const name = edge.node.topic.name;
1488
+ if (name.startsWith("customer-")) {
1489
+ topics.add(name);
1490
+ }
1491
+ }
1492
+ }
1493
+ const sorted = [...topics].sort((a, b) => a.localeCompare(b));
1494
+ for (const topic of sorted) {
1495
+ reporter.log(topic);
1496
+ }
1497
+ },
3252
1498
  };
3253
1499
 
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
- });
1500
+ function parseVersion(v) {
1501
+ return v
1502
+ .replace(/^[^\d]*/, "")
1503
+ .split(".")
1504
+ .map(Number);
1505
+ }
1506
+ function satisfiesMinVersion(current, required) {
1507
+ const cur = parseVersion(current);
1508
+ const req = parseVersion(required.replace(/^>=?\s*/, ""));
1509
+ for (let i = 0; i < 3; i++) {
1510
+ if ((cur[i] ?? 0) > (req[i] ?? 0))
1511
+ return true;
1512
+ if ((cur[i] ?? 0) < (req[i] ?? 0))
1513
+ return false;
3273
1514
  }
1515
+ return true;
3274
1516
  }
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__default.argv)).showHelp();
3303
- },
3304
- };
3305
-
3306
1517
  async function main() {
3307
- if (!semver.satisfies(process__default.version, engines.node)) {
1518
+ if (!satisfiesMinVersion(process__default.version, engines.node)) {
3308
1519
  console.error(`Required node version ${engines.node} not satisfied with current version ${process__default.version}.`);
3309
1520
  process__default.exit(1);
3310
1521
  }
3311
1522
  await yargs(hideBin(process__default.argv))
3312
- .usage(`cals-cli v${version} (build: ${"2026-01-22T15:26:47+0000"})`)
1523
+ .usage(`cals v${version} (build: ${"2026-01-30T15:20:39.822Z"})
1524
+
1525
+ A CLI for managing GitHub repositories.
1526
+
1527
+ Before using, authenticate with: cals auth`)
3313
1528
  .scriptName("cals")
3314
1529
  .locale("en")
1530
+ .strict()
1531
+ .strictCommands()
1532
+ .strictOptions()
3315
1533
  .help("help")
3316
- .command(command$e)
3317
- .command(command$f)
1534
+ .command(command$5)
3318
1535
  .command(command$4)
3319
- .command(command$d)
1536
+ .command(command$3)
1537
+ .command(command$2)
1538
+ .command(command$1)
3320
1539
  .command(command)
3321
1540
  .version(version)
3322
1541
  .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
- .option("validate-cache", {
3332
- describe: "Only read from cache if validated against server",
1542
+ .option("no-cache", {
1543
+ describe: "Bypass cache and fetch fresh data",
3333
1544
  type: "boolean",
3334
1545
  })
1546
+ .example("cals auth", "Set GitHub token")
1547
+ .example("cals repos", "List repositories")
1548
+ .example("cals groups", "List repository groups")
1549
+ .example("cals topics", "List customer topics")
1550
+ .example("cals clone --all | bash", "Clone all repos")
1551
+ .example("cals sync", "Pull latest changes")
3335
1552
  .parse();
3336
1553
  }
3337
1554