@theholocron/holocron-plugin-github 5.0.0-alpha.33 → 5.0.0-alpha.330
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.mts +164 -70
- package/dist/index.mjs +594 -67
- package/package.json +25 -21
package/dist/index.mjs
CHANGED
|
@@ -1,48 +1,74 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { AuthError, MissingTokenError, ProviderApiError, connectionProbe, createFeatureResolver } from "@theholocron/cli";
|
|
2
3
|
import { createGitHubClient, createGitHubClient as createGitHubClient$1 } from "@theholocron/github-client";
|
|
3
|
-
import { AuthError, createFeatureResolver } from "@theholocron/cli";
|
|
4
4
|
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
-
|
|
6
|
+
import { findRootSync } from "@theholocron/fs-utils";
|
|
7
|
+
import { ProviderApiError as ProviderApiError$1 } from "@theholocron/http-client";
|
|
8
|
+
import { errorMessage } from "@theholocron/misc-utils";
|
|
9
|
+
// src/auth.ts
|
|
10
|
+
/** Resolves the token for read-only GitHub access (`HOLOCRON_READ_TOKEN`, keyring key `github.read`). */
|
|
7
11
|
const resolveReadToken = createFeatureResolver({
|
|
8
12
|
envName: "HOLOCRON_READ_TOKEN",
|
|
9
13
|
keyringKey: "github.read"
|
|
10
14
|
});
|
|
15
|
+
/** Resolves the token for issue operations (`HOLOCRON_ISSUES_TOKEN`, keyring key `github.issues`). */
|
|
11
16
|
const resolveIssuesToken = createFeatureResolver({
|
|
12
17
|
envName: "HOLOCRON_ISSUES_TOKEN",
|
|
13
18
|
keyringKey: "github.issues"
|
|
14
19
|
});
|
|
20
|
+
/** Resolves the token for syncing repo files and settings (`HOLOCRON_SYNC_TOKEN`, keyring key `github.sync`). */
|
|
15
21
|
const resolveSyncToken = createFeatureResolver({
|
|
16
22
|
envName: "HOLOCRON_SYNC_TOKEN",
|
|
17
23
|
keyringKey: "github.sync"
|
|
18
24
|
});
|
|
25
|
+
/** Resolves the token for release operations (`HOLOCRON_RELEASE_TOKEN`, keyring key `github.release`). */
|
|
19
26
|
const resolveReleaseToken = createFeatureResolver({
|
|
20
27
|
envName: "HOLOCRON_RELEASE_TOKEN",
|
|
21
28
|
keyringKey: "github.release"
|
|
22
29
|
});
|
|
30
|
+
/** Resolves the repo-scoped admin token (`HOLOCRON_ADMIN_TOKEN`, keyring key `github.admin`). */
|
|
23
31
|
const resolveAdminToken = createFeatureResolver({
|
|
24
32
|
envName: "HOLOCRON_ADMIN_TOKEN",
|
|
25
33
|
keyringKey: "github.admin"
|
|
26
34
|
});
|
|
35
|
+
/** Resolves the org-level secrets token (`HOLOCRON_SECRETS_TOKEN`, keyring key `github.secrets`). */
|
|
36
|
+
const resolveSecretsToken = createFeatureResolver({
|
|
37
|
+
envName: "HOLOCRON_SECRETS_TOKEN",
|
|
38
|
+
keyringKey: "github.secrets"
|
|
39
|
+
});
|
|
40
|
+
/** Resolves the token for deployment operations (`HOLOCRON_DEPLOY_TOKEN`, keyring key `github.deploy`). */
|
|
27
41
|
const resolveDeployToken = createFeatureResolver({
|
|
28
42
|
envName: "HOLOCRON_DEPLOY_TOKEN",
|
|
29
43
|
keyringKey: "github.deploy"
|
|
30
44
|
});
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the token for the user-sync files in the org's private config repo (`HOLOCRON_USER_SYNC_TOKEN`, keyring
|
|
47
|
+
* key `github.user-sync`).
|
|
48
|
+
*/
|
|
49
|
+
const resolveUserSyncToken = createFeatureResolver({
|
|
50
|
+
envName: "HOLOCRON_USER_SYNC_TOKEN",
|
|
51
|
+
keyringKey: "github.user-sync"
|
|
52
|
+
});
|
|
53
|
+
/** Resolves the org-level token (`HOLOCRON_ORG_TOKEN`, keyring key `github.org`). */
|
|
31
54
|
const resolveOrgToken = createFeatureResolver({
|
|
32
55
|
envName: "HOLOCRON_ORG_TOKEN",
|
|
33
56
|
keyringKey: "github.org"
|
|
34
57
|
});
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
// src/capabilities/ci.ts
|
|
59
|
+
/** The GitHub implementation of the `ci` capability, backed by GitHub Actions workflow runs. */
|
|
37
60
|
var GitHubCi = class {
|
|
38
61
|
client;
|
|
39
62
|
opts;
|
|
63
|
+
/** Capability key, always `"ci"`. */
|
|
40
64
|
key = "ci";
|
|
65
|
+
/** Provider name, always `"github"`. */
|
|
41
66
|
providerName = "github";
|
|
42
67
|
constructor(client, opts) {
|
|
43
68
|
this.client = client;
|
|
44
69
|
this.opts = opts;
|
|
45
70
|
}
|
|
71
|
+
/** Lists workflow runs for the repo, optionally filtered by branch, status and limit. */
|
|
46
72
|
async listRuns(filter) {
|
|
47
73
|
return (await this.client.workflows.listRuns(this.opts.repo, {
|
|
48
74
|
branch: filter?.branch,
|
|
@@ -50,6 +76,7 @@ var GitHubCi = class {
|
|
|
50
76
|
status: filter?.status
|
|
51
77
|
})).map((r) => this.mapRun(r));
|
|
52
78
|
}
|
|
79
|
+
/** Fetches one workflow run by id. */
|
|
53
80
|
async getRun(id) {
|
|
54
81
|
const raw = await this.client.workflows.getRun(this.opts.repo, id);
|
|
55
82
|
return this.mapRun(raw);
|
|
@@ -77,17 +104,20 @@ var GitHubCi = class {
|
|
|
77
104
|
}
|
|
78
105
|
}
|
|
79
106
|
};
|
|
80
|
-
|
|
81
|
-
|
|
107
|
+
// src/capabilities/environments.ts
|
|
108
|
+
/** The GitHub implementation of the `environments` capability, managing a repo's deployment environments. */
|
|
82
109
|
var GitHubEnvironments = class {
|
|
83
110
|
client;
|
|
84
111
|
opts;
|
|
112
|
+
/** Capability key, always `"environments"`. */
|
|
85
113
|
key = "environments";
|
|
114
|
+
/** Provider name, always `"github"`. */
|
|
86
115
|
providerName = "github";
|
|
87
116
|
constructor(client, opts) {
|
|
88
117
|
this.client = client;
|
|
89
118
|
this.opts = opts;
|
|
90
119
|
}
|
|
120
|
+
/** Lists the repo's environments with their wait timer, self-review rule and required reviewers. */
|
|
91
121
|
async listEnvironments() {
|
|
92
122
|
return (await this.client.environments.listEnvironments(this.opts.repo)).map((e) => ({
|
|
93
123
|
name: e.name,
|
|
@@ -96,6 +126,7 @@ var GitHubEnvironments = class {
|
|
|
96
126
|
reviewers: this.flattenReviewers(e.protection_rules)
|
|
97
127
|
}));
|
|
98
128
|
}
|
|
129
|
+
/** Creates or updates an environment, sending only the settings the given environment defines. */
|
|
99
130
|
async upsertEnvironment(env) {
|
|
100
131
|
const body = {};
|
|
101
132
|
if (env.waitTimer !== void 0) body.wait_timer = env.waitTimer;
|
|
@@ -106,6 +137,7 @@ var GitHubEnvironments = class {
|
|
|
106
137
|
}));
|
|
107
138
|
await this.client.environments.upsertEnvironment(this.opts.repo, env.name, body);
|
|
108
139
|
}
|
|
140
|
+
/** Deletes the named environment. */
|
|
109
141
|
async deleteEnvironment(name) {
|
|
110
142
|
await this.client.environments.deleteEnvironment(this.opts.repo, name);
|
|
111
143
|
}
|
|
@@ -119,11 +151,13 @@ var GitHubEnvironments = class {
|
|
|
119
151
|
}));
|
|
120
152
|
}
|
|
121
153
|
};
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
// src/capabilities/issues.ts
|
|
155
|
+
/** The GitHub implementation of the `issues` capability, backed by GitHub Issues and status labels. */
|
|
124
156
|
var GitHubIssues = class {
|
|
125
157
|
client;
|
|
158
|
+
/** Capability key, always `"issues"`. */
|
|
126
159
|
key = "issues";
|
|
160
|
+
/** Provider name, always `"github"`. */
|
|
127
161
|
providerName = "github";
|
|
128
162
|
owner;
|
|
129
163
|
repoName;
|
|
@@ -145,6 +179,7 @@ var GitHubIssues = class {
|
|
|
145
179
|
...raw.email ? { emailAddress: raw.email } : {}
|
|
146
180
|
};
|
|
147
181
|
}
|
|
182
|
+
/** Searches the repo's issues, newest update first, leaving out pull requests. */
|
|
148
183
|
async search(filter) {
|
|
149
184
|
const params = {
|
|
150
185
|
state: filter.openOnly ? "open" : "all",
|
|
@@ -164,11 +199,13 @@ var GitHubIssues = class {
|
|
|
164
199
|
assignee: params.assignee
|
|
165
200
|
})).filter((i) => !i.pull_request).map((i) => this.mapIssue(i));
|
|
166
201
|
}
|
|
202
|
+
/** Fetches one issue by its key. */
|
|
167
203
|
async get(key) {
|
|
168
204
|
const number = parseIssueNumber(key, this.owner, this.repoName);
|
|
169
205
|
const raw = await this.client.issues.getIssue(this.repo, number);
|
|
170
206
|
return this.mapIssue(raw);
|
|
171
207
|
}
|
|
208
|
+
/** Creates an issue and returns its key as `#<number>`; the milestone is resolved from its title. */
|
|
172
209
|
async create(input) {
|
|
173
210
|
const body = { title: input.summary };
|
|
174
211
|
if (input.body) body.body = input.body;
|
|
@@ -216,10 +253,12 @@ var GitHubIssues = class {
|
|
|
216
253
|
via: `label set to ${targetLabel}`
|
|
217
254
|
};
|
|
218
255
|
}
|
|
256
|
+
/** Adds a comment to the issue with the given key. */
|
|
219
257
|
async comment(key, body) {
|
|
220
258
|
const number = parseIssueNumber(key, this.owner, this.repoName);
|
|
221
259
|
await this.client.issues.createComment(this.repo, number, body);
|
|
222
260
|
}
|
|
261
|
+
/** Checks the authenticated user, the repo and the label-backed lifecycle slots, and reports what resolves. */
|
|
223
262
|
async doctor() {
|
|
224
263
|
const me = await this.getMyself();
|
|
225
264
|
const repo = await this.client.repos.getRepo(this.repo);
|
|
@@ -337,8 +376,7 @@ function parseIssueNumber(key, owner, repoName) {
|
|
|
337
376
|
if (!/^\d+$/.test(num)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
|
|
338
377
|
return parseInt(num, 10);
|
|
339
378
|
}
|
|
340
|
-
|
|
341
|
-
//#region src/sodium.ts
|
|
379
|
+
// src/sodium.ts
|
|
342
380
|
/**
|
|
343
381
|
* libsodium sealed-box encryption for GitHub Actions secrets.
|
|
344
382
|
*
|
|
@@ -369,32 +407,46 @@ async function encryptSecret(publicKeyBase64, value) {
|
|
|
369
407
|
const ciphertext = sodium.crypto_box_seal(plaintext, publicKey);
|
|
370
408
|
return sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
|
|
371
409
|
}
|
|
372
|
-
|
|
373
|
-
|
|
410
|
+
// src/capabilities/secrets.ts
|
|
411
|
+
/** The GitHub implementation of the `secrets` capability, covering repository, environment and organization secrets. */
|
|
374
412
|
var GitHubSecrets = class {
|
|
375
413
|
client;
|
|
376
414
|
opts;
|
|
415
|
+
orgClient;
|
|
416
|
+
/** Capability key, always `"secrets"`. */
|
|
377
417
|
key = "secrets";
|
|
418
|
+
/** Provider name, always `"github"`. */
|
|
378
419
|
providerName = "github";
|
|
379
|
-
|
|
420
|
+
/**
|
|
421
|
+
* `client` handles repo and environment scopes; `orgClient`, when given, handles
|
|
422
|
+
* the organization scope. They differ when a dedicated org-only token exists:
|
|
423
|
+
* it can write org secrets but not repo ones, and `github.admin` is the reverse.
|
|
424
|
+
*/
|
|
425
|
+
constructor(client, opts, orgClient) {
|
|
380
426
|
this.client = client;
|
|
381
427
|
this.opts = opts;
|
|
428
|
+
this.orgClient = orgClient;
|
|
429
|
+
}
|
|
430
|
+
clientFor(scope) {
|
|
431
|
+
return scope.kind === "organization" ? this.orgClient ?? this.client : this.client;
|
|
382
432
|
}
|
|
383
433
|
async listSecrets(scope) {
|
|
384
|
-
return this.
|
|
434
|
+
return this.clientFor(scope).secrets.listSecrets(this.opts.repo, toGHScope(scope));
|
|
385
435
|
}
|
|
386
436
|
async setSecret(scope, name, value) {
|
|
387
437
|
const ghScope = toGHScope(scope);
|
|
388
|
-
const
|
|
438
|
+
const client = this.clientFor(scope);
|
|
439
|
+
const pk = await client.secrets.getPublicKey(this.opts.repo, ghScope);
|
|
389
440
|
const body = {
|
|
390
441
|
encrypted_value: await encryptSecret(pk.key, value),
|
|
391
442
|
key_id: pk.key_id
|
|
392
443
|
};
|
|
393
444
|
if (scope.kind === "organization") body.visibility = "all";
|
|
394
|
-
await
|
|
445
|
+
await client.secrets.putSecret(this.opts.repo, ghScope, name, body);
|
|
395
446
|
}
|
|
447
|
+
/** Deletes the named secret in the given scope. */
|
|
396
448
|
async deleteSecret(scope, name) {
|
|
397
|
-
await this.
|
|
449
|
+
await this.clientFor(scope).secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
|
|
398
450
|
}
|
|
399
451
|
};
|
|
400
452
|
function toGHScope(scope) {
|
|
@@ -404,8 +456,46 @@ function toGHScope(scope) {
|
|
|
404
456
|
};
|
|
405
457
|
return scope;
|
|
406
458
|
}
|
|
407
|
-
|
|
408
|
-
|
|
459
|
+
// src/capabilities/files.ts
|
|
460
|
+
/** Reads one file; `null` when it does not exist. `version` is the blob sha `writeFile` needs to replace exactly this one. */
|
|
461
|
+
/** A fine-grained token only reaches the repositories and permissions it was given; say which one is missing. */
|
|
462
|
+
function noAccess(repo, action, cause) {
|
|
463
|
+
return new Error(`the token cannot ${action} files in ${repo} (403): give the user-sync token (HOLOCRON_USER_SYNC_TOKEN, or holocron auth set github.user-sync) access to that repository with Contents: ${action === "read" ? "read" : "read and write"}`, { cause });
|
|
464
|
+
}
|
|
465
|
+
async function readRepoFile(client, repo, path) {
|
|
466
|
+
try {
|
|
467
|
+
const file = await client.git.getContents(repo, path);
|
|
468
|
+
if (file.encoding !== "base64") throw new Error(`${repo}/${path}: unexpected encoding \`${file.encoding}\``);
|
|
469
|
+
return {
|
|
470
|
+
content: Buffer.from(file.content, "base64").toString("utf-8"),
|
|
471
|
+
version: file.sha
|
|
472
|
+
};
|
|
473
|
+
} catch (err) {
|
|
474
|
+
if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "read", err);
|
|
475
|
+
if (!(err instanceof ProviderApiError && err.status === 404)) throw err;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
await client.repos.getRepo(repo);
|
|
479
|
+
} catch (err) {
|
|
480
|
+
if (err instanceof ProviderApiError && err.status === 404) throw new Error(`${repo} was not found: it does not exist, or the token cannot see it`, { cause: err });
|
|
481
|
+
throw err;
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
/** Creates or replaces one file in a commit; GitHub refuses (409) a `version` that has since changed. */
|
|
486
|
+
async function writeRepoFile(client, repo, path, content, opts) {
|
|
487
|
+
try {
|
|
488
|
+
await client.git.putContents(repo, path, {
|
|
489
|
+
message: opts.message,
|
|
490
|
+
content,
|
|
491
|
+
...opts.version ? { sha: opts.version } : {}
|
|
492
|
+
});
|
|
493
|
+
} catch (err) {
|
|
494
|
+
if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "write", err);
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// src/capabilities/labels.ts
|
|
409
499
|
/**
|
|
410
500
|
* Idempotently synchronise a repo's labels to the canonical set.
|
|
411
501
|
*
|
|
@@ -443,14 +533,179 @@ async function syncLabels(client, repo, canonical, stale) {
|
|
|
443
533
|
}
|
|
444
534
|
return `${created} created, ${updated} updated, ${deleted} deleted`;
|
|
445
535
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
536
|
+
// src/capabilities/property-fix-hints.ts
|
|
537
|
+
/** GitHub's wording when a custom property value is not one the organization's definition allows. */
|
|
538
|
+
const NOT_ALLOWED = /Value '([^']+)' is not allowed for property '([^']+)'/g;
|
|
539
|
+
/**
|
|
540
|
+
* What to do about each value GitHub says an organization custom property does not allow, one sentence per property,
|
|
541
|
+
* read from GitHub's own message so it works even when the organization's definitions cannot be read. The values
|
|
542
|
+
* belong to the organization's definition, not to the repository, so the fix is to add them there: a repository cannot
|
|
543
|
+
* make its own value allowed. Empty when GitHub's message names no such value.
|
|
544
|
+
*/
|
|
545
|
+
function propertyFixHints(githubMessage) {
|
|
546
|
+
const byProperty = /* @__PURE__ */ new Map();
|
|
547
|
+
for (const [, value, property] of githubMessage.matchAll(NOT_ALLOWED)) {
|
|
548
|
+
const values = byProperty.get(property) ?? [];
|
|
549
|
+
if (!values.includes(value)) values.push(value);
|
|
550
|
+
byProperty.set(property, values);
|
|
551
|
+
}
|
|
552
|
+
return [...byProperty].map(([property, values]) => `add ${values.map((v) => `"${v}"`).join(", ")} to the allowed values of the organization custom property ${property} (the organization's Settings, Custom properties), then run repo setup again`);
|
|
553
|
+
}
|
|
554
|
+
// src/capabilities/rejected-properties.ts
|
|
555
|
+
/** The values `repo setup` sends for `true_false` properties; anything else is rejected. */
|
|
556
|
+
const BOOLEANS = /* @__PURE__ */ new Set(["true", "false"]);
|
|
557
|
+
/**
|
|
558
|
+
* What is wrong with `values` when compared with the organization's property definitions, one sentence per
|
|
559
|
+
* problem, in the order the properties are given: a property the organization does not define, a value a
|
|
560
|
+
* `single_select` or `multi_select` property does not allow, or a `true_false` property that is not `true` or
|
|
561
|
+
* `false`. A `string` or `url` property has nothing to check here. Empty when every value fits its definition.
|
|
562
|
+
*/
|
|
563
|
+
function rejectedProperties(values, schema) {
|
|
564
|
+
const byName = new Map(schema.map((property) => [property.property_name, property]));
|
|
565
|
+
const problems = [];
|
|
566
|
+
for (const [name, value] of Object.entries(values)) {
|
|
567
|
+
const definition = byName.get(name);
|
|
568
|
+
if (!definition) {
|
|
569
|
+
problems.push(`${name} is not a custom property of the organization`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const sent = Array.isArray(value) ? value : [value];
|
|
573
|
+
if (definition.value_type === "true_false") {
|
|
574
|
+
for (const item of sent.filter((item) => !BOOLEANS.has(item))) problems.push(`${name} is "${item}", but it takes only true or false`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const allowed = definition.allowed_values ?? [];
|
|
578
|
+
if (definition.value_type !== "single_select" && definition.value_type !== "multi_select") continue;
|
|
579
|
+
for (const item of sent.filter((item) => !allowed.includes(item))) problems.push(`${name} has "${item}", which is not one of its allowed values (${allowed.join(", ")})`);
|
|
580
|
+
}
|
|
581
|
+
return problems;
|
|
582
|
+
}
|
|
583
|
+
// src/capabilities/properties.ts
|
|
584
|
+
/** GitHub's own message from a 422 body (`{"message": ..., "errors": [...]}`), or the start of the body when it is not JSON. */
|
|
585
|
+
function githubMessage(details) {
|
|
586
|
+
const raw = String(details ?? "").trim();
|
|
587
|
+
try {
|
|
588
|
+
const body = JSON.parse(raw);
|
|
589
|
+
const errors = Array.isArray(body.errors) ? body.errors.map((e) => JSON.stringify(e)).join(", ") : "";
|
|
590
|
+
const message = typeof body.message === "string" ? body.message : raw;
|
|
591
|
+
return errors ? `${message}: ${errors}` : message;
|
|
592
|
+
} catch {
|
|
593
|
+
return raw.slice(0, 300);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/** The organization's property definitions, or `undefined` when they cannot be read (permission, an older client). */
|
|
597
|
+
async function orgSchema(client, repo) {
|
|
598
|
+
try {
|
|
599
|
+
return await client.properties.orgSchema(repo.split("/")[0]);
|
|
600
|
+
} catch {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* The values `values` holds that the organization's property definitions do not allow, one sentence each, or
|
|
606
|
+
* `undefined` when the definitions cannot be read (the token lacks the organization's custom properties permission, or
|
|
607
|
+
* the organization defines none). `client` should be the organization-scoped one when there is one, since reading the
|
|
608
|
+
* definitions is an organization permission a repository token often lacks. Read-only.
|
|
609
|
+
*/
|
|
610
|
+
async function checkPropertyValues(client, repo, values) {
|
|
611
|
+
const schema = await orgSchema(client, repo);
|
|
612
|
+
return schema ? rejectedProperties(values, schema) : void 0;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Sets the repo's custom properties. When GitHub answers 422 the error says which value it refused and what to do
|
|
616
|
+
* about it: the values are compared with the organization's property definitions (read-only, with `schemaClient`, the
|
|
617
|
+
* organization-scoped client when there is one), GitHub's own message is added, and each value GitHub says a property
|
|
618
|
+
* does not allow gets the fix, which does not need the definitions to be readable. One refused value fails the whole
|
|
619
|
+
* request, so none of the properties is set.
|
|
620
|
+
*/
|
|
621
|
+
async function syncProperties(client, repo, values, schemaClient = client) {
|
|
622
|
+
try {
|
|
623
|
+
await client.properties.setProperties(repo, values);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
if (!(err instanceof ProviderApiError$1) || err.status !== 422) throw err;
|
|
626
|
+
const problems = await checkPropertyValues(schemaClient, repo, values) ?? [];
|
|
627
|
+
const said = githubMessage(err.details);
|
|
628
|
+
const fixes = propertyFixHints(said);
|
|
629
|
+
throw new Error(`GitHub refused the custom properties (422), so none was set: ${problems.length > 0 ? problems.join("; ") : "no value differs from the organization's definitions, or they could not be read"}. GitHub said: ${said}${fixes.length > 0 ? `. To fix it: ${fixes.join("; ")}` : ""}`, { cause: err });
|
|
630
|
+
}
|
|
450
631
|
return `${Object.keys(values).length} properties set`;
|
|
451
632
|
}
|
|
452
|
-
|
|
453
|
-
|
|
633
|
+
// src/capabilities/read-repo.ts
|
|
634
|
+
/** The settings `repo setup` writes, which `GET /repos/{repo}` reports under the same names. */
|
|
635
|
+
const SETTING_KEYS = [
|
|
636
|
+
"allow_squash_merge",
|
|
637
|
+
"allow_merge_commit",
|
|
638
|
+
"allow_rebase_merge",
|
|
639
|
+
"allow_auto_merge",
|
|
640
|
+
"allow_update_branch",
|
|
641
|
+
"delete_branch_on_merge",
|
|
642
|
+
"has_issues",
|
|
643
|
+
"has_discussions",
|
|
644
|
+
"has_projects",
|
|
645
|
+
"has_wiki"
|
|
646
|
+
];
|
|
647
|
+
/** The repository's description, homepage, default branch, topics and settings, from one read. */
|
|
648
|
+
async function readRepoInfo(client, repo) {
|
|
649
|
+
const data = await client.repos.getRepo(repo);
|
|
650
|
+
const settings = { default_branch: data.default_branch };
|
|
651
|
+
for (const key of SETTING_KEYS) {
|
|
652
|
+
const value = data[key];
|
|
653
|
+
if (value !== void 0) settings[key] = value;
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
description: data.description ?? null,
|
|
657
|
+
homepage: data.homepage ?? null,
|
|
658
|
+
defaultBranch: data.default_branch,
|
|
659
|
+
topics: data.topics ?? [],
|
|
660
|
+
settings
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/** A ruleset reduced to what `repo setup` writes: its enforcement, rule types and required status checks. */
|
|
664
|
+
function toRulesetDetail(data) {
|
|
665
|
+
const rules = data.rules ?? [];
|
|
666
|
+
const checks = rules.find((r) => r.type === "required_status_checks")?.parameters?.required_status_checks;
|
|
667
|
+
return {
|
|
668
|
+
name: data.name,
|
|
669
|
+
enforcement: data.enforcement,
|
|
670
|
+
ruleTypes: rules.map((r) => r.type),
|
|
671
|
+
requiredChecks: Array.isArray(checks) ? checks.map((c) => String(c.context)) : []
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/** The value, or `undefined` when GitHub refuses the read (permission, plan, feature absent). Other errors still throw. */
|
|
675
|
+
async function readable(read) {
|
|
676
|
+
try {
|
|
677
|
+
return await read();
|
|
678
|
+
} catch (err) {
|
|
679
|
+
if (err instanceof ProviderApiError$1) return void 0;
|
|
680
|
+
throw err;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/** Which security features are on. One GitHub refuses to report is left out, not reported as off. */
|
|
684
|
+
async function readSecurity(client, repo) {
|
|
685
|
+
const [data, alerts, fixes, reporting, codeScanning] = await Promise.all([
|
|
686
|
+
readable(() => client.repos.getRepo(repo)),
|
|
687
|
+
readable(() => client.security.vulnerabilityAlertsEnabled(repo)),
|
|
688
|
+
readable(() => client.security.automatedSecurityFixesEnabled(repo)),
|
|
689
|
+
readable(() => client.security.privateVulnerabilityReportingEnabled(repo)),
|
|
690
|
+
readable(() => client.security.codeScanningDefaultSetupState(repo))
|
|
691
|
+
]);
|
|
692
|
+
const analysis = data?.security_and_analysis;
|
|
693
|
+
const on = (feature) => {
|
|
694
|
+
const status = analysis?.[feature]?.status;
|
|
695
|
+
return status === void 0 ? void 0 : status === "enabled";
|
|
696
|
+
};
|
|
697
|
+
const found = {
|
|
698
|
+
vulnerabilityAlerts: alerts,
|
|
699
|
+
automatedSecurityFixes: fixes,
|
|
700
|
+
secretScanning: on("secret_scanning"),
|
|
701
|
+
secretScanningPushProtection: on("secret_scanning_push_protection"),
|
|
702
|
+
privateVulnerabilityReporting: reporting,
|
|
703
|
+
codeScanningDefaultSetup: codeScanning === void 0 ? void 0 : codeScanning === "configured"
|
|
704
|
+
};
|
|
705
|
+
return Object.fromEntries(Object.entries(found).filter(([, v]) => v !== void 0));
|
|
706
|
+
}
|
|
707
|
+
// src/capabilities/teams.ts
|
|
708
|
+
/** Turns a team entry into slug and permission form; a bare slug gets `push`. */
|
|
454
709
|
function normalizeTeamEntry(entry) {
|
|
455
710
|
if (typeof entry === "string") return {
|
|
456
711
|
slug: entry,
|
|
@@ -458,6 +713,7 @@ function normalizeTeamEntry(entry) {
|
|
|
458
713
|
};
|
|
459
714
|
return entry;
|
|
460
715
|
}
|
|
716
|
+
/** Grants each team access to the repo and reports the outcome; throws when every team fails. */
|
|
461
717
|
async function syncTeams(client, repo, teams) {
|
|
462
718
|
const [org = "", name = ""] = repo.split("/", 2);
|
|
463
719
|
const normalized = teams.map(normalizeTeamEntry);
|
|
@@ -470,17 +726,132 @@ async function syncTeams(client, repo, teams) {
|
|
|
470
726
|
}
|
|
471
727
|
return `${succeeded} team${succeeded === 1 ? "" : "s"} synced`;
|
|
472
728
|
}
|
|
473
|
-
|
|
474
|
-
//#region src/capabilities/topics.ts
|
|
729
|
+
// src/capabilities/topics.ts
|
|
475
730
|
async function syncTopics(client, repo, topics) {
|
|
476
731
|
await client.topics.setTopics(repo, topics);
|
|
477
732
|
return `${topics.length} topics set`;
|
|
478
733
|
}
|
|
479
|
-
|
|
480
|
-
|
|
734
|
+
// src/capabilities/variables.ts
|
|
735
|
+
/** GitHub's rule for a variable name: letters, digits and underscores, not starting with a digit or `GITHUB_`. */
|
|
736
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
737
|
+
/** Longest name GitHub accepts. */
|
|
738
|
+
const MAX_NAME = 256;
|
|
739
|
+
function assertNames(names) {
|
|
740
|
+
for (const name of names) if (!NAME.test(name) || name.length > MAX_NAME || name.toUpperCase().startsWith("GITHUB_")) throw new Error(`invalid variable name ${JSON.stringify(name)}: use letters, digits and underscores (at most ${MAX_NAME}), not starting with a digit or GITHUB_`);
|
|
741
|
+
}
|
|
742
|
+
/** GitHub treats variable names as case-insensitive, so every comparison goes through this. */
|
|
743
|
+
const key = (name) => name.toUpperCase();
|
|
744
|
+
/**
|
|
745
|
+
* Bring one scope's variables in line with the declaration: create what is missing, update what differs, leave what
|
|
746
|
+
* already matches. Variables that exist but are not declared are left alone, or deleted with `prune`, except an
|
|
747
|
+
* organization variable limited to selected repositories, which this client cannot manage and so is never deleted.
|
|
748
|
+
* Returns a one-line summary, naming the ones left so drift is visible. In a dry run it only describes the changes.
|
|
749
|
+
*/
|
|
750
|
+
async function reconcile(client, repo, scope, wanted, opts) {
|
|
751
|
+
assertNames(wanted.map((w) => w.name));
|
|
752
|
+
const existing = new Map((await client.variables.listVariables(repo, scope)).map((v) => [key(v.name), v]));
|
|
753
|
+
const counts = {
|
|
754
|
+
created: 0,
|
|
755
|
+
updated: 0,
|
|
756
|
+
unchanged: 0,
|
|
757
|
+
deleted: 0
|
|
758
|
+
};
|
|
759
|
+
for (const want of wanted) {
|
|
760
|
+
const have = existing.get(key(want.name));
|
|
761
|
+
const body = {
|
|
762
|
+
name: want.name,
|
|
763
|
+
value: want.value
|
|
764
|
+
};
|
|
765
|
+
if (want.visibility) body["visibility"] = want.visibility;
|
|
766
|
+
if (!have) {
|
|
767
|
+
if (opts.dryRun) {
|
|
768
|
+
counts.created++;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
await client.variables.createVariable(repo, scope, body);
|
|
773
|
+
counts.created++;
|
|
774
|
+
continue;
|
|
775
|
+
} catch (err) {
|
|
776
|
+
if (!(err instanceof ProviderApiError && (err.status === 409 || err.status === 422))) throw err;
|
|
777
|
+
}
|
|
778
|
+
} else if (have.value === want.value && (!want.visibility || have.visibility === want.visibility)) {
|
|
779
|
+
counts.unchanged++;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (!opts.dryRun) await client.variables.updateVariable(repo, scope, have?.name ?? want.name, body);
|
|
783
|
+
counts.updated++;
|
|
784
|
+
}
|
|
785
|
+
const declared = new Set(wanted.map((w) => key(w.name)));
|
|
786
|
+
const undeclared = [...existing.entries()].filter(([k]) => !declared.has(k)).map(([, v]) => v);
|
|
787
|
+
const unmanaged = undeclared.filter((v) => v.visibility === "selected");
|
|
788
|
+
if (opts.prune) for (const v of undeclared.filter((u) => u.visibility !== "selected")) {
|
|
789
|
+
if (!opts.dryRun) await client.variables.deleteVariable(repo, scope, v.name);
|
|
790
|
+
counts.deleted++;
|
|
791
|
+
}
|
|
792
|
+
const parts = opts.dryRun ? [
|
|
793
|
+
`would create ${counts.created}`,
|
|
794
|
+
`would update ${counts.updated}`,
|
|
795
|
+
`${counts.unchanged} unchanged`
|
|
796
|
+
] : [
|
|
797
|
+
`${counts.created} created`,
|
|
798
|
+
`${counts.updated} updated`,
|
|
799
|
+
`${counts.unchanged} unchanged`
|
|
800
|
+
];
|
|
801
|
+
if (counts.deleted > 0) parts.push(`${counts.deleted} ${opts.dryRun ? "would be deleted" : "deleted"}`);
|
|
802
|
+
const left = opts.prune ? unmanaged : undeclared;
|
|
803
|
+
if (left.length > 0) {
|
|
804
|
+
const why = opts.prune ? "limited to selected repositories, not managed" : "not declared, left alone";
|
|
805
|
+
parts.push(`${left.length} ${why}: ${left.map((v) => v.name).join(", ")}`);
|
|
806
|
+
}
|
|
807
|
+
return parts.join(", ");
|
|
808
|
+
}
|
|
809
|
+
/** Reconcile a repository's Actions variables. */
|
|
810
|
+
function syncVariables(client, repo, variables, opts = {}) {
|
|
811
|
+
return reconcile(client, repo, { kind: "repo" }, Object.entries(variables).map(([name, value]) => ({
|
|
812
|
+
name,
|
|
813
|
+
value
|
|
814
|
+
})), {
|
|
815
|
+
prune: opts.prune === true,
|
|
816
|
+
dryRun: opts.dryRun === true
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
/** Reconcile an organization's Actions variables. `repo` is only the coordinate the client is bound to. */
|
|
820
|
+
function syncOrgVariables(client, repo, org, variables, opts = {}) {
|
|
821
|
+
const wanted = Object.entries(variables).map(([name, v]) => ({
|
|
822
|
+
name,
|
|
823
|
+
value: v.value,
|
|
824
|
+
visibility: v.visibility
|
|
825
|
+
}));
|
|
826
|
+
return reconcile(client, repo, {
|
|
827
|
+
kind: "organization",
|
|
828
|
+
org
|
|
829
|
+
}, wanted, {
|
|
830
|
+
prune: opts.prune === true,
|
|
831
|
+
dryRun: opts.dryRun === true
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
// src/capabilities/source.ts
|
|
835
|
+
function toPullRequest(raw) {
|
|
836
|
+
return {
|
|
837
|
+
number: raw.number,
|
|
838
|
+
title: raw.title,
|
|
839
|
+
state: raw.state,
|
|
840
|
+
merged: raw.merged_at !== null,
|
|
841
|
+
branch: raw.head.ref,
|
|
842
|
+
headSha: raw.head.sha,
|
|
843
|
+
url: raw.html_url
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* The GitHub implementation of the `source` capability: repo settings, rulesets, branch protection, security
|
|
848
|
+
* features and local workflow files.
|
|
849
|
+
*/
|
|
481
850
|
var GitHubSource = class {
|
|
482
851
|
client;
|
|
852
|
+
/** Capability key, always `"source"`. */
|
|
483
853
|
key = "source";
|
|
854
|
+
/** Provider name, always `"github"`. */
|
|
484
855
|
providerName = "github";
|
|
485
856
|
owner;
|
|
486
857
|
name;
|
|
@@ -488,6 +859,7 @@ var GitHubSource = class {
|
|
|
488
859
|
repoRoot;
|
|
489
860
|
workflowDir;
|
|
490
861
|
orgClient;
|
|
862
|
+
userSyncClient;
|
|
491
863
|
baseUrl;
|
|
492
864
|
fetchImpl;
|
|
493
865
|
constructor(client, opts) {
|
|
@@ -496,15 +868,20 @@ var GitHubSource = class {
|
|
|
496
868
|
this.owner = owner;
|
|
497
869
|
this.name = name;
|
|
498
870
|
this.repo = opts.repo;
|
|
499
|
-
this.repoRoot = opts.repoRoot
|
|
500
|
-
|
|
871
|
+
this.repoRoot = findRootSync(opts.repoRoot, ".git", {
|
|
872
|
+
fallback: "start",
|
|
873
|
+
realpath: true
|
|
874
|
+
});
|
|
875
|
+
this.workflowDir = join(this.repoRoot, ".github", "workflows");
|
|
501
876
|
this.orgClient = opts.orgClient;
|
|
877
|
+
this.userSyncClient = opts.userSyncClient;
|
|
502
878
|
this.baseUrl = opts.baseUrl;
|
|
503
879
|
this.fetchImpl = opts.fetch;
|
|
504
880
|
}
|
|
505
881
|
async whoami() {
|
|
506
882
|
return { login: (await this.client.user.getCurrentUser()).login };
|
|
507
883
|
}
|
|
884
|
+
/** Returns the repo's owner, name and default branch. */
|
|
508
885
|
async getRepo() {
|
|
509
886
|
const data = await this.client.repos.getRepo(this.repo);
|
|
510
887
|
return {
|
|
@@ -513,27 +890,34 @@ var GitHubSource = class {
|
|
|
513
890
|
defaultBranch: data.default_branch
|
|
514
891
|
};
|
|
515
892
|
}
|
|
893
|
+
/** Lists the repo's rulesets. */
|
|
516
894
|
async listRulesets() {
|
|
517
895
|
return this.client.rulesets.listRulesets(this.repo);
|
|
518
896
|
}
|
|
897
|
+
/** Creates a ruleset from the given payload. */
|
|
519
898
|
async createRuleset(payload) {
|
|
520
899
|
return this.client.rulesets.createRuleset(this.repo, payload);
|
|
521
900
|
}
|
|
901
|
+
/** Updates the ruleset with the given id from the payload. */
|
|
522
902
|
async updateRuleset(id, payload) {
|
|
523
903
|
return this.client.rulesets.updateRuleset(this.repo, id, payload);
|
|
524
904
|
}
|
|
905
|
+
/** Updates the repo's settings. */
|
|
525
906
|
async updateRepoSettings(settings) {
|
|
526
907
|
await this.client.repos.updateRepo(this.repo, settings);
|
|
527
908
|
}
|
|
528
909
|
async protectBranch(branch, payload) {
|
|
529
910
|
await this.client.branches.protectBranch(this.repo, branch, payload);
|
|
530
911
|
}
|
|
912
|
+
/** Enables Dependabot vulnerability alerts. */
|
|
531
913
|
async enableVulnerabilityAlerts() {
|
|
532
914
|
await this.client.security.enableVulnerabilityAlerts(this.repo);
|
|
533
915
|
}
|
|
916
|
+
/** Enables Dependabot automated security fixes. */
|
|
534
917
|
async enableAutomatedSecurityFixes() {
|
|
535
918
|
await this.client.security.enableAutomatedSecurityFixes(this.repo);
|
|
536
919
|
}
|
|
920
|
+
/** Enables secret scanning. */
|
|
537
921
|
async enableSecretScanning() {
|
|
538
922
|
await this.client.security.enableSecretScanning(this.repo);
|
|
539
923
|
}
|
|
@@ -546,9 +930,11 @@ var GitHubSource = class {
|
|
|
546
930
|
async enableDependencyGraph() {
|
|
547
931
|
await this.client.security.enableDependencyGraph(this.repo);
|
|
548
932
|
}
|
|
933
|
+
/** Enables private vulnerability reporting. */
|
|
549
934
|
async enablePrivateVulnerabilityReporting() {
|
|
550
935
|
await this.client.security.enablePrivateVulnerabilityReporting(this.repo);
|
|
551
936
|
}
|
|
937
|
+
/** Lists the `.yml` and `.yaml` files in `.github/workflows`, sorted by name; empty when the folder is missing. */
|
|
552
938
|
async listWorkflowFiles() {
|
|
553
939
|
try {
|
|
554
940
|
return (await readdir(this.workflowDir)).filter((e) => e.endsWith(".yml") || e.endsWith(".yaml")).sort();
|
|
@@ -557,6 +943,7 @@ var GitHubSource = class {
|
|
|
557
943
|
throw err;
|
|
558
944
|
}
|
|
559
945
|
}
|
|
946
|
+
/** Reads a workflow file's text, or `null` when it does not exist. */
|
|
560
947
|
async readWorkflowFile(name) {
|
|
561
948
|
try {
|
|
562
949
|
return await readFile(join(this.workflowDir, name), "utf8");
|
|
@@ -565,10 +952,12 @@ var GitHubSource = class {
|
|
|
565
952
|
throw err;
|
|
566
953
|
}
|
|
567
954
|
}
|
|
955
|
+
/** Writes a workflow file, creating `.github/workflows` first when needed. */
|
|
568
956
|
async writeWorkflowFile(name, contents) {
|
|
569
957
|
await ensureDir(this.workflowDir);
|
|
570
958
|
await writeFile(join(this.workflowDir, name), contents, "utf8");
|
|
571
959
|
}
|
|
960
|
+
/** Removes a workflow file; a file that is already gone is not an error. */
|
|
572
961
|
async removeWorkflowFile(name) {
|
|
573
962
|
try {
|
|
574
963
|
await rm(join(this.workflowDir, name));
|
|
@@ -586,14 +975,77 @@ var GitHubSource = class {
|
|
|
586
975
|
return syncLabels(this.client, this.repo, canonical, stale);
|
|
587
976
|
}
|
|
588
977
|
async syncProperties(values) {
|
|
589
|
-
return syncProperties(this.client, this.repo, values);
|
|
978
|
+
return syncProperties(this.client, this.repo, values, this.orgClient ?? this.client);
|
|
979
|
+
}
|
|
980
|
+
async checkPropertyValues(values) {
|
|
981
|
+
return checkPropertyValues(this.orgClient ?? this.client, this.repo, values);
|
|
590
982
|
}
|
|
591
983
|
async syncTeams(teams) {
|
|
592
984
|
return syncTeams(this.orgClient ?? this.client, this.repo, teams);
|
|
593
985
|
}
|
|
986
|
+
async teamMembers(teamSlug, org = this.owner) {
|
|
987
|
+
return (await (this.orgClient ?? this.client).teams.members(org, teamSlug)).map((m) => ({
|
|
988
|
+
id: String(m.id),
|
|
989
|
+
login: m.login
|
|
990
|
+
}));
|
|
991
|
+
}
|
|
992
|
+
async readFile(path, repo = this.repo) {
|
|
993
|
+
return readRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, repo, path);
|
|
994
|
+
}
|
|
995
|
+
async writeFile(path, content, opts) {
|
|
996
|
+
return writeRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, opts.repo ?? this.repo, path, content, opts);
|
|
997
|
+
}
|
|
594
998
|
async syncTopics(topics) {
|
|
595
999
|
return syncTopics(this.client, this.repo, topics);
|
|
596
1000
|
}
|
|
1001
|
+
async syncVariables(variables, opts) {
|
|
1002
|
+
return syncVariables(this.client, this.repo, variables, opts);
|
|
1003
|
+
}
|
|
1004
|
+
async syncOrgVariables(org, variables, opts) {
|
|
1005
|
+
if (!this.orgClient) throw new Error("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
|
|
1006
|
+
return syncOrgVariables(this.orgClient, this.repo, org, variables, opts);
|
|
1007
|
+
}
|
|
1008
|
+
async listVariables() {
|
|
1009
|
+
const found = await this.client.variables.listVariables(this.repo, { kind: "repo" });
|
|
1010
|
+
return Object.fromEntries(found.map((v) => [v.name, v.value]));
|
|
1011
|
+
}
|
|
1012
|
+
async listOrgVariables(org) {
|
|
1013
|
+
if (!this.orgClient) throw new MissingTokenError("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
|
|
1014
|
+
const found = await this.orgClient.variables.listVariables(this.repo, {
|
|
1015
|
+
kind: "organization",
|
|
1016
|
+
org
|
|
1017
|
+
});
|
|
1018
|
+
return Object.fromEntries(found.map((v) => [v.name, {
|
|
1019
|
+
value: v.value,
|
|
1020
|
+
visibility: v.visibility ?? "all"
|
|
1021
|
+
}]));
|
|
1022
|
+
}
|
|
1023
|
+
async getRepoInfo() {
|
|
1024
|
+
return readRepoInfo(this.client, this.repo);
|
|
1025
|
+
}
|
|
1026
|
+
async getProperties() {
|
|
1027
|
+
const found = await this.client.properties.getValues(this.repo);
|
|
1028
|
+
return Object.fromEntries(found.flatMap((p) => p.value === null ? [] : [[p.property_name, p.value]]));
|
|
1029
|
+
}
|
|
1030
|
+
async listLabels() {
|
|
1031
|
+
return (await this.client.labels.listLabels(this.repo)).map((l) => ({
|
|
1032
|
+
name: l.name,
|
|
1033
|
+
color: l.color,
|
|
1034
|
+
description: l.description ?? ""
|
|
1035
|
+
}));
|
|
1036
|
+
}
|
|
1037
|
+
async listTeamAccess() {
|
|
1038
|
+
return (await (this.orgClient ?? this.client).teams.listForRepo(this.repo)).map((t) => ({
|
|
1039
|
+
slug: t.slug,
|
|
1040
|
+
permission: t.permission
|
|
1041
|
+
}));
|
|
1042
|
+
}
|
|
1043
|
+
async getRulesetDetail(id) {
|
|
1044
|
+
return toRulesetDetail(await this.client.rulesets.getRuleset(this.repo, id));
|
|
1045
|
+
}
|
|
1046
|
+
async getSecurity() {
|
|
1047
|
+
return readSecurity(this.client, this.repo);
|
|
1048
|
+
}
|
|
597
1049
|
async syncDescription(description) {
|
|
598
1050
|
await this.client.repos.updateRepo(this.repo, { description });
|
|
599
1051
|
return "description updated";
|
|
@@ -603,15 +1055,27 @@ var GitHubSource = class {
|
|
|
603
1055
|
return "homepage updated";
|
|
604
1056
|
}
|
|
605
1057
|
async getPullRequest(number, repo) {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1058
|
+
return toPullRequest(await this.client.pulls.getPullRequest(repo ?? this.repo, number));
|
|
1059
|
+
}
|
|
1060
|
+
async listPullRequests(state, repo) {
|
|
1061
|
+
return (await this.client.pulls.listPullRequests(repo ?? this.repo, state)).map(toPullRequest);
|
|
1062
|
+
}
|
|
1063
|
+
async listBranches(repo) {
|
|
1064
|
+
return (await this.client.branches.listBranches(repo ?? this.repo)).map((b) => ({
|
|
1065
|
+
name: b.name,
|
|
1066
|
+
sha: b.commit.sha
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
async deleteBranch(name, repo) {
|
|
1070
|
+
try {
|
|
1071
|
+
await this.client.branches.removeBranch(repo ?? this.repo, name);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("does not exist")) return;
|
|
1074
|
+
throw err;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
async listPullRequestCommits(number, repo) {
|
|
1078
|
+
return (await this.client.pulls.listCommits(repo ?? this.repo, number)).map((commit) => commit.sha);
|
|
615
1079
|
}
|
|
616
1080
|
async configurePages(config, token) {
|
|
617
1081
|
const pagesClient = createGitHubClient$1({
|
|
@@ -650,18 +1114,8 @@ async function ensureDir(path) {
|
|
|
650
1114
|
throw err;
|
|
651
1115
|
}
|
|
652
1116
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* `verifyToken` — plugin-level export used by `holocron auth set` +
|
|
657
|
-
* `holocron auth check`. Hits `GET /user` and translates the response
|
|
658
|
-
* into the normalized `VerifyTokenResult` shape.
|
|
659
|
-
*
|
|
660
|
-
* Kept as a standalone function (not a capability method) so the auth
|
|
661
|
-
* command can call it without initializing the full plugin — plugin
|
|
662
|
-
* construction requires an already-resolved token, which is exactly
|
|
663
|
-
* what we don't have yet at bootstrap time.
|
|
664
|
-
*/
|
|
1117
|
+
// src/verify-token.ts
|
|
1118
|
+
/** Checks a token against `GET /user` and returns who it belongs to, or why it failed. */
|
|
665
1119
|
async function verifyToken(token, opts = {}) {
|
|
666
1120
|
const client = createGitHubClient({
|
|
667
1121
|
token,
|
|
@@ -669,49 +1123,57 @@ async function verifyToken(token, opts = {}) {
|
|
|
669
1123
|
fetch: opts.fetch
|
|
670
1124
|
});
|
|
671
1125
|
try {
|
|
672
|
-
const me = await client.user.
|
|
1126
|
+
const { user: me, expiresAt } = await client.user.getCurrentUserWithExpiry();
|
|
673
1127
|
return {
|
|
674
1128
|
ok: true,
|
|
675
|
-
subject: `user @ ${me.login ?? me.email ?? "unknown"}
|
|
1129
|
+
subject: `user @ ${me.login ?? me.email ?? "unknown"}`,
|
|
1130
|
+
...expiresAt ? { expiresAt } : {}
|
|
676
1131
|
};
|
|
677
1132
|
} catch (err) {
|
|
678
1133
|
return {
|
|
679
1134
|
ok: false,
|
|
680
|
-
message:
|
|
1135
|
+
message: errorMessage(err)
|
|
681
1136
|
};
|
|
682
1137
|
}
|
|
683
1138
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
function source(ctx, orgClient) {
|
|
1139
|
+
// src/index.ts
|
|
1140
|
+
/** Builds the `source` capability; the optional clients cover org-level and user-sync access. */
|
|
1141
|
+
function source(ctx, orgClient, userSyncClient) {
|
|
687
1142
|
return new GitHubSource(ctx.client, {
|
|
688
1143
|
repo: ctx.repo,
|
|
689
1144
|
repoRoot: ctx.repoRoot,
|
|
690
1145
|
orgClient,
|
|
1146
|
+
userSyncClient,
|
|
691
1147
|
baseUrl: ctx.options.baseUrl,
|
|
692
1148
|
fetch: ctx.options.fetch
|
|
693
1149
|
});
|
|
694
1150
|
}
|
|
695
|
-
|
|
696
|
-
|
|
1151
|
+
/** Builds the `secrets` capability; the optional client handles organization secrets. */
|
|
1152
|
+
function secrets(ctx, orgClient) {
|
|
1153
|
+
return new GitHubSecrets(ctx.client, { repo: ctx.repo }, orgClient);
|
|
697
1154
|
}
|
|
1155
|
+
/** Builds the `environments` capability. */
|
|
698
1156
|
function environments(ctx) {
|
|
699
1157
|
return new GitHubEnvironments(ctx.client, { repo: ctx.repo });
|
|
700
1158
|
}
|
|
1159
|
+
/** Builds the `ci` capability. */
|
|
701
1160
|
function ci(ctx) {
|
|
702
1161
|
return new GitHubCi(ctx.client, { repo: ctx.repo });
|
|
703
1162
|
}
|
|
1163
|
+
/** Builds the `issues` capability, passing the lifecycle labels when configured. */
|
|
704
1164
|
function issues(ctx) {
|
|
705
1165
|
const opts = { repo: ctx.repo };
|
|
706
1166
|
if (ctx.options.labels !== void 0) opts.labels = ctx.options.labels;
|
|
707
1167
|
return new GitHubIssues(ctx.client, opts);
|
|
708
1168
|
}
|
|
1169
|
+
/** Creates the plugin, resolving a token per capability when it is first used. */
|
|
709
1170
|
function createPlugin(options) {
|
|
710
1171
|
function makeCtx(resolver) {
|
|
1172
|
+
const token = resolver(options);
|
|
711
1173
|
return {
|
|
712
1174
|
options,
|
|
713
1175
|
client: createGitHubClient$1({
|
|
714
|
-
token
|
|
1176
|
+
token,
|
|
715
1177
|
baseUrl: options.baseUrl,
|
|
716
1178
|
fetch: options.fetch
|
|
717
1179
|
}),
|
|
@@ -719,10 +1181,45 @@ function createPlugin(options) {
|
|
|
719
1181
|
repoRoot: options.repoRoot ?? process.cwd()
|
|
720
1182
|
};
|
|
721
1183
|
}
|
|
1184
|
+
/** A token, or undefined when that feature token is simply not configured. */
|
|
1185
|
+
function tryResolve(resolver) {
|
|
1186
|
+
try {
|
|
1187
|
+
return resolver(options);
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
if (err instanceof AuthError) return void 0;
|
|
1190
|
+
throw err;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Organization secrets need an org-resource-owner token (`github.secrets`);
|
|
1195
|
+
* repo and environment secrets need `github.admin`. Each scope prefers its own
|
|
1196
|
+
* token and falls back to the other, so a setup with only one of them keeps
|
|
1197
|
+
* working, and an explicit `--token` serves both. With neither configured this
|
|
1198
|
+
* fails exactly as the admin-token capabilities do.
|
|
1199
|
+
*/
|
|
1200
|
+
function makeSecrets() {
|
|
1201
|
+
const secretsToken = tryResolve(resolveSecretsToken);
|
|
1202
|
+
const adminToken = tryResolve(resolveAdminToken);
|
|
1203
|
+
if (!secretsToken && !adminToken) resolveAdminToken(options);
|
|
1204
|
+
const clientFor = (token) => createGitHubClient$1({
|
|
1205
|
+
token,
|
|
1206
|
+
baseUrl: options.baseUrl,
|
|
1207
|
+
fetch: options.fetch
|
|
1208
|
+
});
|
|
1209
|
+
const repoClient = clientFor(adminToken ?? secretsToken);
|
|
1210
|
+
const orgClient = clientFor(secretsToken ?? adminToken);
|
|
1211
|
+
return secrets({
|
|
1212
|
+
options,
|
|
1213
|
+
client: repoClient,
|
|
1214
|
+
repo: options.repo,
|
|
1215
|
+
repoRoot: options.repoRoot ?? process.cwd()
|
|
1216
|
+
}, orgClient);
|
|
1217
|
+
}
|
|
722
1218
|
function makeOrgClient() {
|
|
723
1219
|
try {
|
|
1220
|
+
const token = resolveOrgToken(options);
|
|
724
1221
|
return createGitHubClient$1({
|
|
725
|
-
token
|
|
1222
|
+
token,
|
|
726
1223
|
baseUrl: options.baseUrl,
|
|
727
1224
|
fetch: options.fetch
|
|
728
1225
|
});
|
|
@@ -730,12 +1227,31 @@ function createPlugin(options) {
|
|
|
730
1227
|
return;
|
|
731
1228
|
}
|
|
732
1229
|
}
|
|
1230
|
+
/** The token for the user-sync files; undefined when it is not configured, and the org token is used instead. */
|
|
1231
|
+
function makeUserSyncClient() {
|
|
1232
|
+
const token = tryResolve(resolveUserSyncToken);
|
|
1233
|
+
return token ? createGitHubClient$1({
|
|
1234
|
+
token,
|
|
1235
|
+
baseUrl: options.baseUrl,
|
|
1236
|
+
fetch: options.fetch
|
|
1237
|
+
}) : void 0;
|
|
1238
|
+
}
|
|
733
1239
|
return {
|
|
734
1240
|
name: "@theholocron/holocron-plugin-github",
|
|
1241
|
+
probes: { connection: connectionProbe({
|
|
1242
|
+
provider: "GitHub",
|
|
1243
|
+
url: options.baseUrl ?? "https://api.github.com",
|
|
1244
|
+
fetch: options.fetch,
|
|
1245
|
+
token: () => resolveReadToken(options),
|
|
1246
|
+
verify: (token) => verifyToken(token, {
|
|
1247
|
+
baseUrl: options.baseUrl,
|
|
1248
|
+
fetch: options.fetch
|
|
1249
|
+
})
|
|
1250
|
+
}) },
|
|
735
1251
|
capabilities: {
|
|
736
|
-
source: () => source(makeCtx(resolveAdminToken), makeOrgClient()),
|
|
1252
|
+
source: () => source(makeCtx(resolveAdminToken), makeOrgClient(), makeUserSyncClient()),
|
|
737
1253
|
ci: () => ci(makeCtx(resolveReadToken)),
|
|
738
|
-
secrets: () =>
|
|
1254
|
+
secrets: () => makeSecrets(),
|
|
739
1255
|
environments: () => environments(makeCtx(resolveAdminToken)),
|
|
740
1256
|
issues: () => issues(makeCtx(resolveIssuesToken))
|
|
741
1257
|
}
|
|
@@ -746,5 +1262,16 @@ function createPlugin(options) {
|
|
|
746
1262
|
* is supplied or the supplied token is rejected.
|
|
747
1263
|
*/
|
|
748
1264
|
const AUTH_HINT = "generate fine-grained Personal Access Tokens at https://github.com/settings/tokens (one per feature — see docs/tokens.md for required scopes per operation), then run: holocron auth set github.<feature> <PAT>";
|
|
749
|
-
|
|
750
|
-
|
|
1265
|
+
/** Every token this plugin takes, for `holocron doctor tokens`. */
|
|
1266
|
+
const tokens = [
|
|
1267
|
+
resolveReadToken,
|
|
1268
|
+
resolveIssuesToken,
|
|
1269
|
+
resolveSyncToken,
|
|
1270
|
+
resolveReleaseToken,
|
|
1271
|
+
resolveAdminToken,
|
|
1272
|
+
resolveSecretsToken,
|
|
1273
|
+
resolveDeployToken,
|
|
1274
|
+
resolveUserSyncToken,
|
|
1275
|
+
resolveOrgToken
|
|
1276
|
+
].map((resolver) => resolver.token);
|
|
1277
|
+
export { AUTH_HINT, AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubSecrets, GitHubSource, ci, createFeatureResolver, createGitHubClient, createPlugin, encryptSecret, environments, issues, normalizeTeamEntry, resolveAdminToken, resolveDeployToken, resolveIssuesToken, resolveOrgToken, resolveReadToken, resolveReleaseToken, resolveSecretsToken, resolveSyncToken, resolveUserSyncToken, secrets, source, syncLabels, syncTeams, tokens, verifyToken };
|