@theholocron/holocron-plugin-github 5.0.0-alpha.28 → 5.0.0-alpha.280

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
- //#region src/auth.ts
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
- //#endregion
36
- //#region src/capabilities/ci.ts
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
- //#endregion
81
- //#region src/capabilities/environments.ts
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
- //#endregion
123
- //#region src/capabilities/issues.ts
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
- //#endregion
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
- //#endregion
373
- //#region src/capabilities/secrets.ts
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
- constructor(client, opts) {
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.client.secrets.listSecrets(this.opts.repo, toGHScope(scope));
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 pk = await this.client.secrets.getPublicKey(this.opts.repo, ghScope);
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 this.client.secrets.putSecret(this.opts.repo, ghScope, name, body);
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.client.secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
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
- //#endregion
408
- //#region src/capabilities/labels.ts
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,149 @@ async function syncLabels(client, repo, canonical, stale) {
443
533
  }
444
534
  return `${created} created, ${updated} updated, ${deleted} deleted`;
445
535
  }
446
- //#endregion
447
- //#region src/capabilities/properties.ts
536
+ // src/capabilities/rejected-properties.ts
537
+ /** The values `repo setup` sends for `true_false` properties; anything else is rejected. */
538
+ const BOOLEANS = /* @__PURE__ */ new Set(["true", "false"]);
539
+ /**
540
+ * What is wrong with `values` when compared with the organization's property definitions, one sentence per
541
+ * problem, in the order the properties are given: a property the organization does not define, a value a
542
+ * `single_select` or `multi_select` property does not allow, or a `true_false` property that is not `true` or
543
+ * `false`. A `string` or `url` property has nothing to check here. Empty when every value fits its definition.
544
+ */
545
+ function rejectedProperties(values, schema) {
546
+ const byName = new Map(schema.map((property) => [property.property_name, property]));
547
+ const problems = [];
548
+ for (const [name, value] of Object.entries(values)) {
549
+ const definition = byName.get(name);
550
+ if (!definition) {
551
+ problems.push(`${name} is not a custom property of the organization`);
552
+ continue;
553
+ }
554
+ const sent = Array.isArray(value) ? value : [value];
555
+ if (definition.value_type === "true_false") {
556
+ for (const item of sent.filter((item) => !BOOLEANS.has(item))) problems.push(`${name} is "${item}", but it takes only true or false`);
557
+ continue;
558
+ }
559
+ const allowed = definition.allowed_values ?? [];
560
+ if (definition.value_type !== "single_select" && definition.value_type !== "multi_select") continue;
561
+ 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(", ")})`);
562
+ }
563
+ return problems;
564
+ }
565
+ // src/capabilities/properties.ts
566
+ /** GitHub's own message from a 422 body (`{"message": ..., "errors": [...]}`), or the start of the body when it is not JSON. */
567
+ function githubMessage(details) {
568
+ const raw = String(details ?? "").trim();
569
+ try {
570
+ const body = JSON.parse(raw);
571
+ const errors = Array.isArray(body.errors) ? body.errors.map((e) => JSON.stringify(e)).join(", ") : "";
572
+ const message = typeof body.message === "string" ? body.message : raw;
573
+ return errors ? `${message}: ${errors}` : message;
574
+ } catch {
575
+ return raw.slice(0, 300);
576
+ }
577
+ }
578
+ /** The organization's property definitions, or `undefined` when they cannot be read (permission, an older client). */
579
+ async function orgSchema(client, repo) {
580
+ try {
581
+ return await client.properties.orgSchema(repo.split("/")[0]);
582
+ } catch {
583
+ return;
584
+ }
585
+ }
586
+ /**
587
+ * Sets the repo's custom properties. When GitHub answers 422 the error says which value it refused: the values
588
+ * are compared with the organization's property definitions (read-only), and GitHub's own message is added.
589
+ * One refused value fails the whole request, so none of the properties is set.
590
+ */
448
591
  async function syncProperties(client, repo, values) {
449
- await client.properties.setProperties(repo, values);
592
+ try {
593
+ await client.properties.setProperties(repo, values);
594
+ } catch (err) {
595
+ if (!(err instanceof ProviderApiError$1) || err.status !== 422) throw err;
596
+ const schema = await orgSchema(client, repo);
597
+ const problems = schema ? rejectedProperties(values, schema) : [];
598
+ const said = githubMessage(err.details);
599
+ 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}`, { cause: err });
600
+ }
450
601
  return `${Object.keys(values).length} properties set`;
