@theholocron/holocron-plugin-github 5.0.0-alpha.34 → 5.0.0-alpha.340

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,504 @@ 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/capabilities/projects.ts
380
+ /** The name of the single select field that holds an item's status. */
381
+ const STATUS_FIELD = "Status";
382
+ /** The name of the field `move` carries over besides the status. */
383
+ const PRIORITY_FIELD = "Priority";
384
+ const PROJECT_FIELDS = "id number title closed url";
385
+ const BOARD_QUERY = `query($id: ID!) { node(id: $id) { ... on ProjectV2 {
386
+ ${PROJECT_FIELDS}
387
+ fields(first: 100) { nodes {
388
+ ... on ProjectV2FieldCommon { id name dataType }
389
+ ... on ProjectV2SingleSelectField { options { id name color description } }
390
+ } }
391
+ views(first: 50) { nodes { id name layout filter groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } } } }
392
+ workflows(first: 50) { nodes { id name enabled } }
393
+ } } }`;
394
+ const ITEMS_QUERY = `query($id: ID!, $after: String) { node(id: $id) { ... on ProjectV2 {
395
+ items(first: 100, after: $after) {
396
+ pageInfo { hasNextPage endCursor }
397
+ nodes {
398
+ id
399
+ fieldValues(first: 30) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } } } }
400
+ content {
401
+ __typename
402
+ ... on Issue { id number title state repository { nameWithOwner } blockedBy(first: 50) { nodes { number state repository { nameWithOwner } } } }
403
+ ... on PullRequest { id number title state repository { nameWithOwner } }
404
+ }
405
+ }
406
+ }
407
+ } } }`;
408
+ const LIST_QUERY = `query($owner: String!, $after: String) { organization(login: $owner) { id projectsV2(first: 100, after: $after) {
409
+ pageInfo { hasNextPage endCursor }
410
+ nodes { ${PROJECT_FIELDS} }
411
+ } } }`;
412
+ const OWNER_QUERY = "query($owner: String!) { organization(login: $owner) { id } }";
413
+ const SET_VALUE = `mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
414
+ updateProjectV2ItemFieldValue(input: { projectId: $project, itemId: $item, fieldId: $field, value: { singleSelectOptionId: $option } }) { projectV2Item { id } }
415
+ }`;
416
+ const UPDATE_OPTIONS = `mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
417
+ updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) { projectV2Field { ... on ProjectV2SingleSelectField { id } } }
418
+ }`;
419
+ const DATA_TYPES = {
420
+ single_select: "SINGLE_SELECT",
421
+ text: "TEXT",
422
+ number: "NUMBER",
423
+ date: "DATE",
424
+ iteration: "ITERATION"
425
+ };
426
+ function fieldTypeOf(dataType) {
427
+ return Object.keys(DATA_TYPES).find((t) => DATA_TYPES[t] === dataType);
428
+ }
429
+ function toSummary(n) {
430
+ return {
431
+ id: n.id,
432
+ number: n.number,
433
+ name: n.title,
434
+ url: n.url,
435
+ closed: n.closed
436
+ };
437
+ }
438
+ function toOptionInput(o) {
439
+ return {
440
+ ...o.id ? { id: o.id } : {},
441
+ name: o.name,
442
+ color: (o.color ?? "GRAY").toUpperCase(),
443
+ description: o.description ?? ""
444
+ };
445
+ }
446
+ /** The GitHub implementation of the `projects` capability, backed by Projects v2. */
447
+ var GitHubProjects = class {
448
+ graphql;
449
+ /** Capability key, always `"projects"`. */
450
+ key = "projects";
451
+ /** Provider name, always `"github"`. */
452
+ providerName = "github";
453
+ repo;
454
+ constructor(graphql, opts) {
455
+ this.graphql = graphql;
456
+ this.repo = opts.repo;
457
+ }
458
+ /** Every board of the organization, open and closed. */
459
+ async list(owner) {
460
+ const boards = [];
461
+ let after = null;
462
+ for (;;) {
463
+ const page = (await this.graphql.query(LIST_QUERY, {
464
+ owner,
465
+ after
466
+ })).organization.projectsV2;
467
+ boards.push(...page.nodes.map(toSummary));
468
+ if (!page.pageInfo.hasNextPage) return boards;
469
+ after = page.pageInfo.endCursor;
470
+ }
471
+ }
472
+ /** The open board with exactly this name. */
473
+ async find(owner, name) {
474
+ return (await this.list(owner)).find((b) => !b.closed && b.name === name);
475
+ }
476
+ /**
477
+ * Makes a board. With `template.source` naming an open board it is copied (fields, options, views and automation
478
+ * rules, no items); otherwise the board is created and brought in line with the template.
479
+ */
480
+ async create(owner, name, template) {
481
+ const ownerId = await this.ownerId(owner);
482
+ const source = template.source ? await this.find(owner, template.source) : void 0;
483
+ if (source) return toSummary((await this.graphql.query(`mutation($project: ID!, $owner: ID!, $title: String!) {
484
+ copyProjectV2(input: { projectId: $project, ownerId: $owner, title: $title, includeDraftIssues: false }) { projectV2 { ${PROJECT_FIELDS} } }
485
+ }`, {
486
+ project: source.id,
487
+ owner: ownerId,
488
+ title: name
489
+ })).copyProjectV2.projectV2);
490
+ const board = toSummary((await this.graphql.query(`mutation($owner: ID!, $title: String!) {
491
+ createProjectV2(input: { ownerId: $owner, title: $title }) { projectV2 { ${PROJECT_FIELDS} } }
492
+ }`, {
493
+ owner: ownerId,
494
+ title: name
495
+ })).createProjectV2.projectV2);
496
+ await this.ensure(owner, board, template);
497
+ return board;
498
+ }
499
+ /** Closes the board; `updateProjectV2` with `closed: true` is the only way, and reversible. */
500
+ async close(_owner, board) {
501
+ await this.graphql.query(`mutation($project: ID!) { updateProjectV2(input: { projectId: $project, closed: true }) { projectV2 { id } } }`, { project: board.id });
502
+ }
503
+ /** The live board: statuses, other fields, views and automation rules. */
504
+ async read(_owner, board) {
505
+ const raw = await this.readRaw(board.id);
506
+ const status = raw.fields.find((f) => f.name === STATUS_FIELD);
507
+ const fields = [];
508
+ for (const f of raw.fields) {
509
+ const type = fieldTypeOf(f.dataType);
510
+ if (!type || f.name === STATUS_FIELD) continue;
511
+ fields.push({
512
+ name: f.name,
513
+ type,
514
+ ...f.options ? { options: f.options.map((o) => o.name) } : {}
515
+ });
516
+ }
517
+ const views = [];
518
+ for (const v of raw.views) {
519
+ const layout = v.layout === "BOARD_LAYOUT" ? "board" : v.layout === "TABLE_LAYOUT" ? "table" : void 0;
520
+ if (!layout) continue;
521
+ views.push({
522
+ name: v.name,
523
+ layout,
524
+ ...v.filter ? { filter: v.filter } : {},
525
+ ...v.groupBy[0] ? { groupBy: v.groupBy[0] } : {}
526
+ });
527
+ }
528
+ return {
529
+ board: raw.summary,
530
+ statuses: (status?.options ?? []).map((o) => o.name),
531
+ fields,
532
+ views,
533
+ rules: raw.rules.map(({ name, enabled }) => ({
534
+ name,
535
+ enabled
536
+ }))
537
+ };
538
+ }
539
+ /**
540
+ * Brings the board in line with the template. Never deletes a field, option or view, and never sends an option
541
+ * list without ids. Stops (throws) when the status field cannot be read.
542
+ */
543
+ async ensure(_owner, board, template) {
544
+ const raw = await this.readRaw(board.id);
545
+ const status = raw.fields.find((f) => f.name === STATUS_FIELD && f.options);
546
+ if (!status) throw new Error(`cannot read the "${STATUS_FIELD}" field of board "${board.name}"; stopped before any write so no item loses its status`);
547
+ const changes = [];
548
+ const todos = [];
549
+ const statusChanges = await this.ensureOptions(board, status, template.statuses.map((s) => ({
550
+ name: s.name,
551
+ color: s.color,
552
+ description: s.description
553
+ })));
554
+ changes.push(...statusChanges.map((c) => `status ${c}`));
555
+ for (const want of template.fields) {
556
+ const have = raw.fields.find((f) => f.name === want.name);
557
+ if (!have) {
558
+ await this.createField(board.id, want);
559
+ changes.push(`field ${want.name} created`);
560
+ continue;
561
+ }
562
+ if (fieldTypeOf(have.dataType) !== want.type) {
563
+ todos.push(`field "${want.name}" is ${have.dataType} but the template says ${want.type}; change it by hand`);
564
+ continue;
565
+ }
566
+ if (want.type === "single_select" && have.options && want.options) {
567
+ const diff = await this.ensureOptions(board, have, want.options.map((name) => ({ name })));
568
+ changes.push(...diff.map((c) => `field ${want.name} option ${c}`));
569
+ }
570
+ }
571
+ for (const want of template.views) {
572
+ const have = raw.views.find((v) => v.name === want.name);
573
+ const layout = want.layout === "board" ? "BOARD_LAYOUT" : "TABLE_LAYOUT";
574
+ let viewId = have?.id;
575
+ if (!viewId) {
576
+ viewId = await this.createView(board.id, want.name, layout);
577
+ changes.push(`view ${want.name} created`);
578
+ } else if (have && have.layout !== layout) {
579
+ await this.updateView(viewId, { layout });
580
+ changes.push(`view ${want.name} layout set to ${want.layout}`);
581
+ }
582
+ if (want.filter !== void 0 && want.filter !== (have?.filter ?? "")) {
583
+ await this.updateView(viewId, { filter: want.filter });
584
+ changes.push(`view ${want.name} filter set`);
585
+ }
586
+ if (want.groupBy && have?.groupBy[0] !== want.groupBy) todos.push(`group view "${want.name}" by ${want.groupBy}; the API cannot set a view's grouping`);
587
+ }
588
+ for (const rule of template.rules ?? []) {
589
+ const have = raw.rules.find((r) => r.name === rule);
590
+ if (!have) todos.push(`create the automation rule "${rule}"; the API cannot create one`);
591
+ else if (!have.enabled) todos.push(`turn on the automation rule "${rule}"; the API cannot switch one`);
592
+ }
593
+ return {
594
+ created: false,
595
+ changes,
596
+ todos
597
+ };
598
+ }
599
+ /**
600
+ * Makes a single select field hold the wanted options in order, every existing one sent with its id. Options the
601
+ * template does not mention stay, after the wanted ones. Returns what changed (`Blocked added`, `order set`).
602
+ */
603
+ async ensureOptions(board, field, wanted) {
604
+ /* v8 ignore next -- every caller passes a field it found with options; the fallback only satisfies the type */
605
+ const existing = field.options ?? [];
606
+ const next = [];
607
+ const changes = [];
608
+ for (const w of wanted) {
609
+ const have = existing.find((o) => o.name === w.name);
610
+ if (have) {
611
+ next.push({
612
+ id: have.id,
613
+ name: have.name,
614
+ color: (w.color ?? have.color).toUpperCase(),
615
+ description: w.description ?? have.description
616
+ });
617
+ if (next[next.length - 1].color !== have.color || next[next.length - 1].description !== have.description) changes.push(`${have.name} updated`);
618
+ } else {
619
+ next.push({
620
+ name: w.name,
621
+ color: (w.color ?? "GRAY").toUpperCase(),
622
+ description: w.description ?? ""
623
+ });
624
+ changes.push(`${w.name} added`);
625
+ }
626
+ }
627
+ for (const have of existing) if (!next.some((n) => n.id === have.id)) next.push({ ...have });
628
+ const sameOrder = next.length === existing.length && next.every((n, i) => n.id === existing[i]?.id);
629
+ if (changes.length === 0 && sameOrder) return [];
630
+ if (sameOrder === false && !changes.some((c) => c.endsWith("added"))) changes.push("order set");
631
+ const before = await this.fetchItems(board.id);
632
+ const snapshot = /* @__PURE__ */ new Map();
633
+ for (const item of before) {
634
+ const value = item.values[field.name];
635
+ if (value) snapshot.set(item.id, value);
636
+ }
637
+ await this.graphql.query(UPDATE_OPTIONS, {
638
+ field: field.id,
639
+ options: next.map(toOptionInput)
640
+ });
641
+ const afterField = (await this.readRaw(board.id)).fields.find((f) => f.id === field.id);
642
+ const afterItems = await this.fetchItems(board.id);
643
+ const afterValues = new Map(afterItems.map((i) => [i.id, i.values[field.name]]));
644
+ const lost = [...snapshot].filter(([itemId]) => !afterValues.get(itemId));
645
+ if (lost.length > 0) {
646
+ let restored = 0;
647
+ for (const [itemId, name] of lost) {
648
+ const option = afterField?.options?.find((o) => o.name === name);
649
+ if (!option) continue;
650
+ await this.graphql.query(SET_VALUE, {
651
+ project: board.id,
652
+ item: itemId,
653
+ field: field.id,
654
+ option: option.id
655
+ });
656
+ restored++;
657
+ }
658
+ throw new Error(`updating "${field.name}" on board "${board.name}" blanked ${lost.length} item value(s); wrote ${restored} back by option name`);
659
+ }
660
+ return changes;
661
+ }
662
+ async createField(projectId, want) {
663
+ await this.graphql.query(`mutation($project: ID!, $name: String!, $type: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]) {
664
+ createProjectV2Field(input: { projectId: $project, name: $name, dataType: $type, singleSelectOptions: $options }) { projectV2Field { ... on ProjectV2SingleSelectField { id } } }
665
+ }`, {
666
+ project: projectId,
667
+ name: want.name,
668
+ type: DATA_TYPES[want.type],
669
+ options: want.type === "single_select" ? (want.options ?? []).map((name) => toOptionInput({ name })) : void 0
670
+ });
671
+ }
672
+ async createView(projectId, name, layout) {
673
+ return (await this.graphql.query(`mutation($project: ID!, $name: String!, $layout: ProjectV2ViewLayout!) {
674
+ createProjectV2View(input: { projectId: $project, name: $name, layout: $layout }) { projectV2View { id } }
675
+ }`, {
676
+ project: projectId,
677
+ name,
678
+ layout
679
+ })).createProjectV2View.projectV2View.id;
680
+ }
681
+ async updateView(viewId, change) {
682
+ await this.graphql.query(`mutation($view: ID!, $layout: ProjectV2ViewLayout, $filter: String) {
683
+ updateProjectV2View(input: { viewId: $view, layout: $layout, filter: $filter }) { projectV2View { id } }
684
+ }`, {
685
+ view: viewId,
686
+ layout: change.layout,
687
+ filter: change.filter
688
+ });
689
+ }
690
+ /** The board's issues and pull requests with their status, whether they are open and their open blockers. */
691
+ async items(_owner, board, template) {
692
+ const categories = new Map(template.statuses.map((s) => [s.name, s.category]));
693
+ return (await this.fetchItems(board.id)).map((i) => {
694
+ const status = i.values[STATUS_FIELD];
695
+ const category = status ? categories.get(status) : void 0;
696
+ return {
697
+ key: this.keyOf(i.repo, i.number),
698
+ title: i.title,
699
+ open: i.open,
700
+ ...status ? { status } : {},
701
+ ...category ? { category } : {},
702
+ blockedBy: i.blockers.map((b) => this.keyOf(b.repo, b.number))
703
+ };
704
+ });
705
+ }
706
+ /** Puts an item in a status; `transitioned` is false when it already is there. */
707
+ async setStatus(_owner, board, key, status) {
708
+ const ref = this.parseKey(key);
709
+ const field = (await this.readRaw(board.id)).fields.find((f) => f.name === STATUS_FIELD && f.options);
710
+ const option = field?.options?.find((o) => o.name === status);
711
+ if (!field || !option) throw new Error(`board "${board.name}" has no status "${status}"`);
712
+ const item = (await this.fetchItems(board.id)).find((i) => i.repo === ref.repo && i.number === ref.number);
713
+ if (!item) throw new Error(`${key} is not on board "${board.name}"`);
714
+ if (item.values[STATUS_FIELD] === status) return {
715
+ transitioned: false,
716
+ status
717
+ };
718
+ await this.graphql.query(SET_VALUE, {
719
+ project: board.id,
720
+ item: item.id,
721
+ field: field.id,
722
+ option: option.id
723
+ });
724
+ return {
725
+ transitioned: true,
726
+ status,
727
+ via: "project field set"
728
+ };
729
+ }
730
+ /**
731
+ * Puts the open items of `from` on `to` with their status and priority, only where the target has no value;
732
+ * closed and merged ones stay, and are set to the template's done status when theirs is not a done one.
733
+ */
734
+ async move(_owner, from, to, template) {
735
+ const report = {
736
+ moved: [],
737
+ kept: [],
738
+ left: []
739
+ };
740
+ const [source, target, fromRaw, toRaw] = await Promise.all([
741
+ this.fetchItems(from.id),
742
+ this.fetchItems(to.id),
743
+ this.readRaw(from.id),
744
+ this.readRaw(to.id)
745
+ ]);
746
+ const done = template.statuses.find((s) => s.category === "done")?.name;
747
+ const doneNames = new Set(template.statuses.filter((s) => s.category === "done").map((s) => s.name));
748
+ const carried = [STATUS_FIELD, PRIORITY_FIELD];
749
+ for (const item of source) {
750
+ const key = this.keyOf(item.repo, item.number);
751
+ if (!item.open) {
752
+ const current = item.values[STATUS_FIELD];
753
+ if (done && (!current || !doneNames.has(current))) await this.setOption(from.id, fromRaw, item.id, STATUS_FIELD, done);
754
+ report.left.push(key);
755
+ continue;
756
+ }
757
+ let targetItem = target.find((t) => t.contentId === item.contentId);
758
+ if (targetItem) report.kept.push(key);
759
+ else {
760
+ const data = await this.graphql.query(`mutation($project: ID!, $content: ID!) { addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { item { id } } }`, {
761
+ project: to.id,
762
+ content: item.contentId
763
+ });
764
+ targetItem = {
765
+ ...item,
766
+ id: data.addProjectV2ItemById.item.id,
767
+ values: {}
768
+ };
769
+ report.moved.push(key);
770
+ }
771
+ for (const name of carried) {
772
+ const value = item.values[name];
773
+ if (value && !targetItem.values[name]) await this.setOption(to.id, toRaw, targetItem.id, name, value);
774
+ }
775
+ }
776
+ return report;
777
+ }
778
+ /** A `holocron doctor` smoke check: how many boards the token sees in the configured repository's organization. */
779
+ async whoami() {
780
+ try {
781
+ return {
782
+ ok: true,
783
+ boards: (await this.list(this.repo.split("/")[0] ?? "")).length
784
+ };
785
+ } catch {
786
+ return {
787
+ ok: false,
788
+ boards: 0
789
+ };
790
+ }
791
+ }
792
+ /** Sets a single select value by option name; a field or option the board lacks is skipped. */
793
+ async setOption(projectId, raw, itemId, fieldName, optionName) {
794
+ const field = raw.fields.find((f) => f.name === fieldName && f.options);
795
+ const option = field?.options?.find((o) => o.name === optionName);
796
+ if (!field || !option) return;
797
+ await this.graphql.query(SET_VALUE, {
798
+ project: projectId,
799
+ item: itemId,
800
+ field: field.id,
801
+ option: option.id
802
+ });
803
+ }
804
+ async ownerId(owner) {
805
+ return (await this.graphql.query(OWNER_QUERY, { owner })).organization.id;
806
+ }
807
+ async readRaw(id) {
808
+ const n = (await this.graphql.query(BOARD_QUERY, { id })).node;
809
+ if (!n) throw new Error(`board ${id} not found`);
810
+ return {
811
+ summary: toSummary(n),
812
+ fields: n.fields.nodes.filter((f) => f.id).map((f) => ({
813
+ id: f.id,
814
+ name: f.name,
815
+ dataType: f.dataType,
816
+ ...f.options ? { options: f.options } : {}
817
+ })),
818
+ views: n.views.nodes.map((v) => ({
819
+ id: v.id,
820
+ name: v.name,
821
+ layout: v.layout,
822
+ filter: v.filter,
823
+ groupBy: (v.groupByFields?.nodes ?? []).map((g) => g.name)
824
+ })),
825
+ rules: n.workflows.nodes.map((w) => ({
826
+ id: w.id,
827
+ name: w.name,
828
+ enabled: w.enabled
829
+ }))
830
+ };
831
+ }
832
+ async fetchItems(id) {
833
+ const items = [];
834
+ let after = null;
835
+ for (;;) {
836
+ const page = (await this.graphql.query(ITEMS_QUERY, {
837
+ id,
838
+ after
839
+ })).node.items;
840
+ for (const n of page.nodes) {
841
+ const c = n.content;
842
+ if (!c || !c.repository) continue;
843
+ const values = {};
844
+ for (const v of n.fieldValues.nodes) if (v.name && v.field?.name) values[v.field.name] = v.name;
845
+ items.push({
846
+ id: n.id,
847
+ contentId: c.id,
848
+ repo: c.repository.nameWithOwner,
849
+ number: c.number,
850
+ title: c.title,
851
+ open: c.state === "OPEN",
852
+ values,
853
+ blockers: (c.blockedBy?.nodes ?? []).filter((b) => b.state === "OPEN").map((b) => ({
854
+ repo: b.repository.nameWithOwner,
855
+ number: b.number
856
+ }))
857
+ });
858
+ }
859
+ if (!page.pageInfo.hasNextPage) return items;
860
+ after = page.pageInfo.endCursor;
861
+ }
862
+ }
863
+ keyOf(repo, number) {
864
+ return repo === this.repo ? `#${number}` : `${repo}#${number}`;
865
+ }
866
+ parseKey(key) {
867
+ const m = /^(?:([^#\s]+))?#?(\d+)$/.exec(key.trim());
868
+ const hasRepo = key.includes("#") && m?.[1];
869
+ if (!m) throw new Error(`not an issue key: ${key}`);
870
+ return {
871
+ repo: hasRepo ? m[1] : this.repo,
872
+ number: Number(m[2])
873
+ };
874
+ }
875
+ };
876
+ // src/sodium.ts
342
877
  /**
343
878
  * libsodium sealed-box encryption for GitHub Actions secrets.
344
879
  *
@@ -369,32 +904,46 @@ async function encryptSecret(publicKeyBase64, value) {
369
904
  const ciphertext = sodium.crypto_box_seal(plaintext, publicKey);
370
905
  return sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
371
906
  }
372
- //#endregion
373
- //#region src/capabilities/secrets.ts
907
+ // src/capabilities/secrets.ts
908
+ /** The GitHub implementation of the `secrets` capability, covering repository, environment and organization secrets. */
374
909
  var GitHubSecrets = class {
375
910
  client;
376
911
  opts;
912
+ orgClient;
913
+ /** Capability key, always `"secrets"`. */
377
914
  key = "secrets";
915
+ /** Provider name, always `"github"`. */
378
916
  providerName = "github";
379
- constructor(client, opts) {
917
+ /**
918
+ * `client` handles repo and environment scopes; `orgClient`, when given, handles
919
+ * the organization scope. They differ when a dedicated org-only token exists:
920
+ * it can write org secrets but not repo ones, and `github.admin` is the reverse.
921
+ */
922
+ constructor(client, opts, orgClient) {
380
923
  this.client = client;
381
924
  this.opts = opts;
925
+ this.orgClient = orgClient;
926
+ }
927
+ clientFor(scope) {
928
+ return scope.kind === "organization" ? this.orgClient ?? this.client : this.client;
382
929
  }
383
930
  async listSecrets(scope) {
384
- return this.client.secrets.listSecrets(this.opts.repo, toGHScope(scope));
931
+ return this.clientFor(scope).secrets.listSecrets(this.opts.repo, toGHScope(scope));
385
932
  }
386
933
  async setSecret(scope, name, value) {
387
934
  const ghScope = toGHScope(scope);
388
- const pk = await this.client.secrets.getPublicKey(this.opts.repo, ghScope);
935
+ const client = this.clientFor(scope);
936
+ const pk = await client.secrets.getPublicKey(this.opts.repo, ghScope);
389
937
  const body = {
390
938
  encrypted_value: await encryptSecret(pk.key, value),
391
939
  key_id: pk.key_id
392
940
  };
393
941
  if (scope.kind === "organization") body.visibility = "all";
394
- await this.client.secrets.putSecret(this.opts.repo, ghScope, name, body);
942
+ await client.secrets.putSecret(this.opts.repo, ghScope, name, body);
395
943
  }
944
+ /** Deletes the named secret in the given scope. */
396
945
  async deleteSecret(scope, name) {
397
- await this.client.secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
946
+ await this.clientFor(scope).secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
398
947
  }
399
948
  };
400
949
  function toGHScope(scope) {
@@ -404,8 +953,46 @@ function toGHScope(scope) {
404
953
  };
405
954
  return scope;
406
955
  }
407
- //#endregion
408
- //#region src/capabilities/labels.ts
956
+ // src/capabilities/files.ts
957
+ /** Reads one file; `null` when it does not exist. `version` is the blob sha `writeFile` needs to replace exactly this one. */
958
+ /** A fine-grained token only reaches the repositories and permissions it was given; say which one is missing. */
959
+ function noAccess(repo, action, cause) {
960
+ 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 });
961
+ }
962
+ async function readRepoFile(client, repo, path) {
963
+ try {
964
+ const file = await client.git.getContents(repo, path);
965
+ if (file.encoding !== "base64") throw new Error(`${repo}/${path}: unexpected encoding \`${file.encoding}\``);
966
+ return {
967
+ content: Buffer.from(file.content, "base64").toString("utf-8"),
968
+ version: file.sha
969
+ };
970
+ } catch (err) {
971
+ if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "read", err);
972
+ if (!(err instanceof ProviderApiError && err.status === 404)) throw err;
973
+ }
974
+ try {
975
+ await client.repos.getRepo(repo);
976
+ } catch (err) {
977
+ 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 });
978
+ throw err;
979
+ }
980
+ return null;
981
+ }
982
+ /** Creates or replaces one file in a commit; GitHub refuses (409) a `version` that has since changed. */
983
+ async function writeRepoFile(client, repo, path, content, opts) {
984
+ try {
985
+ await client.git.putContents(repo, path, {
986
+ message: opts.message,
987
+ content,
988
+ ...opts.version ? { sha: opts.version } : {}
989
+ });
990
+ } catch (err) {
991
+ if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "write", err);
992
+ throw err;
993
+ }
994
+ }
995
+ // src/capabilities/labels.ts
409
996
  /**
410
997
  * Idempotently synchronise a repo's labels to the canonical set.
411
998
  *
@@ -443,14 +1030,179 @@ async function syncLabels(client, repo, canonical, stale) {
443
1030
  }
444
1031
  return `${created} created, ${updated} updated, ${deleted} deleted`;
445
1032
  }
446
- //#endregion
447
- //#region src/capabilities/properties.ts
448
- async function syncProperties(client, repo, values) {
449
- await client.properties.setProperties(repo, values);
1033
+ // src/capabilities/property-fix-hints.ts
1034
+ /** GitHub's wording when a custom property value is not one the organization's definition allows. */
1035
+ const NOT_ALLOWED = /Value '([^']+)' is not allowed for property '([^']+)'/g;
1036
+ /**
1037
+ * What to do about each value GitHub says an organization custom property does not allow, one sentence per property,
1038
+ * read from GitHub's own message so it works even when the organization's definitions cannot be read. The values
1039
+ * belong to the organization's definition, not to the repository, so the fix is to add them there: a repository cannot
1040
+ * make its own value allowed. Empty when GitHub's message names no such value.
1041
+ */
1042
+ function propertyFixHints(githubMessage) {
1043
+ const byProperty = /* @__PURE__ */ new Map();
1044
+ for (const [, value, property] of githubMessage.matchAll(NOT_ALLOWED)) {
1045
+ const values = byProperty.get(property) ?? [];
1046
+ if (!values.includes(value)) values.push(value);
1047
+ byProperty.set(property, values);
1048
+ }
1049
+ 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`);
1050
+ }
1051
+ // src/capabilities/rejected-properties.ts
1052
+ /** The values `repo setup` sends for `true_false` properties; anything else is rejected. */
1053
+ const BOOLEANS = /* @__PURE__ */ new Set(["true", "false"]);
1054
+ /**
1055
+ * What is wrong with `values` when compared with the organization's property definitions, one sentence per
1056
+ * problem, in the order the properties are given: a property the organization does not define, a value a
1057
+ * `single_select` or `multi_select` property does not allow, or a `true_false` property that is not `true` or
1058
+ * `false`. A `string` or `url` property has nothing to check here. Empty when every value fits its definition.
1059
+ */
1060
+ function rejectedProperties(values, schema) {
1061
+ const byName = new Map(schema.map((property) => [property.property_name, property]));
1062
+ const problems = [];
1063
+ for (const [name, value] of Object.entries(values)) {
1064
+ const definition = byName.get(name);
1065
+ if (!definition) {
1066
+ problems.push(`${name} is not a custom property of the organization`);
1067
+ continue;
1068
+ }
1069
+ const sent = Array.isArray(value) ? value : [value];
1070
+ if (definition.value_type === "true_false") {
1071
+ for (const item of sent.filter((item) => !BOOLEANS.has(item))) problems.push(`${name} is "${item}", but it takes only true or false`);
1072
+ continue;
1073
+ }
1074
+ const allowed = definition.allowed_values ?? [];
1075
+ if (definition.value_type !== "single_select" && definition.value_type !== "multi_select") continue;
1076
+ 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(", ")})`);
1077
+ }
1078
+ return problems;
1079
+ }
1080
+ // src/capabilities/properties.ts
1081
+ /** GitHub's own message from a 422 body (`{"message": ..., "errors": [...]}`), or the start of the body when it is not JSON. */
1082
+ function githubMessage(details) {
1083
+ const raw = String(details ?? "").trim();
1084
+ try {
1085
+ const body = JSON.parse(raw);
1086
+ const errors = Array.isArray(body.errors) ? body.errors.map((e) => JSON.stringify(e)).join(", ") : "";
1087
+ const message = typeof body.message === "string" ? body.message : raw;
1088
+ return errors ? `${message}: ${errors}` : message;
1089
+ } catch {
1090
+ return raw.slice(0, 300);
1091
+ }
1092
+ }
1093
+ /** The organization's property definitions, or `undefined` when they cannot be read (permission, an older client). */
1094
+ async function orgSchema(client, repo) {
1095
+ try {
1096
+ return await client.properties.orgSchema(repo.split("/")[0]);
1097
+ } catch {
1098
+ return;
1099
+ }
1100
+ }
1101
+ /**
1102
+ * The values `values` holds that the organization's property definitions do not allow, one sentence each, or
1103
+ * `undefined` when the definitions cannot be read (the token lacks the organization's custom properties permission, or
1104
+ * the organization defines none). `client` should be the organization-scoped one when there is one, since reading the
1105
+ * definitions is an organization permission a repository token often lacks. Read-only.
1106
+ */
1107
+ async function checkPropertyValues(client, repo, values) {
1108
+ const schema = await orgSchema(client, repo);
1109
+ return schema ? rejectedProperties(values, schema) : void 0;
1110
+ }
1111
+ /**
1112
+ * Sets the repo's custom properties. When GitHub answers 422 the error says which value it refused and what to do
1113
+ * about it: the values are compared with the organization's property definitions (read-only, with `schemaClient`, the
1114
+ * organization-scoped client when there is one), GitHub's own message is added, and each value GitHub says a property
1115
+ * does not allow gets the fix, which does not need the definitions to be readable. One refused value fails the whole
1116
+ * request, so none of the properties is set.
1117
+ */
1118
+ async function syncProperties(client, repo, values, schemaClient = client) {
1119
+ try {
1120
+ await client.properties.setProperties(repo, values);
1121
+ } catch (err) {
1122
+ if (!(err instanceof ProviderApiError$1) || err.status !== 422) throw err;
1123
+ const problems = await checkPropertyValues(schemaClient, repo, values) ?? [];
1124
+ const said = githubMessage(err.details);
1125
+ const fixes = propertyFixHints(said);
1126
+ 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 });
1127
+ }
450
1128
  return `${Object.keys(values).length} properties set`;