451
602
  }
452
- //#endregion
453
- //#region src/capabilities/teams.ts
603
+ // src/capabilities/read-repo.ts
604
+ /** The settings `repo setup` writes, which `GET /repos/{repo}` reports under the same names. */
605
+ const SETTING_KEYS = [
606
+ "allow_squash_merge",
607
+ "allow_merge_commit",
608
+ "allow_rebase_merge",
609
+ "allow_auto_merge",
610
+ "allow_update_branch",
611
+ "delete_branch_on_merge",
612
+ "has_issues",
613
+ "has_discussions",
614
+ "has_projects",
615
+ "has_wiki"
616
+ ];
617
+ /** The repository's description, homepage, default branch, topics and settings, from one read. */
618
+ async function readRepoInfo(client, repo) {
619
+ const data = await client.repos.getRepo(repo);
620
+ const settings = { default_branch: data.default_branch };
621
+ for (const key of SETTING_KEYS) {
622
+ const value = data[key];
623
+ if (value !== void 0) settings[key] = value;
624
+ }
625
+ return {
626
+ description: data.description ?? null,
627
+ homepage: data.homepage ?? null,
628
+ defaultBranch: data.default_branch,
629
+ topics: data.topics ?? [],
630
+ settings
631
+ };
632
+ }
633
+ /** A ruleset reduced to what `repo setup` writes: its enforcement, rule types and required status checks. */
634
+ function toRulesetDetail(data) {
635
+ const rules = data.rules ?? [];
636
+ const checks = rules.find((r) => r.type === "required_status_checks")?.parameters?.required_status_checks;
637
+ return {
638
+ name: data.name,
639
+ enforcement: data.enforcement,
640
+ ruleTypes: rules.map((r) => r.type),
641
+ requiredChecks: Array.isArray(checks) ? checks.map((c) => String(c.context)) : []
642
+ };
643
+ }
644
+ /** The value, or `undefined` when GitHub refuses the read (permission, plan, feature absent). Other errors still throw. */
645
+ async function readable(read) {
646
+ try {
647
+ return await read();
648
+ } catch (err) {
649
+ if (err instanceof ProviderApiError$1) return void 0;
650
+ throw err;
651
+ }
652
+ }
653
+ /** Which security features are on. One GitHub refuses to report is left out, not reported as off. */
654
+ async function readSecurity(client, repo) {
655
+ const [data, alerts, fixes, reporting, codeScanning] = await Promise.all([
656
+ readable(() => client.repos.getRepo(repo)),
657
+ readable(() => client.security.vulnerabilityAlertsEnabled(repo)),
658
+ readable(() => client.security.automatedSecurityFixesEnabled(repo)),
659
+ readable(() => client.security.privateVulnerabilityReportingEnabled(repo)),
660
+ readable(() => client.security.codeScanningDefaultSetupState(repo))
661
+ ]);
662
+ const analysis = data?.security_and_analysis;
663
+ const on = (feature) => {
664
+ const status = analysis?.[feature]?.status;
665
+ return status === void 0 ? void 0 : status === "enabled";
666
+ };
667
+ const found = {
668
+ vulnerabilityAlerts: alerts,
669
+ automatedSecurityFixes: fixes,
670
+ secretScanning: on("secret_scanning"),
671
+ secretScanningPushProtection: on("secret_scanning_push_protection"),
672
+ privateVulnerabilityReporting: reporting,
673
+ codeScanningDefaultSetup: codeScanning === void 0 ? void 0 : codeScanning === "configured"
674
+ };
675
+ return Object.fromEntries(Object.entries(found).filter(([, v]) => v !== void 0));
676
+ }
677
+ // src/capabilities/teams.ts
678
+ /** Turns a team entry into slug and permission form; a bare slug gets `push`. */
454
679
  function normalizeTeamEntry(entry) {
455
680
  if (typeof entry === "string") return {
456
681
  slug: entry,
@@ -458,6 +683,7 @@ function normalizeTeamEntry(entry) {
458
683
  };
459
684
  return entry;
460
685
  }
686
+ /** Grants each team access to the repo and reports the outcome; throws when every team fails. */
461
687
  async function syncTeams(client, repo, teams) {
462
688
  const [org = "", name = ""] = repo.split("/", 2);
463
689
  const normalized = teams.map(normalizeTeamEntry);
@@ -470,17 +696,132 @@ async function syncTeams(client, repo, teams) {
470
696
  }
471
697
  return `${succeeded} team${succeeded === 1 ? "" : "s"} synced`;
472
698
  }
473
- //#endregion
474
- //#region src/capabilities/topics.ts
699
+ // src/capabilities/topics.ts
475
700
  async function syncTopics(client, repo, topics) {
476
701
  await client.topics.setTopics(repo, topics);
477
702
  return `${topics.length} topics set`;
478
703
  }
479
- //#endregion
480
- //#region src/capabilities/source.ts
704
+ // src/capabilities/variables.ts
705
+ /** GitHub's rule for a variable name: letters, digits and underscores, not starting with a digit or `GITHUB_`. */
706
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
707
+ /** Longest name GitHub accepts. */
708
+ const MAX_NAME = 256;
709
+ function assertNames(names) {
710
+ 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_`);
711
+ }
712
+ /** GitHub treats variable names as case-insensitive, so every comparison goes through this. */
713
+ const key = (name) => name.toUpperCase();
714
+ /**
715
+ * Bring one scope's variables in line with the declaration: create what is missing, update what differs, leave what
716
+ * already matches. Variables that exist but are not declared are left alone, or deleted with `prune`, except an
717
+ * organization variable limited to selected repositories, which this client cannot manage and so is never deleted.
718
+ * Returns a one-line summary, naming the ones left so drift is visible. In a dry run it only describes the changes.
719
+ */
720
+ async function reconcile(client, repo, scope, wanted, opts) {
721
+ assertNames(wanted.map((w) => w.name));
722
+ const existing = new Map((await client.variables.listVariables(repo, scope)).map((v) => [key(v.name), v]));
723
+ const counts = {
724
+ created: 0,
725
+ updated: 0,
726
+ unchanged: 0,
727
+ deleted: 0
728
+ };
729
+ for (const want of wanted) {
730
+ const have = existing.get(key(want.name));
731
+ const body = {
732
+ name: want.name,
733
+ value: want.value
734
+ };
735
+ if (want.visibility) body["visibility"] = want.visibility;
736
+ if (!have) {
737
+ if (opts.dryRun) {
738
+ counts.created++;
739
+ continue;
740
+ }
741
+ try {
742
+ await client.variables.createVariable(repo, scope, body);
743
+ counts.created++;
744
+ continue;
745
+ } catch (err) {
746
+ if (!(err instanceof ProviderApiError && (err.status === 409 || err.status === 422))) throw err;
747
+ }
748
+ } else if (have.value === want.value && (!want.visibility || have.visibility === want.visibility)) {
749
+ counts.unchanged++;
750
+ continue;
751
+ }
752
+ if (!opts.dryRun) await client.variables.updateVariable(repo, scope, have?.name ?? want.name, body);
753
+ counts.updated++;
754
+ }
755
+ const declared = new Set(wanted.map((w) => key(w.name)));
756
+ const undeclared = [...existing.entries()].filter(([k]) => !declared.has(k)).map(([, v]) => v);
757
+ const unmanaged = undeclared.filter((v) => v.visibility === "selected");
758
+ if (opts.prune) for (const v of undeclared.filter((u) => u.visibility !== "selected")) {
759
+ if (!opts.dryRun) await client.variables.deleteVariable(repo, scope, v.name);
760
+ counts.deleted++;
761
+ }
762
+ const parts = opts.dryRun ? [
763
+ `would create ${counts.created}`,
764
+ `would update ${counts.updated}`,
765
+ `${counts.unchanged} unchanged`
766
+ ] : [
767
+ `${counts.created} created`,
768
+ `${counts.updated} updated`,
769
+ `${counts.unchanged} unchanged`
770
+ ];
771
+ if (counts.deleted > 0) parts.push(`${counts.deleted} ${opts.dryRun ? "would be deleted" : "deleted"}`);
772
+ const left = opts.prune ? unmanaged : undeclared;
773
+ if (left.length > 0) {
774
+ const why = opts.prune ? "limited to selected repositories, not managed" : "not declared, left alone";
775
+ parts.push(`${left.length} ${why}: ${left.map((v) => v.name).join(", ")}`);
776
+ }
777
+ return parts.join(", ");
778
+ }
779
+ /** Reconcile a repository's Actions variables. */
780
+ function syncVariables(client, repo, variables, opts = {}) {
781
+ return reconcile(client, repo, { kind: "repo" }, Object.entries(variables).map(([name, value]) => ({
782
+ name,
783
+ value
784
+ })), {
785
+ prune: opts.prune === true,
786
+ dryRun: opts.dryRun === true
787
+ });
788
+ }
789
+ /** Reconcile an organization's Actions variables. `repo` is only the coordinate the client is bound to. */
790
+ function syncOrgVariables(client, repo, org, variables, opts = {}) {
791
+ const wanted = Object.entries(variables).map(([name, v]) => ({
792
+ name,
793
+ value: v.value,
794
+ visibility: v.visibility
795
+ }));
796
+ return reconcile(client, repo, {
797
+ kind: "organization",
798
+ org
799
+ }, wanted, {
800
+ prune: opts.prune === true,
801
+ dryRun: opts.dryRun === true
802
+ });
803
+ }
804
+ // src/capabilities/source.ts
805
+ function toPullRequest(raw) {
806
+ return {
807
+ number: raw.number,
808
+ title: raw.title,
809
+ state: raw.state,
810
+ merged: raw.merged_at !== null,
811
+ branch: raw.head.ref,
812
+ headSha: raw.head.sha,
813
+ url: raw.html_url
814
+ };
815
+ }
816
+ /**
817
+ * The GitHub implementation of the `source` capability: repo settings, rulesets, branch protection, security
818
+ * features and local workflow files.
819
+ */
481
820
  var GitHubSource = class {
482
821
  client;
822
+ /** Capability key, always `"source"`. */
483
823
  key = "source";
824
+ /** Provider name, always `"github"`. */
484
825
  providerName = "github";
485
826
  owner;
486
827
  name;
@@ -488,6 +829,7 @@ var GitHubSource = class {
488
829
  repoRoot;
489
830
  workflowDir;
490
831
  orgClient;
832
+ userSyncClient;
491
833
  baseUrl;
492
834
  fetchImpl;
493
835
  constructor(client, opts) {
@@ -496,15 +838,20 @@ var GitHubSource = class {
496
838
  this.owner = owner;
497
839
  this.name = name;
498
840
  this.repo = opts.repo;
499
- this.repoRoot = opts.repoRoot;
500
- this.workflowDir = join(opts.repoRoot, ".github", "workflows");
841
+ this.repoRoot = findRootSync(opts.repoRoot, ".git", {
842
+ fallback: "start",
843
+ realpath: true
844
+ });
845
+ this.workflowDir = join(this.repoRoot, ".github", "workflows");
501
846
  this.orgClient = opts.orgClient;
847
+ this.userSyncClient = opts.userSyncClient;
502
848
  this.baseUrl = opts.baseUrl;
503
849
  this.fetchImpl = opts.fetch;
504
850
  }
505
851
  async whoami() {
506
852
  return { login: (await this.client.user.getCurrentUser()).login };
507
853
  }
854
+ /** Returns the repo's owner, name and default branch. */
508
855
  async getRepo() {
509
856
  const data = await this.client.repos.getRepo(this.repo);
510
857
  return {
@@ -513,27 +860,34 @@ var GitHubSource = class {
513
860
  defaultBranch: data.default_branch
514
861
  };
515
862
  }
863
+ /** Lists the repo's rulesets. */
516
864
  async listRulesets() {
517
865
  return this.client.rulesets.listRulesets(this.repo);
518
866
  }
867
+ /** Creates a ruleset from the given payload. */
519
868
  async createRuleset(payload) {
520
869
  return this.client.rulesets.createRuleset(this.repo, payload);
521
870
  }
871
+ /** Updates the ruleset with the given id from the payload. */
522
872
  async updateRuleset(id, payload) {
523
873
  return this.client.rulesets.updateRuleset(this.repo, id, payload);
524
874
  }
875
+ /** Updates the repo's settings. */
525
876
  async updateRepoSettings(settings) {
526
877
  await this.client.repos.updateRepo(this.repo, settings);
527
878
  }
528
879
  async protectBranch(branch, payload) {
529
880
  await this.client.branches.protectBranch(this.repo, branch, payload);
530
881
  }
882
+ /** Enables Dependabot vulnerability alerts. */
531
883
  async enableVulnerabilityAlerts() {
532
884
  await this.client.security.enableVulnerabilityAlerts(this.repo);
533
885
  }
886
+ /** Enables Dependabot automated security fixes. */
534
887
  async enableAutomatedSecurityFixes() {
535
888
  await this.client.security.enableAutomatedSecurityFixes(this.repo);
536
889
  }
890
+ /** Enables secret scanning. */
537
891
  async enableSecretScanning() {
538
892
  await this.client.security.enableSecretScanning(this.repo);
539
893
  }
@@ -546,9 +900,11 @@ var GitHubSource = class {
546
900
  async enableDependencyGraph() {
547
901
  await this.client.security.enableDependencyGraph(this.repo);
548
902
  }
903
+ /** Enables private vulnerability reporting. */
549
904
  async enablePrivateVulnerabilityReporting() {
550
905
  await this.client.security.enablePrivateVulnerabilityReporting(this.repo);
551
906
  }
907
+ /** Lists the `.yml` and `.yaml` files in `.github/workflows`, sorted by name; empty when the folder is missing. */
552
908
  async listWorkflowFiles() {
553
909
  try {
554
910
  return (await readdir(this.workflowDir)).filter((e) => e.endsWith(".yml") || e.endsWith(".yaml")).sort();
@@ -557,6 +913,7 @@ var GitHubSource = class {
557
913
  throw err;
558
914
  }
559
915
  }
916
+ /** Reads a workflow file's text, or `null` when it does not exist. */
560
917
  async readWorkflowFile(name) {
561
918
  try {
562
919
  return await readFile(join(this.workflowDir, name), "utf8");
@@ -565,10 +922,12 @@ var GitHubSource = class {
565
922
  throw err;
566
923
  }
567
924
  }
925
+ /** Writes a workflow file, creating `.github/workflows` first when needed. */
568
926
  async writeWorkflowFile(name, contents) {
569
927
  await ensureDir(this.workflowDir);
570
928
  await writeFile(join(this.workflowDir, name), contents, "utf8");
571
929
  }
930
+ /** Removes a workflow file; a file that is already gone is not an error. */
572
931
  async removeWorkflowFile(name) {
573
932
  try {
574
933
  await rm(join(this.workflowDir, name));
@@ -591,9 +950,69 @@ var GitHubSource = class {
591
950
  async syncTeams(teams) {
592
951
  return syncTeams(this.orgClient ?? this.client, this.repo, teams);
593
952
  }
953
+ async teamMembers(teamSlug, org = this.owner) {
954
+ return (await (this.orgClient ?? this.client).teams.members(org, teamSlug)).map((m) => ({
955
+ id: String(m.id),
956
+ login: m.login
957
+ }));
958
+ }
959
+ async readFile(path, repo = this.repo) {
960
+ return readRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, repo, path);
961
+ }
962
+ async writeFile(path, content, opts) {
963
+ return writeRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, opts.repo ?? this.repo, path, content, opts);
964
+ }
594
965
  async syncTopics(topics) {
595
966
  return syncTopics(this.client, this.repo, topics);
596
967
  }
968
+ async syncVariables(variables, opts) {
969
+ return syncVariables(this.client, this.repo, variables, opts);
970
+ }
971
+ async syncOrgVariables(org, variables, opts) {
972
+ if (!this.orgClient) throw new Error("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
973
+ return syncOrgVariables(this.orgClient, this.repo, org, variables, opts);
974
+ }
975
+ async listVariables() {
976
+ const found = await this.client.variables.listVariables(this.repo, { kind: "repo" });
977
+ return Object.fromEntries(found.map((v) => [v.name, v.value]));
978
+ }
979
+ async listOrgVariables(org) {
980
+ if (!this.orgClient) throw new MissingTokenError("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
981
+ const found = await this.orgClient.variables.listVariables(this.repo, {
982
+ kind: "organization",
983
+ org
984
+ });
985
+ return Object.fromEntries(found.map((v) => [v.name, {
986
+ value: v.value,
987
+ visibility: v.visibility ?? "all"
988
+ }]));
989
+ }
990
+ async getRepoInfo() {
991
+ return readRepoInfo(this.client, this.repo);
992
+ }
993
+ async getProperties() {
994
+ const found = await this.client.properties.getValues(this.repo);
995
+ return Object.fromEntries(found.flatMap((p) => p.value === null ? [] : [[p.property_name, p.value]]));
996
+ }
997
+ async listLabels() {
998
+ return (await this.client.labels.listLabels(this.repo)).map((l) => ({
999
+ name: l.name,
1000
+ color: l.color,
1001
+ description: l.description ?? ""
1002
+ }));
1003
+ }
1004
+ async listTeamAccess() {
1005
+ return (await (this.orgClient ?? this.client).teams.listForRepo(this.repo)).map((t) => ({
1006
+ slug: t.slug,
1007
+ permission: t.permission
1008
+ }));
1009
+ }
1010
+ async getRulesetDetail(id) {
1011
+ return toRulesetDetail(await this.client.rulesets.getRuleset(this.repo, id));
1012
+ }
1013
+ async getSecurity() {
1014
+ return readSecurity(this.client, this.repo);
1015
+ }
597
1016
  async syncDescription(description) {
598
1017
  await this.client.repos.updateRepo(this.repo, { description });
599
1018
  return "description updated";
@@ -603,15 +1022,27 @@ var GitHubSource = class {
603
1022
  return "homepage updated";
604
1023
  }
605
1024
  async getPullRequest(number, repo) {
606
- const raw = await this.client.pulls.getPullRequest(repo ?? this.repo, number);
607
- return {
608
- number: raw.number,
609
- title: raw.title,
610
- state: raw.state,
611
- merged: raw.merged_at !== null,
612
- branch: raw.head.ref,
613
- url: raw.html_url
614
- };
1025
+ return toPullRequest(await this.client.pulls.getPullRequest(repo ?? this.repo, number));
1026
+ }
1027
+ async listPullRequests(state, repo) {
1028
+ return (await this.client.pulls.listPullRequests(repo ?? this.repo, state)).map(toPullRequest);
1029
+ }
1030
+ async listBranches(repo) {
1031
+ return (await this.client.branches.listBranches(repo ?? this.repo)).map((b) => ({
1032
+ name: b.name,
1033
+ sha: b.commit.sha
1034
+ }));
1035
+ }
1036
+ async deleteBranch(name, repo) {
1037
+ try {
1038
+ await this.client.branches.removeBranch(repo ?? this.repo, name);
1039
+ } catch (err) {
1040
+ if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("does not exist")) return;
1041
+ throw err;
1042
+ }
1043
+ }
1044
+ async listPullRequestCommits(number, repo) {
1045
+ return (await this.client.pulls.listCommits(repo ?? this.repo, number)).map((commit) => commit.sha);
615
1046
  }
616
1047
  async configurePages(config, token) {
617
1048
  const pagesClient = createGitHubClient$1({
@@ -650,18 +1081,8 @@ async function ensureDir(path) {
650
1081
  throw err;
651
1082
  }
652
1083
  }
653
- //#endregion
654
- //#region src/verify-token.ts
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
- */
1084
+ // src/verify-token.ts
1085
+ /** Checks a token against `GET /user` and returns who it belongs to, or why it failed. */
665
1086
  async function verifyToken(token, opts = {}) {
666
1087
  const client = createGitHubClient({
667
1088
  token,
@@ -669,49 +1090,57 @@ async function verifyToken(token, opts = {}) {
669
1090
  fetch: opts.fetch
670
1091
  });
671
1092
  try {
672
- const me = await client.user.getCurrentUser();
1093
+ const { user: me, expiresAt } = await client.user.getCurrentUserWithExpiry();
673
1094
  return {
674
1095
  ok: true,
675
- subject: `user @ ${me.login ?? me.email ?? "unknown"}`
1096
+ subject: `user @ ${me.login ?? me.email ?? "unknown"}`,
1097
+ ...expiresAt ? { expiresAt } : {}
676
1098
  };
677
1099
  } catch (err) {
678
1100
  return {
679
1101
  ok: false,
680
- message: err instanceof Error ? err.message : String(err)
1102
+ message: errorMessage(err)
681
1103
  };
682
1104
  }
683
1105
  }
684
- //#endregion
685
- //#region src/index.ts
686
- function source(ctx, orgClient) {
1106
+ // src/index.ts
1107
+ /** Builds the `source` capability; the optional clients cover org-level and user-sync access. */
1108
+ function source(ctx, orgClient, userSyncClient) {
687
1109
  return new GitHubSource(ctx.client, {
688
1110
  repo: ctx.repo,
689
1111
  repoRoot: ctx.repoRoot,
690
1112
  orgClient,
1113
+ userSyncClient,
691
1114
  baseUrl: ctx.options.baseUrl,
692
1115
  fetch: ctx.options.fetch
693
1116
  });
694
1117
  }
695
- function secrets(ctx) {
696
- return new GitHubSecrets(ctx.client, { repo: ctx.repo });
1118
+ /** Builds the `secrets` capability; the optional client handles organization secrets. */
1119
+ function secrets(ctx, orgClient) {
1120
+ return new GitHubSecrets(ctx.client, { repo: ctx.repo }, orgClient);
697
1121
  }
1122
+ /** Builds the `environments` capability. */
698
1123
  function environments(ctx) {
699
1124
  return new GitHubEnvironments(ctx.client, { repo: ctx.repo });
700
1125
  }
1126
+ /** Builds the `ci` capability. */
701
1127
  function ci(ctx) {
702
1128
  return new GitHubCi(ctx.client, { repo: ctx.repo });
703
1129
  }
1130
+ /** Builds the `issues` capability, passing the lifecycle labels when configured. */
704
1131
  function issues(ctx) {
705
1132
  const opts = { repo: ctx.repo };
706
1133
  if (ctx.options.labels !== void 0) opts.labels = ctx.options.labels;
707
1134
  return new GitHubIssues(ctx.client, opts);
708
1135
  }
1136
+ /** Creates the plugin, resolving a token per capability when it is first used. */
709
1137
  function createPlugin(options) {
710
1138
  function makeCtx(resolver) {
1139
+ const token = resolver(options);
711
1140
  return {
712
1141
  options,
713
1142
  client: createGitHubClient$1({
714
- token: resolver(options),
1143
+ token,
715
1144
  baseUrl: options.baseUrl,
716
1145
  fetch: options.fetch
717
1146
  }),
@@ -719,10 +1148,45 @@ function createPlugin(options) {
719
1148
  repoRoot: options.repoRoot ?? process.cwd()
720
1149
  };
721
1150
  }
1151
+ /** A token, or undefined when that feature token is simply not configured. */
1152
+ function tryResolve(resolver) {
1153
+ try {
1154
+ return resolver(options);
1155
+ } catch (err) {
1156
+ if (err instanceof AuthError) return void 0;
1157
+ throw err;
1158
+ }
1159
+ }
1160
+ /**
1161
+ * Organization secrets need an org-resource-owner token (`github.secrets`);
1162
+ * repo and environment secrets need `github.admin`. Each scope prefers its own
1163
+ * token and falls back to the other, so a setup with only one of them keeps
1164
+ * working, and an explicit `--token` serves both. With neither configured this
1165
+ * fails exactly as the admin-token capabilities do.
1166
+ */
1167
+ function makeSecrets() {
1168
+ const secretsToken = tryResolve(resolveSecretsToken);
1169
+ const adminToken = tryResolve(resolveAdminToken);
1170
+ if (!secretsToken && !adminToken) resolveAdminToken(options);
1171
+ const clientFor = (token) => createGitHubClient$1({
1172
+ token,
1173
+ baseUrl: options.baseUrl,
1174
+ fetch: options.fetch
1175
+ });
1176
+ const repoClient = clientFor(adminToken ?? secretsToken);
1177
+ const orgClient = clientFor(secretsToken ?? adminToken);
1178
+ return secrets({
1179
+ options,
1180
+ client: repoClient,
1181
+ repo: options.repo,
1182
+ repoRoot: options.repoRoot ?? process.cwd()
1183
+ }, orgClient);
1184
+ }
722
1185
  function makeOrgClient() {
723
1186
  try {
1187
+ const token = resolveOrgToken(options);
724
1188
  return createGitHubClient$1({
725
- token: resolveOrgToken(options),
1189
+ token,
726
1190
  baseUrl: options.baseUrl,
727
1191
  fetch: options.fetch
728
1192
  });
@@ -730,12 +1194,31 @@ function createPlugin(options) {
730
1194
  return;
731
1195
  }
732
1196
  }
1197
+ /** The token for the user-sync files; undefined when it is not configured, and the org token is used instead. */
1198
+ function makeUserSyncClient() {
1199
+ const token = tryResolve(resolveUserSyncToken);
1200
+ return token ? createGitHubClient$1({
1201
+ token,
1202
+ baseUrl: options.baseUrl,
1203
+ fetch: options.fetch
1204
+ }) : void 0;
1205
+ }
733
1206
  return {
734
1207
  name: "@theholocron/holocron-plugin-github",
1208
+ probes: { connection: connectionProbe({
1209
+ provider: "GitHub",
1210
+ url: options.baseUrl ?? "https://api.github.com",
1211
+ fetch: options.fetch,
1212
+ token: () => resolveReadToken(options),
1213
+ verify: (token) => verifyToken(token, {
1214
+ baseUrl: options.baseUrl,
1215
+ fetch: options.fetch
1216
+ })
1217
+ }) },
735
1218
  capabilities: {
736
- source: () => source(makeCtx(resolveAdminToken), makeOrgClient()),
1219
+ source: () => source(makeCtx(resolveAdminToken), makeOrgClient(), makeUserSyncClient()),
737
1220
  ci: () => ci(makeCtx(resolveReadToken)),
738
- secrets: () => secrets(makeCtx(resolveAdminToken)),
1221
+ secrets: () => makeSecrets(),
739
1222
  environments: () => environments(makeCtx(resolveAdminToken)),
740
1223
  issues: () => issues(makeCtx(resolveIssuesToken))
741
1224
  }
@@ -746,5 +1229,16 @@ function createPlugin(options) {
746
1229
  * is supplied or the supplied token is rejected.
747
1230
  */
748
1231
  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
- //#endregion
750
- export { AUTH_HINT, AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubSecrets, GitHubSource, ci, createFeatureResolver, createGitHubClient, createPlugin, encryptSecret, environments, issues, normalizeTeamEntry, resolveAdminToken, resolveDeployToken, resolveIssuesToken, resolveOrgToken, resolveReadToken, resolveReleaseToken, resolveSyncToken, secrets, source, syncLabels, syncTeams, verifyToken };
1232
+ /** Every token this plugin takes, for `holocron doctor tokens`. */
1233
+ const tokens = [
1234
+ resolveReadToken,
1235
+ resolveIssuesToken,
1236
+ resolveSyncToken,
1237
+ resolveReleaseToken,
1238
+ resolveAdminToken,
1239
+ resolveSecretsToken,
1240
+ resolveDeployToken,
1241
+ resolveUserSyncToken,
1242
+ resolveOrgToken
1243
+ ].map((resolver) => resolver.token);
1244
+ 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 };