451
1129
  }
452
- //#endregion
453
- //#region src/capabilities/teams.ts
1130
+ // src/capabilities/read-repo.ts
1131
+ /** The settings `repo setup` writes, which `GET /repos/{repo}` reports under the same names. */
1132
+ const SETTING_KEYS = [
1133
+ "allow_squash_merge",
1134
+ "allow_merge_commit",
1135
+ "allow_rebase_merge",
1136
+ "allow_auto_merge",
1137
+ "allow_update_branch",
1138
+ "delete_branch_on_merge",
1139
+ "has_issues",
1140
+ "has_discussions",
1141
+ "has_projects",
1142
+ "has_wiki"
1143
+ ];
1144
+ /** The repository's description, homepage, default branch, topics and settings, from one read. */
1145
+ async function readRepoInfo(client, repo) {
1146
+ const data = await client.repos.getRepo(repo);
1147
+ const settings = { default_branch: data.default_branch };
1148
+ for (const key of SETTING_KEYS) {
1149
+ const value = data[key];
1150
+ if (value !== void 0) settings[key] = value;
1151
+ }
1152
+ return {
1153
+ description: data.description ?? null,
1154
+ homepage: data.homepage ?? null,
1155
+ defaultBranch: data.default_branch,
1156
+ topics: data.topics ?? [],
1157
+ settings
1158
+ };
1159
+ }
1160
+ /** A ruleset reduced to what `repo setup` writes: its enforcement, rule types and required status checks. */
1161
+ function toRulesetDetail(data) {
1162
+ const rules = data.rules ?? [];
1163
+ const checks = rules.find((r) => r.type === "required_status_checks")?.parameters?.required_status_checks;
1164
+ return {
1165
+ name: data.name,
1166
+ enforcement: data.enforcement,
1167
+ ruleTypes: rules.map((r) => r.type),
1168
+ requiredChecks: Array.isArray(checks) ? checks.map((c) => String(c.context)) : []
1169
+ };
1170
+ }
1171
+ /** The value, or `undefined` when GitHub refuses the read (permission, plan, feature absent). Other errors still throw. */
1172
+ async function readable(read) {
1173
+ try {
1174
+ return await read();
1175
+ } catch (err) {
1176
+ if (err instanceof ProviderApiError$1) return void 0;
1177
+ throw err;
1178
+ }
1179
+ }
1180
+ /** Which security features are on. One GitHub refuses to report is left out, not reported as off. */
1181
+ async function readSecurity(client, repo) {
1182
+ const [data, alerts, fixes, reporting, codeScanning] = await Promise.all([
1183
+ readable(() => client.repos.getRepo(repo)),
1184
+ readable(() => client.security.vulnerabilityAlertsEnabled(repo)),
1185
+ readable(() => client.security.automatedSecurityFixesEnabled(repo)),
1186
+ readable(() => client.security.privateVulnerabilityReportingEnabled(repo)),
1187
+ readable(() => client.security.codeScanningDefaultSetupState(repo))
1188
+ ]);
1189
+ const analysis = data?.security_and_analysis;
1190
+ const on = (feature) => {
1191
+ const status = analysis?.[feature]?.status;
1192
+ return status === void 0 ? void 0 : status === "enabled";
1193
+ };
1194
+ const found = {
1195
+ vulnerabilityAlerts: alerts,
1196
+ automatedSecurityFixes: fixes,
1197
+ secretScanning: on("secret_scanning"),
1198
+ secretScanningPushProtection: on("secret_scanning_push_protection"),
1199
+ privateVulnerabilityReporting: reporting,
1200
+ codeScanningDefaultSetup: codeScanning === void 0 ? void 0 : codeScanning === "configured"
1201
+ };
1202
+ return Object.fromEntries(Object.entries(found).filter(([, v]) => v !== void 0));
1203
+ }
1204
+ // src/capabilities/teams.ts
1205
+ /** Turns a team entry into slug and permission form; a bare slug gets `push`. */
454
1206
  function normalizeTeamEntry(entry) {
455
1207
  if (typeof entry === "string") return {
456
1208
  slug: entry,
@@ -458,6 +1210,7 @@ function normalizeTeamEntry(entry) {
458
1210
  };
459
1211
  return entry;
460
1212
  }
1213
+ /** Grants each team access to the repo and reports the outcome; throws when every team fails. */
461
1214
  async function syncTeams(client, repo, teams) {
462
1215
  const [org = "", name = ""] = repo.split("/", 2);
463
1216
  const normalized = teams.map(normalizeTeamEntry);
@@ -470,17 +1223,132 @@ async function syncTeams(client, repo, teams) {
470
1223
  }
471
1224
  return `${succeeded} team${succeeded === 1 ? "" : "s"} synced`;
472
1225
  }
473
- //#endregion
474
- //#region src/capabilities/topics.ts
1226
+ // src/capabilities/topics.ts
475
1227
  async function syncTopics(client, repo, topics) {
476
1228
  await client.topics.setTopics(repo, topics);
477
1229
  return `${topics.length} topics set`;
478
1230
  }
479
- //#endregion
480
- //#region src/capabilities/source.ts
1231
+ // src/capabilities/variables.ts
1232
+ /** GitHub's rule for a variable name: letters, digits and underscores, not starting with a digit or `GITHUB_`. */
1233
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1234
+ /** Longest name GitHub accepts. */
1235
+ const MAX_NAME = 256;
1236
+ function assertNames(names) {
1237
+ 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_`);
1238
+ }
1239
+ /** GitHub treats variable names as case-insensitive, so every comparison goes through this. */
1240
+ const key = (name) => name.toUpperCase();
1241
+ /**
1242
+ * Bring one scope's variables in line with the declaration: create what is missing, update what differs, leave what
1243
+ * already matches. Variables that exist but are not declared are left alone, or deleted with `prune`, except an
1244
+ * organization variable limited to selected repositories, which this client cannot manage and so is never deleted.
1245
+ * Returns a one-line summary, naming the ones left so drift is visible. In a dry run it only describes the changes.
1246
+ */
1247
+ async function reconcile(client, repo, scope, wanted, opts) {
1248
+ assertNames(wanted.map((w) => w.name));
1249
+ const existing = new Map((await client.variables.listVariables(repo, scope)).map((v) => [key(v.name), v]));
1250
+ const counts = {
1251
+ created: 0,
1252
+ updated: 0,
1253
+ unchanged: 0,
1254
+ deleted: 0
1255
+ };
1256
+ for (const want of wanted) {
1257
+ const have = existing.get(key(want.name));
1258
+ const body = {
1259
+ name: want.name,
1260
+ value: want.value
1261
+ };
1262
+ if (want.visibility) body["visibility"] = want.visibility;
1263
+ if (!have) {
1264
+ if (opts.dryRun) {
1265
+ counts.created++;
1266
+ continue;
1267
+ }
1268
+ try {
1269
+ await client.variables.createVariable(repo, scope, body);
1270
+ counts.created++;
1271
+ continue;
1272
+ } catch (err) {
1273
+ if (!(err instanceof ProviderApiError && (err.status === 409 || err.status === 422))) throw err;
1274
+ }
1275
+ } else if (have.value === want.value && (!want.visibility || have.visibility === want.visibility)) {
1276
+ counts.unchanged++;
1277
+ continue;
1278
+ }
1279
+ if (!opts.dryRun) await client.variables.updateVariable(repo, scope, have?.name ?? want.name, body);
1280
+ counts.updated++;
1281
+ }
1282
+ const declared = new Set(wanted.map((w) => key(w.name)));
1283
+ const undeclared = [...existing.entries()].filter(([k]) => !declared.has(k)).map(([, v]) => v);
1284
+ const unmanaged = undeclared.filter((v) => v.visibility === "selected");
1285
+ if (opts.prune) for (const v of undeclared.filter((u) => u.visibility !== "selected")) {
1286
+ if (!opts.dryRun) await client.variables.deleteVariable(repo, scope, v.name);
1287
+ counts.deleted++;
1288
+ }
1289
+ const parts = opts.dryRun ? [
1290
+ `would create ${counts.created}`,
1291
+ `would update ${counts.updated}`,
1292
+ `${counts.unchanged} unchanged`
1293
+ ] : [
1294
+ `${counts.created} created`,
1295
+ `${counts.updated} updated`,
1296
+ `${counts.unchanged} unchanged`
1297
+ ];
1298
+ if (counts.deleted > 0) parts.push(`${counts.deleted} ${opts.dryRun ? "would be deleted" : "deleted"}`);
1299
+ const left = opts.prune ? unmanaged : undeclared;
1300
+ if (left.length > 0) {
1301
+ const why = opts.prune ? "limited to selected repositories, not managed" : "not declared, left alone";
1302
+ parts.push(`${left.length} ${why}: ${left.map((v) => v.name).join(", ")}`);
1303
+ }
1304
+ return parts.join(", ");
1305
+ }
1306
+ /** Reconcile a repository's Actions variables. */
1307
+ function syncVariables(client, repo, variables, opts = {}) {
1308
+ return reconcile(client, repo, { kind: "repo" }, Object.entries(variables).map(([name, value]) => ({
1309
+ name,
1310
+ value
1311
+ })), {
1312
+ prune: opts.prune === true,
1313
+ dryRun: opts.dryRun === true
1314
+ });
1315
+ }
1316
+ /** Reconcile an organization's Actions variables. `repo` is only the coordinate the client is bound to. */
1317
+ function syncOrgVariables(client, repo, org, variables, opts = {}) {
1318
+ const wanted = Object.entries(variables).map(([name, v]) => ({
1319
+ name,
1320
+ value: v.value,
1321
+ visibility: v.visibility
1322
+ }));
1323
+ return reconcile(client, repo, {
1324
+ kind: "organization",
1325
+ org
1326
+ }, wanted, {
1327
+ prune: opts.prune === true,
1328
+ dryRun: opts.dryRun === true
1329
+ });
1330
+ }
1331
+ // src/capabilities/source.ts
1332
+ function toPullRequest(raw) {
1333
+ return {
1334
+ number: raw.number,
1335
+ title: raw.title,
1336
+ state: raw.state,
1337
+ merged: raw.merged_at !== null,
1338
+ branch: raw.head.ref,
1339
+ headSha: raw.head.sha,
1340
+ url: raw.html_url
1341
+ };
1342
+ }
1343
+ /**
1344
+ * The GitHub implementation of the `source` capability: repo settings, rulesets, branch protection, security
1345
+ * features and local workflow files.
1346
+ */
481
1347
  var GitHubSource = class {
482
1348
  client;
1349
+ /** Capability key, always `"source"`. */
483
1350
  key = "source";
1351
+ /** Provider name, always `"github"`. */
484
1352
  providerName = "github";
485
1353
  owner;
486
1354
  name;
@@ -488,6 +1356,7 @@ var GitHubSource = class {
488
1356
  repoRoot;
489
1357
  workflowDir;
490
1358
  orgClient;
1359
+ userSyncClient;
491
1360
  baseUrl;
492
1361
  fetchImpl;
493
1362
  constructor(client, opts) {
@@ -496,15 +1365,20 @@ var GitHubSource = class {
496
1365
  this.owner = owner;
497
1366
  this.name = name;
498
1367
  this.repo = opts.repo;
499
- this.repoRoot = opts.repoRoot;
500
- this.workflowDir = join(opts.repoRoot, ".github", "workflows");
1368
+ this.repoRoot = findRootSync(opts.repoRoot, ".git", {
1369
+ fallback: "start",
1370
+ realpath: true
1371
+ });
1372
+ this.workflowDir = join(this.repoRoot, ".github", "workflows");
501
1373
  this.orgClient = opts.orgClient;
1374
+ this.userSyncClient = opts.userSyncClient;
502
1375
  this.baseUrl = opts.baseUrl;
503
1376
  this.fetchImpl = opts.fetch;
504
1377
  }
505
1378
  async whoami() {
506
1379
  return { login: (await this.client.user.getCurrentUser()).login };
507
1380
  }
1381
+ /** Returns the repo's owner, name and default branch. */
508
1382
  async getRepo() {
509
1383
  const data = await this.client.repos.getRepo(this.repo);
510
1384
  return {
@@ -513,27 +1387,34 @@ var GitHubSource = class {
513
1387
  defaultBranch: data.default_branch
514
1388
  };
515
1389
  }
1390
+ /** Lists the repo's rulesets. */
516
1391
  async listRulesets() {
517
1392
  return this.client.rulesets.listRulesets(this.repo);
518
1393
  }
1394
+ /** Creates a ruleset from the given payload. */
519
1395
  async createRuleset(payload) {
520
1396
  return this.client.rulesets.createRuleset(this.repo, payload);
521
1397
  }
1398
+ /** Updates the ruleset with the given id from the payload. */
522
1399
  async updateRuleset(id, payload) {
523
1400
  return this.client.rulesets.updateRuleset(this.repo, id, payload);
524
1401
  }
1402
+ /** Updates the repo's settings. */
525
1403
  async updateRepoSettings(settings) {
526
1404
  await this.client.repos.updateRepo(this.repo, settings);
527
1405
  }
528
1406
  async protectBranch(branch, payload) {
529
1407
  await this.client.branches.protectBranch(this.repo, branch, payload);
530
1408
  }
1409
+ /** Enables Dependabot vulnerability alerts. */
531
1410
  async enableVulnerabilityAlerts() {
532
1411
  await this.client.security.enableVulnerabilityAlerts(this.repo);
533
1412
  }
1413
+ /** Enables Dependabot automated security fixes. */
534
1414
  async enableAutomatedSecurityFixes() {
535
1415
  await this.client.security.enableAutomatedSecurityFixes(this.repo);
536
1416
  }
1417
+ /** Enables secret scanning. */
537
1418
  async enableSecretScanning() {
538
1419
  await this.client.security.enableSecretScanning(this.repo);
539
1420
  }
@@ -546,9 +1427,11 @@ var GitHubSource = class {
546
1427
  async enableDependencyGraph() {
547
1428
  await this.client.security.enableDependencyGraph(this.repo);
548
1429
  }
1430
+ /** Enables private vulnerability reporting. */
549
1431
  async enablePrivateVulnerabilityReporting() {
550
1432
  await this.client.security.enablePrivateVulnerabilityReporting(this.repo);
551
1433
  }
1434
+ /** Lists the `.yml` and `.yaml` files in `.github/workflows`, sorted by name; empty when the folder is missing. */
552
1435
  async listWorkflowFiles() {
553
1436
  try {
554
1437
  return (await readdir(this.workflowDir)).filter((e) => e.endsWith(".yml") || e.endsWith(".yaml")).sort();
@@ -557,6 +1440,7 @@ var GitHubSource = class {
557
1440
  throw err;
558
1441
  }
559
1442
  }
1443
+ /** Reads a workflow file's text, or `null` when it does not exist. */
560
1444
  async readWorkflowFile(name) {
561
1445
  try {
562
1446
  return await readFile(join(this.workflowDir, name), "utf8");
@@ -565,10 +1449,12 @@ var GitHubSource = class {
565
1449
  throw err;
566
1450
  }
567
1451
  }
1452
+ /** Writes a workflow file, creating `.github/workflows` first when needed. */
568
1453
  async writeWorkflowFile(name, contents) {
569
1454
  await ensureDir(this.workflowDir);
570
1455
  await writeFile(join(this.workflowDir, name), contents, "utf8");
571
1456
  }
1457
+ /** Removes a workflow file; a file that is already gone is not an error. */
572
1458
  async removeWorkflowFile(name) {
573
1459
  try {
574
1460
  await rm(join(this.workflowDir, name));
@@ -586,14 +1472,77 @@ var GitHubSource = class {
586
1472
  return syncLabels(this.client, this.repo, canonical, stale);
587
1473
  }
588
1474
  async syncProperties(values) {
589
- return syncProperties(this.client, this.repo, values);
1475
+ return syncProperties(this.client, this.repo, values, this.orgClient ?? this.client);
1476
+ }
1477
+ async checkPropertyValues(values) {
1478
+ return checkPropertyValues(this.orgClient ?? this.client, this.repo, values);
590
1479
  }
591
1480
  async syncTeams(teams) {
592
1481
  return syncTeams(this.orgClient ?? this.client, this.repo, teams);
593
1482
  }
1483
+ async teamMembers(teamSlug, org = this.owner) {
1484
+ return (await (this.orgClient ?? this.client).teams.members(org, teamSlug)).map((m) => ({
1485
+ id: String(m.id),
1486
+ login: m.login
1487
+ }));
1488
+ }
1489
+ async readFile(path, repo = this.repo) {
1490
+ return readRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, repo, path);
1491
+ }
1492
+ async writeFile(path, content, opts) {
1493
+ return writeRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, opts.repo ?? this.repo, path, content, opts);
1494
+ }
594
1495
  async syncTopics(topics) {
595
1496
  return syncTopics(this.client, this.repo, topics);
596
1497
  }
1498
+ async syncVariables(variables, opts) {
1499
+ return syncVariables(this.client, this.repo, variables, opts);
1500
+ }
1501
+ async syncOrgVariables(org, variables, opts) {
1502
+ if (!this.orgClient) throw new Error("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
1503
+ return syncOrgVariables(this.orgClient, this.repo, org, variables, opts);
1504
+ }
1505
+ async listVariables() {
1506
+ const found = await this.client.variables.listVariables(this.repo, { kind: "repo" });
1507
+ return Object.fromEntries(found.map((v) => [v.name, v.value]));
1508
+ }
1509
+ async listOrgVariables(org) {
1510
+ if (!this.orgClient) throw new MissingTokenError("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
1511
+ const found = await this.orgClient.variables.listVariables(this.repo, {
1512
+ kind: "organization",
1513
+ org
1514
+ });
1515
+ return Object.fromEntries(found.map((v) => [v.name, {
1516
+ value: v.value,
1517
+ visibility: v.visibility ?? "all"
1518
+ }]));
1519
+ }
1520
+ async getRepoInfo() {
1521
+ return readRepoInfo(this.client, this.repo);
1522
+ }
1523
+ async getProperties() {
1524
+ const found = await this.client.properties.getValues(this.repo);
1525
+ return Object.fromEntries(found.flatMap((p) => p.value === null ? [] : [[p.property_name, p.value]]));
1526
+ }
1527
+ async listLabels() {
1528
+ return (await this.client.labels.listLabels(this.repo)).map((l) => ({
1529
+ name: l.name,
1530
+ color: l.color,
1531
+ description: l.description ?? ""
1532
+ }));
1533
+ }
1534
+ async listTeamAccess() {
1535
+ return (await (this.orgClient ?? this.client).teams.listForRepo(this.repo)).map((t) => ({
1536
+ slug: t.slug,
1537
+ permission: t.permission
1538
+ }));
1539
+ }
1540
+ async getRulesetDetail(id) {
1541
+ return toRulesetDetail(await this.client.rulesets.getRuleset(this.repo, id));
1542
+ }
1543
+ async getSecurity() {
1544
+ return readSecurity(this.client, this.repo);
1545
+ }
597
1546
  async syncDescription(description) {
598
1547
  await this.client.repos.updateRepo(this.repo, { description });
599
1548
  return "description updated";
@@ -603,15 +1552,27 @@ var GitHubSource = class {
603
1552
  return "homepage updated";
604
1553
  }
605
1554
  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
- };
1555
+ return toPullRequest(await this.client.pulls.getPullRequest(repo ?? this.repo, number));
1556
+ }
1557
+ async listPullRequests(state, repo) {
1558
+ return (await this.client.pulls.listPullRequests(repo ?? this.repo, state)).map(toPullRequest);
1559
+ }
1560
+ async listBranches(repo) {
1561
+ return (await this.client.branches.listBranches(repo ?? this.repo)).map((b) => ({
1562
+ name: b.name,
1563
+ sha: b.commit.sha
1564
+ }));
1565
+ }
1566
+ async deleteBranch(name, repo) {
1567
+ try {
1568
+ await this.client.branches.removeBranch(repo ?? this.repo, name);
1569
+ } catch (err) {
1570
+ if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("does not exist")) return;
1571
+ throw err;
1572
+ }
1573
+ }
1574
+ async listPullRequestCommits(number, repo) {
1575
+ return (await this.client.pulls.listCommits(repo ?? this.repo, number)).map((commit) => commit.sha);
615
1576
  }
616
1577
  async configurePages(config, token) {
617
1578
  const pagesClient = createGitHubClient$1({
@@ -650,18 +1611,8 @@ async function ensureDir(path) {
650
1611
  throw err;
651
1612
  }
652
1613
  }
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
- */
1614
+ // src/verify-token.ts
1615
+ /** Checks a token against `GET /user` and returns who it belongs to, or why it failed. */
665
1616
  async function verifyToken(token, opts = {}) {
666
1617
  const client = createGitHubClient({
667
1618
  token,
@@ -669,49 +1620,61 @@ async function verifyToken(token, opts = {}) {
669
1620
  fetch: opts.fetch
670
1621
  });
671
1622
  try {
672
- const me = await client.user.getCurrentUser();
1623
+ const { user: me, expiresAt } = await client.user.getCurrentUserWithExpiry();
673
1624
  return {
674
1625
  ok: true,
675
- subject: `user @ ${me.login ?? me.email ?? "unknown"}`
1626
+ subject: `user @ ${me.login ?? me.email ?? "unknown"}`,
1627
+ ...expiresAt ? { expiresAt } : {}
676
1628
  };
677
1629
  } catch (err) {
678
1630
  return {
679
1631
  ok: false,
680
- message: err instanceof Error ? err.message : String(err)
1632
+ message: errorMessage(err)
681
1633
  };
682
1634
  }
683
1635
  }
684
- //#endregion
685
- //#region src/index.ts
686
- function source(ctx, orgClient) {
1636
+ // src/index.ts
1637
+ /** Builds the `source` capability; the optional clients cover org-level and user-sync access. */
1638
+ function source(ctx, orgClient, userSyncClient) {
687
1639
  return new GitHubSource(ctx.client, {
688
1640
  repo: ctx.repo,
689
1641
  repoRoot: ctx.repoRoot,
690
1642
  orgClient,
1643
+ userSyncClient,
691
1644
  baseUrl: ctx.options.baseUrl,
692
1645
  fetch: ctx.options.fetch
693
1646
  });
694
1647
  }
695
- function secrets(ctx) {
696
- return new GitHubSecrets(ctx.client, { repo: ctx.repo });
1648
+ /** Builds the `secrets` capability; the optional client handles organization secrets. */
1649
+ function secrets(ctx, orgClient) {
1650
+ return new GitHubSecrets(ctx.client, { repo: ctx.repo }, orgClient);
697
1651
  }
1652
+ /** Builds the `environments` capability. */
698
1653
  function environments(ctx) {
699
1654
  return new GitHubEnvironments(ctx.client, { repo: ctx.repo });
700
1655
  }
1656
+ /** Builds the `ci` capability. */
701
1657
  function ci(ctx) {
702
1658
  return new GitHubCi(ctx.client, { repo: ctx.repo });
703
1659
  }
1660
+ /** Builds the `issues` capability, passing the lifecycle labels when configured. */
704
1661
  function issues(ctx) {
705
1662
  const opts = { repo: ctx.repo };
706
1663
  if (ctx.options.labels !== void 0) opts.labels = ctx.options.labels;
707
1664
  return new GitHubIssues(ctx.client, opts);
708
1665
  }
1666
+ /** Builds the `projects` capability: GitHub Projects v2 over GraphQL, with items keyed relative to the configured repo. */
1667
+ function projects(ctx) {
1668
+ return new GitHubProjects(ctx.client.graphql, { repo: ctx.repo });
1669
+ }
1670
+ /** Creates the plugin, resolving a token per capability when it is first used. */
709
1671
  function createPlugin(options) {
710
1672
  function makeCtx(resolver) {
1673
+ const token = resolver(options);
711
1674
  return {
712
1675
  options,
713
1676
  client: createGitHubClient$1({
714
- token: resolver(options),
1677
+ token,
715
1678
  baseUrl: options.baseUrl,
716
1679
  fetch: options.fetch
717
1680
  }),
@@ -719,10 +1682,45 @@ function createPlugin(options) {
719
1682
  repoRoot: options.repoRoot ?? process.cwd()
720
1683
  };
721
1684
  }
1685
+ /** A token, or undefined when that feature token is simply not configured. */
1686
+ function tryResolve(resolver) {
1687
+ try {
1688
+ return resolver(options);
1689
+ } catch (err) {
1690
+ if (err instanceof AuthError) return void 0;
1691
+ throw err;
1692
+ }
1693
+ }
1694
+ /**
1695
+ * Organization secrets need an org-resource-owner token (`github.secrets`);
1696
+ * repo and environment secrets need `github.admin`. Each scope prefers its own
1697
+ * token and falls back to the other, so a setup with only one of them keeps
1698
+ * working, and an explicit `--token` serves both. With neither configured this
1699
+ * fails exactly as the admin-token capabilities do.
1700
+ */
1701
+ function makeSecrets() {
1702
+ const secretsToken = tryResolve(resolveSecretsToken);
1703
+ const adminToken = tryResolve(resolveAdminToken);
1704
+ if (!secretsToken && !adminToken) resolveAdminToken(options);
1705
+ const clientFor = (token) => createGitHubClient$1({
1706
+ token,
1707
+ baseUrl: options.baseUrl,
1708
+ fetch: options.fetch
1709
+ });
1710
+ const repoClient = clientFor(adminToken ?? secretsToken);
1711
+ const orgClient = clientFor(secretsToken ?? adminToken);
1712
+ return secrets({
1713
+ options,
1714
+ client: repoClient,
1715
+ repo: options.repo,
1716
+ repoRoot: options.repoRoot ?? process.cwd()
1717
+ }, orgClient);
1718
+ }
722
1719
  function makeOrgClient() {
723
1720
  try {
1721
+ const token = resolveOrgToken(options);
724
1722
  return createGitHubClient$1({
725
- token: resolveOrgToken(options),
1723
+ token,
726
1724
  baseUrl: options.baseUrl,
727
1725
  fetch: options.fetch
728
1726
  });
@@ -730,14 +1728,34 @@ function createPlugin(options) {
730
1728
  return;
731
1729
  }
732
1730
  }
1731
+ /** The token for the user-sync files; undefined when it is not configured, and the org token is used instead. */
1732
+ function makeUserSyncClient() {
1733
+ const token = tryResolve(resolveUserSyncToken);
1734
+ return token ? createGitHubClient$1({
1735
+ token,
1736
+ baseUrl: options.baseUrl,
1737
+ fetch: options.fetch
1738
+ }) : void 0;
1739
+ }
733
1740
  return {
734
1741
  name: "@theholocron/holocron-plugin-github",
1742
+ probes: { connection: connectionProbe({
1743
+ provider: "GitHub",
1744
+ url: options.baseUrl ?? "https://api.github.com",
1745
+ fetch: options.fetch,
1746
+ token: () => resolveReadToken(options),
1747
+ verify: (token) => verifyToken(token, {
1748
+ baseUrl: options.baseUrl,
1749
+ fetch: options.fetch
1750
+ })
1751
+ }) },
735
1752
  capabilities: {
736
- source: () => source(makeCtx(resolveAdminToken), makeOrgClient()),
1753
+ source: () => source(makeCtx(resolveAdminToken), makeOrgClient(), makeUserSyncClient()),
737
1754
  ci: () => ci(makeCtx(resolveReadToken)),
738
- secrets: () => secrets(makeCtx(resolveAdminToken)),
1755
+ secrets: () => makeSecrets(),
739
1756
  environments: () => environments(makeCtx(resolveAdminToken)),
740
- issues: () => issues(makeCtx(resolveIssuesToken))
1757
+ issues: () => issues(makeCtx(resolveIssuesToken)),
1758
+ projects: () => projects(makeCtx(resolveOrgToken))
741
1759
  }
742
1760
  };
743
1761
  }
@@ -746,5 +1764,16 @@ function createPlugin(options) {
746
1764
  * is supplied or the supplied token is rejected.
747
1765
  */
748
1766
  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 };
1767
+ /** Every token this plugin takes, for `holocron doctor tokens`. */
1768
+ const tokens = [
1769
+ resolveReadToken,
1770
+ resolveIssuesToken,
1771
+ resolveSyncToken,
1772
+ resolveReleaseToken,
1773
+ resolveAdminToken,
1774
+ resolveSecretsToken,
1775
+ resolveDeployToken,
1776
+ resolveUserSyncToken,
1777
+ resolveOrgToken
1778
+ ].map((resolver) => resolver.token);
1779
+ export { AUTH_HINT, AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubProjects, GitHubSecrets, GitHubSource, ci, createFeatureResolver, createGitHubClient, createPlugin, encryptSecret, environments, issues, normalizeTeamEntry, projects, resolveAdminToken, resolveDeployToken, resolveIssuesToken, resolveOrgToken, resolveReadToken, resolveReleaseToken, resolveSecretsToken, resolveSyncToken, resolveUserSyncToken, secrets, source, syncLabels, syncTeams, tokens, verifyToken };