@theholocron/holocron-plugin-github 5.0.0-alpha.44 → 5.0.0-alpha.442

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, pickByCreation, readKeysLimited, reportProgress, tokenExpectations } 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,348 @@ var GitHubEnvironments = class {
119
151
  }));
120
152
  }
121
153
  };
122
- //#endregion
123
- //#region src/capabilities/issues.ts
154
+ // src/capabilities/issues-migration.ts
155
+ /**
156
+ * Export and import of GitHub issues for `holocron migrate`.
157
+ *
158
+ * GitHub holds less than a planning tool, so what does not fit is named in each result's `dropped` list rather than
159
+ * lost quietly:
160
+ * - priority is a label (`priority: high`), because on GitHub it lives in a project field, not on the issue;
161
+ * - an import cannot backdate an issue or a comment, and cannot post as someone else, so the original date and
162
+ * author go in the text;
163
+ * - an import does not set parent or blocked-by links (they need GraphQL node ids of issues made in the same run);
164
+ * - an assignee is not mapped (a Linear user is not a GitHub login).
165
+ * A migrated issue ends with `<!-- holocron:migrated-from <url> -->`, which is how a second run finds it.
166
+ */
167
+ const MARKER = /<!-- holocron:migrated-from (\S+) -->/;
168
+ const PRIORITY_LABEL = /^priority\s*[:/-]\s*(urgent|high|medium|low)$/i;
169
+ const LINK_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) {
170
+ issue(number: $number) { parent { number } blockedBy(first: 50) { nodes { number } } } } }`;
171
+ /** Splits `priority: high` labels from the rest. */
172
+ function splitLabels(names) {
173
+ let priority;
174
+ const labels = [];
175
+ for (const name of names) {
176
+ const match = PRIORITY_LABEL.exec(name);
177
+ if (match) priority = match[1].toLowerCase();
178
+ else labels.push(name);
179
+ }
180
+ return {
181
+ labels,
182
+ ...priority ? { priority } : {}
183
+ };
184
+ }
185
+ function categoryOf(issue) {
186
+ if (issue.state === "closed") return "done";
187
+ const names = issue.labels.map((l) => l.name.toLowerCase());
188
+ if (names.some((n) => n === "status:in-review" || n === "status: in review")) return "in-review";
189
+ if (names.some((n) => n === "status:in-progress" || n === "status: in progress")) return "in-progress";
190
+ return "open";
191
+ }
192
+ /** How many issues an export fetches at once. GitHub's secondary rate limit punishes bursts of requests, not volume. */
193
+ const EXPORT_CONCURRENCY = 4;
194
+ /** Runs `fn` over `items`, at most `limit` at a time, and returns the results in the order of `items`. */
195
+ async function mapLimit(items, limit, fn) {
196
+ const results = new Array(items.length);
197
+ let next = 0;
198
+ const worker = async () => {
199
+ while (next < items.length) {
200
+ const at = next++;
201
+ results[at] = await fn(items[at]);
202
+ }
203
+ };
204
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
205
+ return results;
206
+ }
207
+ /** GitHub's own answer to a burst of requests (403 or 429 naming the secondary limit) becomes a message that says what to do. */
208
+ function explainSecondaryLimit(err) {
209
+ if (err instanceof ProviderApiError && (err.status === 403 || err.status === 429) && String(err.details).includes("secondary rate limit")) return /* @__PURE__ */ new Error("GitHub's secondary rate limit stopped the export. It limits bursts of requests, not the hourly quota, and nothing has been written. Wait a couple of minutes and run the command again.");
210
+ return err;
211
+ }
212
+ /**
213
+ * Reads the issues `selector` names, with their comments, priority label, parent and blockers.
214
+ *
215
+ * With `limit`, only that many survivors of the other filters are read, from the `order` end of the creation order. A
216
+ * list of keys is sorted by issue number (which rises with creation in one repository) before anything is fetched, then
217
+ * fetched a few at a time until enough are kept; a listing already carries `created_at`, so it is cut before the
218
+ * per-issue comment and link requests. `info.matched` receives how many the selection matched before the cut.
219
+ */
220
+ async function exportIssues(client, repo, selector, info = {}) {
221
+ return readIssues(client, repo, selector, info).catch((err) => {
222
+ throw explainSecondaryLimit(err);
223
+ });
224
+ }
225
+ async function readIssues(client, repo, selector, info) {
226
+ const [owner = "", name = ""] = repo.split("/", 2);
227
+ const since = selector.since ? Date.parse(selector.since) : void 0;
228
+ const survives = (i) => !i.pull_request && !(selector.openOnly && i.state === "closed") && (since === void 0 || Date.parse(i.updated_at) >= since);
229
+ const { limit, order = "oldest" } = selector;
230
+ let issues;
231
+ if (selector.keys) {
232
+ const fetch = (k) => client.issues.getIssue(repo, parseNumber(k));
233
+ info.matched = selector.keys.length;
234
+ issues = limit === void 0 ? (await mapLimit(selector.keys, EXPORT_CONCURRENCY, fetch)).filter(survives) : await readKeysLimited({
235
+ keys: selector.keys,
236
+ order,
237
+ limit,
238
+ concurrency: EXPORT_CONCURRENCY,
239
+ fetch,
240
+ keep: survives
241
+ });
242
+ } else {
243
+ let raw;
244
+ if (selector.milestone) {
245
+ const milestone = await findMilestone(client, repo, selector.milestone);
246
+ if (!milestone) throw new Error(`Milestone "${selector.milestone}" not found in ${repo}.`);
247
+ raw = await listAll(client, repo, { milestone: String(milestone.number) });
248
+ } else if (selector.all) raw = await listAll(client, repo, {});
249
+ else throw new Error("export needs keys, a milestone or all");
250
+ issues = raw.filter(survives);
251
+ info.matched = issues.length;
252
+ if (limit !== void 0) issues = pickByCreation(issues, (i) => i.created_at ?? i.updated_at, order, limit);
253
+ }
254
+ const records = [];
255
+ for (const issue of issues) {
256
+ reportProgress(info.onProgress, records.length, issues.length);
257
+ const comments = await client.issues.listComments(repo, issue.number);
258
+ const links = (await client.graphql.query(LINK_QUERY, {
259
+ owner,
260
+ name,
261
+ number: issue.number
262
+ }).catch(() => void 0))?.repository.issue;
263
+ const { labels, priority } = splitLabels(issue.labels.map((l) => l.name));
264
+ const body = issue.body?.replace(MARKER, "").trimEnd();
265
+ const migratedFrom = issue.body ? MARKER.exec(issue.body)?.[1] : void 0;
266
+ records.push({
267
+ key: `#${issue.number}`,
268
+ url: issue.html_url,
269
+ title: issue.title,
270
+ ...body ? { body } : {},
271
+ statusCategory: categoryOf(issue),
272
+ closed: issue.state === "closed",
273
+ ...priority ? { priority } : {},
274
+ labels: labels.filter((l) => !l.toLowerCase().startsWith("status:")),
275
+ ...issue.assignee ? { assignee: {
276
+ id: issue.assignee.login,
277
+ displayName: issue.assignee.name ?? issue.assignee.login,
278
+ ...issue.assignee.email ? { emailAddress: issue.assignee.email } : {}
279
+ } } : {},
280
+ ...issue.milestone ? { milestone: issue.milestone.title } : {},
281
+ ...links?.parent ? { parent: `#${links.parent.number}` } : {},
282
+ blockedBy: (links?.blockedBy.nodes ?? []).map((n) => `#${n.number}`),
283
+ comments: comments.map((c) => ({
284
+ ...c.user ? { author: c.user.login } : {},
285
+ body: c.body,
286
+ createdAt: c.created_at
287
+ })),
288
+ createdAt: issue.created_at ?? issue.updated_at,
289
+ updatedAt: issue.updated_at,
290
+ ...migratedFrom ? { migratedFrom } : {}
291
+ });
292
+ }
293
+ reportProgress(info.onProgress, records.length, issues.length);
294
+ return records;
295
+ }
296
+ /** Makes the records as issues, skipping any whose source address an earlier run already recorded. */
297
+ async function importIssues(client, repo, records, options = {}) {
298
+ const existing = /* @__PURE__ */ new Map();
299
+ for (const issue of await listAll(client, repo, {})) {
300
+ const url = issue.body ? MARKER.exec(issue.body)?.[1] : void 0;
301
+ if (url) existing.set(url, issue.number);
302
+ }
303
+ const results = [];
304
+ const tick = () => reportProgress(options.onProgress, results.length, records.length);
305
+ for (const record of records) {
306
+ const found = existing.get(record.url);
307
+ if (found !== void 0) {
308
+ results.push({
309
+ source: record.key,
310
+ key: `#${found}`,
311
+ created: false,
312
+ dropped: []
313
+ });
314
+ tick();
315
+ continue;
316
+ }
317
+ const dropped = droppedFor(record);
318
+ if (options.dryRun) {
319
+ results.push({
320
+ source: record.key,
321
+ created: true,
322
+ dropped
323
+ });
324
+ tick();
325
+ continue;
326
+ }
327
+ const number = await createFromRecord(client, repo, record, options);
328
+ results.push({
329
+ source: record.key,
330
+ key: `#${number}`,
331
+ created: true,
332
+ dropped
333
+ });
334
+ tick();
335
+ }
336
+ return results;
337
+ }
338
+ /** What GitHub cannot hold of this record, for the report. */
339
+ function droppedFor(record) {
340
+ const dropped = ["createdAt (GitHub sets it; the original date is in the text)"];
341
+ if (record.assignee) dropped.push("assignee (not mapped)");
342
+ if (record.parent) dropped.push("parent (not set on GitHub)");
343
+ if (record.blockedBy.length > 0) dropped.push("blockedBy (not set on GitHub)");
344
+ return dropped;
345
+ }
346
+ /** The labels a record becomes on GitHub: its own, plus `priority: <level>` (GitHub's priority is a label). */
347
+ function labelsFor(record) {
348
+ return [...record.labels, ...record.priority && record.priority !== "none" ? [`priority: ${record.priority}`] : []];
349
+ }
350
+ /** The text a comment gets when it is copied: GitHub cannot post as another author or backdate. */
351
+ function renderComment(comment) {
352
+ return `${comment.author ? `**${comment.author}**` : "Someone"} commented on ${comment.createdAt}:\n\n${comment.body}`;
353
+ }
354
+ /** Makes one issue from a record, with its comments, and closes it when the record is closed; returns its number. */
355
+ async function createFromRecord(client, repo, record, options) {
356
+ const milestoneName = options.milestone ?? record.milestone;
357
+ const milestone = milestoneName ? await ensureMilestone(client, repo, milestoneName) : void 0;
358
+ const labels = labelsFor(record);
359
+ const created = await client.issues.createIssue(repo, {
360
+ title: record.title,
361
+ body: withProvenance(record),
362
+ ...labels.length > 0 ? { labels } : {},
363
+ ...milestone !== void 0 ? { milestone } : {}
364
+ });
365
+ for (const comment of record.comments) await client.issues.createComment(repo, created.number, renderComment(comment));
366
+ if (record.closed) await client.issues.updateIssue(repo, created.number, {
367
+ state: "closed",
368
+ state_reason: "completed"
369
+ });
370
+ return created.number;
371
+ }
372
+ /** The text of a migrated issue: the record's body, then where it came from and the marker a second run looks for. */
373
+ function withProvenance(record) {
374
+ return `${record.body ? `${record.body}\n\n` : ""}---\n_Migrated from [${record.key}](${record.url}); created ${record.createdAt}._\n<!-- holocron:migrated-from ${record.url} -->`;
375
+ }
376
+ function parseNumber(key) {
377
+ const n = key.trim().replace(/^.*#/, "");
378
+ if (!/^\d+$/.test(n)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
379
+ return parseInt(n, 10);
380
+ }
381
+ async function listAll(client, repo, extra) {
382
+ const all = [];
383
+ for (let page = 1;; page++) {
384
+ const batch = await client.issues.listIssues(repo, {
385
+ state: "all",
386
+ per_page: 100,
387
+ page,
388
+ ...extra
389
+ });
390
+ all.push(...batch);
391
+ if (batch.length < 100) return all;
392
+ }
393
+ }
394
+ async function findMilestone(client, repo, ref) {
395
+ return (await client.issues.listMilestones(repo, { state: "all" })).find((m) => String(m.number) === ref || m.title.toLowerCase() === ref.toLowerCase());
396
+ }
397
+ async function ensureMilestone(client, repo, title) {
398
+ return (await findMilestone(client, repo, title))?.number ?? (await client.issues.createMilestone(repo, title)).number;
399
+ }
400
+ // src/capabilities/issues-sync.ts
401
+ const COMMENT_HEADER = /^(?:\*\*.*?\*\*|Someone) commented on (\S+?):\n/;
402
+ /** Whether GitHub is asking to be left alone: a 429, or a 403 that says it is a rate limit. */
403
+ function isRateLimited(err) {
404
+ return err instanceof ProviderApiError && (err.status === 429 || err.status === 403 && /rate limit/i.test(`${err.message} ${String(err.details)}`));
405
+ }
406
+ const normalize = (text) => text.replace(/\r\n/g, "\n").trim();
407
+ const sorted = (names) => names.map((n) => n.toLowerCase()).sort().join("\n");
408
+ /** Makes the records missing from the repo and updates the ones an earlier run made. See the file comment. */
409
+ async function syncIssues(client, repo, records, options = {}) {
410
+ const existing = /* @__PURE__ */ new Map();
411
+ for (const issue of await listAll(client, repo, {})) {
412
+ const url = issue.body ? MARKER.exec(issue.body)?.[1] : void 0;
413
+ if (url) existing.set(url, issue);
414
+ }
415
+ const results = [];
416
+ for (const record of records) try {
417
+ const found = existing.get(record.url);
418
+ results.push(found ? await update(client, repo, found, record, options) : await create(client, repo, record, options));
419
+ reportProgress(options.onProgress, results.length, records.length);
420
+ } catch (err) {
421
+ if (isRateLimited(err)) return results;
422
+ throw err;
423
+ }
424
+ return results;
425
+ }
426
+ async function create(client, repo, record, options) {
427
+ const dropped = droppedFor(record);
428
+ if (options.dryRun) return {
429
+ source: record.key,
430
+ created: true,
431
+ updated: false,
432
+ changes: [],
433
+ dropped
434
+ };
435
+ const number = await createFromRecord(client, repo, record, options);
436
+ return {
437
+ source: record.key,
438
+ key: `#${number}`,
439
+ created: true,
440
+ updated: false,
441
+ changes: [],
442
+ dropped
443
+ };
444
+ }
445
+ async function update(client, repo, issue, record, options) {
446
+ const patch = {};
447
+ const changes = [];
448
+ if (issue.title !== record.title) {
449
+ patch.title = record.title;
450
+ changes.push("title");
451
+ }
452
+ const body = withProvenance(record);
453
+ if (normalize(issue.body) !== normalize(body)) {
454
+ patch.body = body;
455
+ changes.push("description");
456
+ }
457
+ if (issue.state === "closed" !== record.closed) {
458
+ patch.state = record.closed ? "closed" : "open";
459
+ if (record.closed) patch.state_reason = "completed";
460
+ changes.push("state");
461
+ }
462
+ const current = issue.labels.map((l) => l.name);
463
+ const finalLabels = [...labelsFor(record), ...current.filter((n) => n.toLowerCase().startsWith("status:"))];
464
+ if (sorted(finalLabels) !== sorted(current)) {
465
+ patch.labels = finalLabels;
466
+ changes.push("labels");
467
+ }
468
+ const comments = await client.issues.listComments(repo, issue.number);
469
+ const seen = new Set(comments.map((c) => COMMENT_HEADER.exec(c.body)?.[1]));
470
+ const missing = record.comments.filter((c) => !seen.has(c.createdAt));
471
+ if (missing.length > 0) changes.push("comments");
472
+ if (!options.dryRun) {
473
+ if (Object.keys(patch).length > 0) await client.issues.updateIssue(repo, issue.number, patch);
474
+ for (const comment of missing) await client.issues.createComment(repo, issue.number, renderComment(comment));
475
+ }
476
+ return {
477
+ source: record.key,
478
+ key: `#${issue.number}`,
479
+ created: false,
480
+ updated: changes.length > 0,
481
+ changes,
482
+ dropped: droppedFor(record)
483
+ };
484
+ }
485
+ // src/capabilities/issues.ts
486
+ /** Issues read per request when `search` has a `text`; GitHub's largest page. */
487
+ const TEXT_PAGE_SIZE = 100;
488
+ /** Pages `search` reads for a `text` before it stops (the 2000 most recently updated issues). */
489
+ const TEXT_MAX_PAGES = 20;
490
+ /** The GitHub implementation of the `issues` capability, backed by GitHub Issues and status labels. */
124
491
  var GitHubIssues = class {
125
492
  client;
493
+ /** Capability key, always `"issues"`. */
126
494
  key = "issues";
495
+ /** Provider name, always `"github"`. */
127
496
  providerName = "github";
128
497
  owner;
129
498
  repoName;
@@ -145,7 +514,18 @@ var GitHubIssues = class {
145
514
  ...raw.email ? { emailAddress: raw.email } : {}
146
515
  };
147
516
  }
517
+ /**
518
+ * Searches the repo's issues, newest update first, leaving out pull requests.
519
+ *
520
+ * With `filter.text`, the issues are paged (newest update first, 100 a page, at most 20 pages) and compared
521
+ * client-side, without regard to case, until `limit` matches are found or the issues run out. GitHub's issue
522
+ * search (`GET /search/issues`) is deliberately not used: it indexes the words of a body, so punctuation and an
523
+ * HTML comment such as `<!-- holocron:idea notes/a.md -->` cannot be relied on to match, it is limited to 30
524
+ * requests a minute and it is eventually consistent (an issue made a moment ago may be missing). Paging has none
525
+ * of those limits, at the cost of reading up to 20 pages of a very large repository.
526
+ */
148
527
  async search(filter) {
528
+ if (filter.text) return this.searchText(filter, filter.text);
149
529
  const params = {
150
530
  state: filter.openOnly ? "open" : "all",
151
531
  sort: "updated",
@@ -164,11 +544,39 @@ var GitHubIssues = class {
164
544
  assignee: params.assignee
165
545
  })).filter((i) => !i.pull_request).map((i) => this.mapIssue(i));
166
546
  }
547
+ async searchText(filter, text) {
548
+ const needle = text.toLowerCase();
549
+ const limit = filter.limit ?? 50;
550
+ const assignee = filter.assignee === "currentUser" ? (await this.getMyself()).id : filter.assignee || void 0;
551
+ const found = [];
552
+ for (let page = 1; page <= TEXT_MAX_PAGES; page++) {
553
+ const raw = await this.client.issues.listIssues(this.repo, {
554
+ state: filter.openOnly ? "open" : "all",
555
+ sort: "updated",
556
+ direction: "desc",
557
+ per_page: TEXT_PAGE_SIZE,
558
+ page,
559
+ filter: "all",
560
+ ...assignee ? { assignee } : {}
561
+ });
562
+ for (const issue of raw) {
563
+ if (issue.pull_request) continue;
564
+ if (`${issue.title}\n${issue.body ?? ""}`.toLowerCase().includes(needle)) {
565
+ found.push(this.mapIssue(issue));
566
+ if (found.length >= limit) return found;
567
+ }
568
+ }
569
+ if (raw.length < TEXT_PAGE_SIZE) break;
570
+ }
571
+ return found;
572
+ }
573
+ /** Fetches one issue by its key. */
167
574
  async get(key) {
168
575
  const number = parseIssueNumber(key, this.owner, this.repoName);
169
576
  const raw = await this.client.issues.getIssue(this.repo, number);
170
577
  return this.mapIssue(raw);
171
578
  }
579
+ /** Creates an issue and returns its key as `#<number>`; the milestone is resolved from its title. */
172
580
  async create(input) {
173
581
  const body = { title: input.summary };
174
582
  if (input.body) body.body = input.body;
@@ -216,10 +624,12 @@ var GitHubIssues = class {
216
624
  via: `label set to ${targetLabel}`
217
625
  };
218
626
  }
627
+ /** Adds a comment to the issue with the given key. */
219
628
  async comment(key, body) {
220
629
  const number = parseIssueNumber(key, this.owner, this.repoName);
221
630
  await this.client.issues.createComment(this.repo, number, body);
222
631
  }
632
+ /** Checks the authenticated user, the repo and the label-backed lifecycle slots, and reports what resolves. */
223
633
  async doctor() {
224
634
  const me = await this.getMyself();
225
635
  const repo = await this.client.repos.getRepo(this.repo);
@@ -288,6 +698,18 @@ var GitHubIssues = class {
288
698
  lifecycle
289
699
  };
290
700
  }
701
+ /** Reads issues with their comments, priority label, parent and blockers, for `holocron migrate`. */
702
+ export(selector, info) {
703
+ return exportIssues(this.client, this.repo, selector, info);
704
+ }
705
+ /** Makes the records as issues; one already made by an earlier run is skipped. */
706
+ import(records, options) {
707
+ return importIssues(this.client, this.repo, records, options);
708
+ }
709
+ /** Like `import`, and it also updates what an earlier import or sync made, for `holocron sync issues`. */
710
+ sync(records, options) {
711
+ return syncIssues(this.client, this.repo, records, options);
712
+ }
291
713
  async resolveMilestone(ref) {
292
714
  if (/^\d+$/.test(ref)) return parseInt(ref, 10);
293
715
  const match = (await this.client.issues.listMilestones(this.repo, { state: "all" })).find((m) => m.title.toLowerCase() === ref.toLowerCase());
@@ -337,8 +759,584 @@ function parseIssueNumber(key, owner, repoName) {
337
759
  if (!/^\d+$/.test(num)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
338
760
  return parseInt(num, 10);
339
761
  }
340
- //#endregion
341
- //#region src/sodium.ts
762
+ // src/capabilities/projects.ts
763
+ /** The name of the single select field that holds an item's status. */
764
+ const STATUS_FIELD = "Status";
765
+ /** The name of the field `move` carries over besides the status. */
766
+ const PRIORITY_FIELD = "Priority";
767
+ const P_LEVELS = {
768
+ p0: "urgent",
769
+ p1: "high",
770
+ p2: "medium",
771
+ p3: "low"
772
+ };
773
+ /** The neutral priority a `Priority` option stands for, by its name; undefined for a name that is not one of them. */
774
+ function priorityOf(value) {
775
+ const name = value?.trim().toLowerCase();
776
+ if (name === "urgent" || name === "high" || name === "medium" || name === "low") return name;
777
+ return name ? P_LEVELS[name] : void 0;
778
+ }
779
+ const PROJECT_FIELDS = "id number title closed url";
780
+ const BOARD_QUERY = `query($id: ID!) { node(id: $id) { ... on ProjectV2 {
781
+ ${PROJECT_FIELDS}
782
+ fields(first: 100) { nodes {
783
+ ... on ProjectV2FieldCommon { id name dataType }
784
+ ... on ProjectV2SingleSelectField { options { id name color description } }
785
+ } }
786
+ views(first: 50) { nodes { id name layout filter groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } } } }
787
+ workflows(first: 50) { nodes { id name enabled } }
788
+ } } }`;
789
+ const ITEMS_QUERY = `query($id: ID!, $after: String) { node(id: $id) { ... on ProjectV2 {
790
+ items(first: 100, after: $after) {
791
+ pageInfo { hasNextPage endCursor }
792
+ nodes {
793
+ id
794
+ fieldValues(first: 30) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } } } }
795
+ content {
796
+ __typename
797
+ ... on Issue { id number title state repository { nameWithOwner } blockedBy(first: 50) { nodes { number state repository { nameWithOwner } } } }
798
+ ... on PullRequest { id number title state repository { nameWithOwner } }
799
+ }
800
+ }
801
+ }
802
+ } } }`;
803
+ const LIST_QUERY = `query($owner: String!, $after: String) { organization(login: $owner) { id projectsV2(first: 100, after: $after) {
804
+ pageInfo { hasNextPage endCursor }
805
+ nodes { ${PROJECT_FIELDS} }
806
+ } } }`;
807
+ const OWNER_QUERY = "query($owner: String!) { organization(login: $owner) { id } }";
808
+ const SET_VALUE = `mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
809
+ updateProjectV2ItemFieldValue(input: { projectId: $project, itemId: $item, fieldId: $field, value: { singleSelectOptionId: $option } }) { projectV2Item { id } }
810
+ }`;
811
+ const UPDATE_OPTIONS = `mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
812
+ updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) { projectV2Field { ... on ProjectV2SingleSelectField { id } } }
813
+ }`;
814
+ const DATA_TYPES = {
815
+ single_select: "SINGLE_SELECT",
816
+ text: "TEXT",
817
+ number: "NUMBER",
818
+ date: "DATE",
819
+ iteration: "ITERATION"
820
+ };
821
+ function fieldTypeOf(dataType) {
822
+ return Object.keys(DATA_TYPES).find((t) => DATA_TYPES[t] === dataType);
823
+ }
824
+ function toSummary(n) {
825
+ return {
826
+ id: n.id,
827
+ number: n.number,
828
+ name: n.title,
829
+ url: n.url,
830
+ closed: n.closed
831
+ };
832
+ }
833
+ function toOptionInput(o) {
834
+ return {
835
+ ...o.id ? { id: o.id } : {},
836
+ name: o.name,
837
+ color: (o.color ?? "GRAY").toUpperCase(),
838
+ description: o.description ?? ""
839
+ };
840
+ }
841
+ /** The GitHub implementation of the `projects` capability, backed by Projects v2. */
842
+ var GitHubProjects = class {
843
+ graphql;
844
+ /** Capability key, always `"projects"`. */
845
+ key = "projects";
846
+ /** Provider name, always `"github"`. */
847
+ providerName = "github";
848
+ repo;
849
+ constructor(graphql, opts) {
850
+ this.graphql = graphql;
851
+ this.repo = opts.repo;
852
+ }
853
+ /** Every board of the organization, open and closed. */
854
+ async list(owner) {
855
+ const boards = [];
856
+ let after = null;
857
+ for (;;) {
858
+ const page = (await this.graphql.query(LIST_QUERY, {
859
+ owner,
860
+ after
861
+ })).organization.projectsV2;
862
+ boards.push(...page.nodes.map(toSummary));
863
+ if (!page.pageInfo.hasNextPage) return boards;
864
+ after = page.pageInfo.endCursor;
865
+ }
866
+ }
867
+ /** The open board with exactly this name. */
868
+ async find(owner, name) {
869
+ return (await this.list(owner)).find((b) => !b.closed && b.name === name);
870
+ }
871
+ /**
872
+ * Makes a board. With `template.source` naming an open board it is copied (fields, options, views and automation
873
+ * rules, no items); otherwise the board is created and brought in line with the template.
874
+ */
875
+ async create(owner, name, template) {
876
+ const ownerId = await this.ownerId(owner);
877
+ const source = template.source ? await this.find(owner, template.source) : void 0;
878
+ if (source) return toSummary((await this.graphql.query(`mutation($project: ID!, $owner: ID!, $title: String!) {
879
+ copyProjectV2(input: { projectId: $project, ownerId: $owner, title: $title, includeDraftIssues: false }) { projectV2 { ${PROJECT_FIELDS} } }
880
+ }`, {
881
+ project: source.id,
882
+ owner: ownerId,
883
+ title: name
884
+ })).copyProjectV2.projectV2);
885
+ const board = toSummary((await this.graphql.query(`mutation($owner: ID!, $title: String!) {
886
+ createProjectV2(input: { ownerId: $owner, title: $title }) { projectV2 { ${PROJECT_FIELDS} } }
887
+ }`, {
888
+ owner: ownerId,
889
+ title: name
890
+ })).createProjectV2.projectV2);
891
+ await this.ensure(owner, board, template);
892
+ return board;
893
+ }
894
+ /** Closes the board; `updateProjectV2` with `closed: true` is the only way, and reversible. */
895
+ async close(_owner, board) {
896
+ await this.graphql.query(`mutation($project: ID!) { updateProjectV2(input: { projectId: $project, closed: true }) { projectV2 { id } } }`, { project: board.id });
897
+ }
898
+ /** The live board: statuses, other fields, views and automation rules. */
899
+ async read(_owner, board) {
900
+ const raw = await this.readRaw(board.id);
901
+ const status = raw.fields.find((f) => f.name === STATUS_FIELD);
902
+ const fields = [];
903
+ for (const f of raw.fields) {
904
+ const type = fieldTypeOf(f.dataType);
905
+ if (!type || f.name === STATUS_FIELD) continue;
906
+ fields.push({
907
+ name: f.name,
908
+ type,
909
+ ...f.options ? { options: f.options.map((o) => o.name) } : {}
910
+ });
911
+ }
912
+ const views = [];
913
+ for (const v of raw.views) {
914
+ const layout = v.layout === "BOARD_LAYOUT" ? "board" : v.layout === "TABLE_LAYOUT" ? "table" : void 0;
915
+ if (!layout) continue;
916
+ views.push({
917
+ name: v.name,
918
+ layout,
919
+ ...v.filter ? { filter: v.filter } : {},
920
+ ...v.groupBy[0] ? { groupBy: v.groupBy[0] } : {}
921
+ });
922
+ }
923
+ return {
924
+ board: raw.summary,
925
+ statuses: (status?.options ?? []).map((o) => o.name),
926
+ fields,
927
+ views,
928
+ rules: raw.rules.map(({ name, enabled }) => ({
929
+ name,
930
+ enabled
931
+ }))
932
+ };
933
+ }
934
+ /**
935
+ * Brings the board in line with the template. Never deletes a field, option or view, and never sends an option
936
+ * list without ids. Stops (throws) when the status field cannot be read.
937
+ */
938
+ async ensure(_owner, board, template) {
939
+ const raw = await this.readRaw(board.id);
940
+ const status = raw.fields.find((f) => f.name === STATUS_FIELD && f.options);
941
+ 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`);
942
+ const changes = [];
943
+ const todos = [];
944
+ const statusChanges = await this.ensureOptions(board, status, template.statuses.map((s) => ({
945
+ name: s.name,
946
+ color: s.color,
947
+ description: s.description
948
+ })));
949
+ changes.push(...statusChanges.map((c) => `status ${c}`));
950
+ for (const want of template.fields) {
951
+ const have = raw.fields.find((f) => f.name === want.name);
952
+ if (!have) {
953
+ await this.createField(board.id, want);
954
+ changes.push(`field ${want.name} created`);
955
+ continue;
956
+ }
957
+ if (fieldTypeOf(have.dataType) !== want.type) {
958
+ todos.push(`field "${want.name}" is ${have.dataType} but the template says ${want.type}; change it by hand`);
959
+ continue;
960
+ }
961
+ if (want.type === "single_select" && have.options && want.options) {
962
+ const diff = await this.ensureOptions(board, have, want.options.map((name) => ({ name })));
963
+ changes.push(...diff.map((c) => `field ${want.name} option ${c}`));
964
+ }
965
+ }
966
+ for (const want of template.views) {
967
+ const have = raw.views.find((v) => v.name === want.name);
968
+ const layout = want.layout === "board" ? "BOARD_LAYOUT" : "TABLE_LAYOUT";
969
+ let viewId = have?.id;
970
+ if (!viewId) {
971
+ viewId = await this.createView(board.id, want.name, layout);
972
+ changes.push(`view ${want.name} created`);
973
+ } else if (have && have.layout !== layout) {
974
+ await this.updateView(viewId, { layout });
975
+ changes.push(`view ${want.name} layout set to ${want.layout}`);
976
+ }
977
+ if (want.filter !== void 0 && want.filter !== (have?.filter ?? "")) {
978
+ await this.updateView(viewId, { filter: want.filter });
979
+ changes.push(`view ${want.name} filter set`);
980
+ }
981
+ 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`);
982
+ }
983
+ for (const rule of template.rules ?? []) {
984
+ const have = raw.rules.find((r) => r.name === rule);
985
+ if (!have) todos.push(`create the automation rule "${rule}"; the API cannot create one`);
986
+ else if (!have.enabled) todos.push(`turn on the automation rule "${rule}"; the API cannot switch one`);
987
+ }
988
+ return {
989
+ created: false,
990
+ changes,
991
+ todos
992
+ };
993
+ }
994
+ /**
995
+ * Makes a single select field hold the wanted options in order, every existing one sent with its id. Options the
996
+ * template does not mention stay, after the wanted ones. Returns what changed (`Blocked added`, `order set`).
997
+ */
998
+ async ensureOptions(board, field, wanted) {
999
+ /* v8 ignore next -- every caller passes a field it found with options; the fallback only satisfies the type */
1000
+ const existing = field.options ?? [];
1001
+ const next = [];
1002
+ const changes = [];
1003
+ for (const w of wanted) {
1004
+ const have = existing.find((o) => o.name === w.name);
1005
+ if (have) {
1006
+ next.push({
1007
+ id: have.id,
1008
+ name: have.name,
1009
+ color: (w.color ?? have.color).toUpperCase(),
1010
+ description: w.description ?? have.description
1011
+ });
1012
+ if (next[next.length - 1].color !== have.color || next[next.length - 1].description !== have.description) changes.push(`${have.name} updated`);
1013
+ } else {
1014
+ next.push({
1015
+ name: w.name,
1016
+ color: (w.color ?? "GRAY").toUpperCase(),
1017
+ description: w.description ?? ""
1018
+ });
1019
+ changes.push(`${w.name} added`);
1020
+ }
1021
+ }
1022
+ for (const have of existing) if (!next.some((n) => n.id === have.id)) next.push({ ...have });
1023
+ const sameOrder = next.length === existing.length && next.every((n, i) => n.id === existing[i]?.id);
1024
+ if (changes.length === 0 && sameOrder) return [];
1025
+ if (sameOrder === false && !changes.some((c) => c.endsWith("added"))) changes.push("order set");
1026
+ const before = await this.fetchItems(board.id);
1027
+ const snapshot = /* @__PURE__ */ new Map();
1028
+ for (const item of before) {
1029
+ const value = item.values[field.name];
1030
+ if (value) snapshot.set(item.id, value);
1031
+ }
1032
+ await this.graphql.query(UPDATE_OPTIONS, {
1033
+ field: field.id,
1034
+ options: next.map(toOptionInput)
1035
+ });
1036
+ const afterField = (await this.readRaw(board.id)).fields.find((f) => f.id === field.id);
1037
+ const afterItems = await this.fetchItems(board.id);
1038
+ const afterValues = new Map(afterItems.map((i) => [i.id, i.values[field.name]]));
1039
+ const lost = [...snapshot].filter(([itemId]) => !afterValues.get(itemId));
1040
+ if (lost.length > 0) {
1041
+ let restored = 0;
1042
+ for (const [itemId, name] of lost) {
1043
+ const option = afterField?.options?.find((o) => o.name === name);
1044
+ if (!option) continue;
1045
+ await this.graphql.query(SET_VALUE, {
1046
+ project: board.id,
1047
+ item: itemId,
1048
+ field: field.id,
1049
+ option: option.id
1050
+ });
1051
+ restored++;
1052
+ }
1053
+ throw new Error(`updating "${field.name}" on board "${board.name}" blanked ${lost.length} item value(s); wrote ${restored} back by option name`);
1054
+ }
1055
+ return changes;
1056
+ }
1057
+ async createField(projectId, want) {
1058
+ await this.graphql.query(`mutation($project: ID!, $name: String!, $type: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]) {
1059
+ createProjectV2Field(input: { projectId: $project, name: $name, dataType: $type, singleSelectOptions: $options }) { projectV2Field { ... on ProjectV2SingleSelectField { id } } }
1060
+ }`, {
1061
+ project: projectId,
1062
+ name: want.name,
1063
+ type: DATA_TYPES[want.type],
1064
+ options: want.type === "single_select" ? (want.options ?? []).map((name) => toOptionInput({ name })) : void 0
1065
+ });
1066
+ }
1067
+ async createView(projectId, name, layout) {
1068
+ return (await this.graphql.query(`mutation($project: ID!, $name: String!, $layout: ProjectV2ViewLayout!) {
1069
+ createProjectV2View(input: { projectId: $project, name: $name, layout: $layout }) { projectV2View { id } }
1070
+ }`, {
1071
+ project: projectId,
1072
+ name,
1073
+ layout
1074
+ })).createProjectV2View.projectV2View.id;
1075
+ }
1076
+ async updateView(viewId, change) {
1077
+ await this.graphql.query(`mutation($view: ID!, $layout: ProjectV2ViewLayout, $filter: String) {
1078
+ updateProjectV2View(input: { viewId: $view, layout: $layout, filter: $filter }) { projectV2View { id } }
1079
+ }`, {
1080
+ view: viewId,
1081
+ layout: change.layout,
1082
+ filter: change.filter
1083
+ });
1084
+ }
1085
+ /** The board's issues and pull requests with their status, whether they are open and their open blockers. */
1086
+ async items(_owner, board, template) {
1087
+ const categories = new Map(template.statuses.map((s) => [s.name, s.category]));
1088
+ return (await this.fetchItems(board.id)).map((i) => {
1089
+ const status = i.values[STATUS_FIELD];
1090
+ const category = status ? categories.get(status) : void 0;
1091
+ const priority = priorityOf(i.values[PRIORITY_FIELD]);
1092
+ return {
1093
+ key: this.keyOf(i.repo, i.number),
1094
+ title: i.title,
1095
+ open: i.open,
1096
+ ...status ? { status } : {},
1097
+ ...category ? { category } : {},
1098
+ blockedBy: i.blockers.map((b) => this.keyOf(b.repo, b.number)),
1099
+ ...priority ? { priority } : {}
1100
+ };
1101
+ });
1102
+ }
1103
+ /** Puts an item in a status; `transitioned` is false when it already is there. */
1104
+ async setStatus(_owner, board, key, status) {
1105
+ const ref = this.parseKey(key);
1106
+ const field = (await this.readRaw(board.id)).fields.find((f) => f.name === STATUS_FIELD && f.options);
1107
+ const option = field?.options?.find((o) => o.name === status);
1108
+ if (!field || !option) throw new Error(`board "${board.name}" has no status "${status}"`);
1109
+ const item = (await this.fetchItems(board.id)).find((i) => i.repo === ref.repo && i.number === ref.number);
1110
+ if (!item) throw new Error(`${key} is not on board "${board.name}"`);
1111
+ if (item.values[STATUS_FIELD] === status) return {
1112
+ transitioned: false,
1113
+ status
1114
+ };
1115
+ await this.graphql.query(SET_VALUE, {
1116
+ project: board.id,
1117
+ item: item.id,
1118
+ field: field.id,
1119
+ option: option.id
1120
+ });
1121
+ return {
1122
+ transitioned: true,
1123
+ status,
1124
+ via: "project field set"
1125
+ };
1126
+ }
1127
+ /**
1128
+ * Puts the open items of `from` on `to` with their status and priority, only where the target has no value;
1129
+ * closed and merged ones stay, and are set to the template's done status when theirs is not a done one.
1130
+ */
1131
+ async move(_owner, from, to, template) {
1132
+ const report = {
1133
+ moved: [],
1134
+ kept: [],
1135
+ left: []
1136
+ };
1137
+ const [source, target, fromRaw, toRaw] = await Promise.all([
1138
+ this.fetchItems(from.id),
1139
+ this.fetchItems(to.id),
1140
+ this.readRaw(from.id),
1141
+ this.readRaw(to.id)
1142
+ ]);
1143
+ const done = template.statuses.find((s) => s.category === "done")?.name;
1144
+ const doneNames = new Set(template.statuses.filter((s) => s.category === "done").map((s) => s.name));
1145
+ const carried = [STATUS_FIELD, PRIORITY_FIELD];
1146
+ for (const item of source) {
1147
+ const key = this.keyOf(item.repo, item.number);
1148
+ if (!item.open) {
1149
+ const current = item.values[STATUS_FIELD];
1150
+ if (done && (!current || !doneNames.has(current))) await this.setOption(from.id, fromRaw, item.id, STATUS_FIELD, done);
1151
+ report.left.push(key);
1152
+ continue;
1153
+ }
1154
+ let targetItem = target.find((t) => t.contentId === item.contentId);
1155
+ if (targetItem) report.kept.push(key);
1156
+ else {
1157
+ const data = await this.graphql.query(`mutation($project: ID!, $content: ID!) { addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { item { id } } }`, {
1158
+ project: to.id,
1159
+ content: item.contentId
1160
+ });
1161
+ targetItem = {
1162
+ ...item,
1163
+ id: data.addProjectV2ItemById.item.id,
1164
+ values: {}
1165
+ };
1166
+ report.moved.push(key);
1167
+ }
1168
+ for (const name of carried) {
1169
+ const value = item.values[name];
1170
+ if (value && !targetItem.values[name]) await this.setOption(to.id, toRaw, targetItem.id, name, value);
1171
+ }
1172
+ }
1173
+ return report;
1174
+ }
1175
+ /**
1176
+ * Puts issues on the board (`addProjectV2ItemById`, the content id the issue's node id) and sets Status and Priority
1177
+ * through the single select update, only where the item has no value yet. A status or priority with no option on
1178
+ * the board is reported in `dropped`; no option is ever created, so no value is cleared.
1179
+ */
1180
+ async addItems(_owner, board, items, template) {
1181
+ const raw = await this.readRaw(board.id);
1182
+ const onBoard = await this.fetchItems(board.id);
1183
+ const statusField = raw.fields.find((f) => f.name === STATUS_FIELD && f.options);
1184
+ const priorityField = raw.fields.find((f) => f.name === PRIORITY_FIELD && f.options);
1185
+ const results = [];
1186
+ for (const input of items) {
1187
+ const ref = this.parseKey(input.key);
1188
+ let current = onBoard.find((i) => i.repo === ref.repo && i.number === ref.number);
1189
+ const added = !current;
1190
+ if (!current) current = {
1191
+ id: (await this.graphql.query(`mutation($project: ID!, $content: ID!) { addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { item { id } } }`, {
1192
+ project: board.id,
1193
+ content: await this.nodeId(ref)
1194
+ })).addProjectV2ItemById.item.id,
1195
+ contentId: "",
1196
+ repo: ref.repo,
1197
+ number: ref.number,
1198
+ title: "",
1199
+ open: true,
1200
+ values: {},
1201
+ blockers: []
1202
+ };
1203
+ const dropped = [];
1204
+ if (input.status !== void 0 || input.category !== void 0) {
1205
+ const name = this.statusFor(input, template, statusField?.options);
1206
+ if (!name) dropped.push(`status ${input.status ?? input.category} (no matching option on the board)`);
1207
+ else if (!current.values[STATUS_FIELD]) await this.setOption(board.id, raw, current.id, STATUS_FIELD, name);
1208
+ }
1209
+ if (input.priority !== void 0 && input.priority !== "none") {
1210
+ const option = priorityField?.options?.find((o) => priorityOf(o.name) === input.priority);
1211
+ if (!option) dropped.push(`priority ${input.priority} (no matching option on the board)`);
1212
+ else if (!current.values[PRIORITY_FIELD]) await this.setOption(board.id, raw, current.id, PRIORITY_FIELD, option.name);
1213
+ }
1214
+ results.push({
1215
+ key: input.key,
1216
+ added,
1217
+ dropped
1218
+ });
1219
+ }
1220
+ return results;
1221
+ }
1222
+ /** The board's own status for an input: the option named like its status, else the template's status of its category. */
1223
+ statusFor(input, template, options) {
1224
+ const has = (name) => options?.find((o) => o.name.toLowerCase() === name.toLowerCase())?.name;
1225
+ const named = input.status === void 0 ? void 0 : has(input.status);
1226
+ if (named) return named;
1227
+ const byCategory = template.statuses.find((s) => s.category === input.category);
1228
+ return byCategory ? has(byCategory.name) : void 0;
1229
+ }
1230
+ /** The node id of an issue, which `addProjectV2ItemById` takes as the content id. */
1231
+ async nodeId(ref) {
1232
+ const [owner, name] = ref.repo.split("/");
1233
+ const id = (await this.graphql.query(`query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { issue(number: $number) { id } } }`, {
1234
+ owner,
1235
+ name,
1236
+ number: ref.number
1237
+ })).repository?.issue?.id;
1238
+ if (!id) throw new Error(`issue ${ref.repo}#${ref.number} not found`);
1239
+ return id;
1240
+ }
1241
+ /** A `holocron doctor` smoke check: how many boards the token sees in the configured repository's organization. */
1242
+ async whoami() {
1243
+ try {
1244
+ return {
1245
+ ok: true,
1246
+ boards: (await this.list(this.repo.split("/")[0] ?? "")).length
1247
+ };
1248
+ } catch {
1249
+ return {
1250
+ ok: false,
1251
+ boards: 0
1252
+ };
1253
+ }
1254
+ }
1255
+ /** Sets a single select value by option name; a field or option the board lacks is skipped. */
1256
+ async setOption(projectId, raw, itemId, fieldName, optionName) {
1257
+ const field = raw.fields.find((f) => f.name === fieldName && f.options);
1258
+ const option = field?.options?.find((o) => o.name === optionName);
1259
+ if (!field || !option) return;
1260
+ await this.graphql.query(SET_VALUE, {
1261
+ project: projectId,
1262
+ item: itemId,
1263
+ field: field.id,
1264
+ option: option.id
1265
+ });
1266
+ }
1267
+ async ownerId(owner) {
1268
+ return (await this.graphql.query(OWNER_QUERY, { owner })).organization.id;
1269
+ }
1270
+ async readRaw(id) {
1271
+ const n = (await this.graphql.query(BOARD_QUERY, { id })).node;
1272
+ if (!n) throw new Error(`board ${id} not found`);
1273
+ return {
1274
+ summary: toSummary(n),
1275
+ fields: n.fields.nodes.filter((f) => f.id).map((f) => ({
1276
+ id: f.id,
1277
+ name: f.name,
1278
+ dataType: f.dataType,
1279
+ ...f.options ? { options: f.options } : {}
1280
+ })),
1281
+ views: n.views.nodes.map((v) => ({
1282
+ id: v.id,
1283
+ name: v.name,
1284
+ layout: v.layout,
1285
+ filter: v.filter,
1286
+ groupBy: (v.groupByFields?.nodes ?? []).map((g) => g.name)
1287
+ })),
1288
+ rules: n.workflows.nodes.map((w) => ({
1289
+ id: w.id,
1290
+ name: w.name,
1291
+ enabled: w.enabled
1292
+ }))
1293
+ };
1294
+ }
1295
+ async fetchItems(id) {
1296
+ const items = [];
1297
+ let after = null;
1298
+ for (;;) {
1299
+ const page = (await this.graphql.query(ITEMS_QUERY, {
1300
+ id,
1301
+ after
1302
+ })).node.items;
1303
+ for (const n of page.nodes) {
1304
+ const c = n.content;
1305
+ if (!c || !c.repository) continue;
1306
+ const values = {};
1307
+ for (const v of n.fieldValues.nodes) if (v.name && v.field?.name) values[v.field.name] = v.name;
1308
+ items.push({
1309
+ id: n.id,
1310
+ contentId: c.id,
1311
+ repo: c.repository.nameWithOwner,
1312
+ number: c.number,
1313
+ title: c.title,
1314
+ open: c.state === "OPEN",
1315
+ values,
1316
+ blockers: (c.blockedBy?.nodes ?? []).filter((b) => b.state === "OPEN").map((b) => ({
1317
+ repo: b.repository.nameWithOwner,
1318
+ number: b.number
1319
+ }))
1320
+ });
1321
+ }
1322
+ if (!page.pageInfo.hasNextPage) return items;
1323
+ after = page.pageInfo.endCursor;
1324
+ }
1325
+ }
1326
+ keyOf(repo, number) {
1327
+ return repo === this.repo ? `#${number}` : `${repo}#${number}`;
1328
+ }
1329
+ parseKey(key) {
1330
+ const m = /^(?:([^#\s]+))?#?(\d+)$/.exec(key.trim());
1331
+ const hasRepo = key.includes("#") && m?.[1];
1332
+ if (!m) throw new Error(`not an issue key: ${key}`);
1333
+ return {
1334
+ repo: hasRepo ? m[1] : this.repo,
1335
+ number: Number(m[2])
1336
+ };
1337
+ }
1338
+ };
1339
+ // src/sodium.ts
342
1340
  /**
343
1341
  * libsodium sealed-box encryption for GitHub Actions secrets.
344
1342
  *
@@ -369,32 +1367,86 @@ async function encryptSecret(publicKeyBase64, value) {
369
1367
  const ciphertext = sodium.crypto_box_seal(plaintext, publicKey);
370
1368
  return sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
371
1369
  }
372
- //#endregion
373
- //#region src/capabilities/secrets.ts
1370
+ // src/capabilities/secrets.ts
1371
+ /** The GitHub implementation of the `secrets` capability, covering repository, environment and organization secrets. */
374
1372
  var GitHubSecrets = class {
375
1373
  client;
376
1374
  opts;
1375
+ orgClient;
1376
+ /** Capability key, always `"secrets"`. */
377
1377
  key = "secrets";
1378
+ /** Provider name, always `"github"`. */
378
1379
  providerName = "github";
379
- constructor(client, opts) {
1380
+ /**
1381
+ * `client` handles repo and environment scopes; `orgClient`, when given, handles
1382
+ * the organization scope. They differ when a dedicated org-only token exists:
1383
+ * it can write org secrets but not repo ones, and `github.admin` is the reverse.
1384
+ */
1385
+ constructor(client, opts, orgClient) {
380
1386
  this.client = client;
381
1387
  this.opts = opts;
1388
+ this.orgClient = orgClient;
1389
+ }
1390
+ /** Repository name to numeric id, so one run asks GitHub once per repository however many secrets name it. */
1391
+ repoIds = /* @__PURE__ */ new Map();
1392
+ clientFor(scope) {
1393
+ return scope.kind === "organization" ? this.orgClient ?? this.client : this.client;
382
1394
  }
383
1395
  async listSecrets(scope) {
384
- return this.client.secrets.listSecrets(this.opts.repo, toGHScope(scope));
1396
+ return this.clientFor(scope).secrets.listSecrets(this.opts.repo, toGHScope(scope));
385
1397
  }
386
1398
  async setSecret(scope, name, value) {
387
1399
  const ghScope = toGHScope(scope);
388
- const pk = await this.client.secrets.getPublicKey(this.opts.repo, ghScope);
1400
+ const client = this.clientFor(scope);
1401
+ const readers = scope.kind === "organization" ? await this.readers(scope) : {};
1402
+ const pk = await client.secrets.getPublicKey(this.opts.repo, ghScope);
389
1403
  const body = {
390
1404
  encrypted_value: await encryptSecret(pk.key, value),
391
1405
  key_id: pk.key_id
392
1406
  };
393
- if (scope.kind === "organization") body.visibility = "all";
394
- await this.client.secrets.putSecret(this.opts.repo, ghScope, name, body);
1407
+ Object.assign(body, readers);
1408
+ await client.secrets.putSecret(this.opts.repo, ghScope, name, body);
1409
+ }
1410
+ /**
1411
+ * Who can read each organization secret, by name (never a value): `selected` ones come with the names of their
1412
+ * repositories. Any other scope has no visibility and gives `{}`.
1413
+ */
1414
+ async listSecretAccess(scope) {
1415
+ if (scope.kind !== "organization") return {};
1416
+ const client = this.clientFor(scope);
1417
+ const found = {};
1418
+ for (const info of await client.secrets.listOrgSecretInfo(scope.name)) found[info.name] = info.visibility === "selected" ? {
1419
+ visibility: "selected",
1420
+ repos: await client.secrets.listSelectedRepositories(scope.name, info.name)
1421
+ } : { visibility: info.visibility };
1422
+ return found;
1423
+ }
1424
+ /** The `visibility` (and repository ids) of an organization write. No `visibility` keeps the older default, `all`. */
1425
+ async readers(scope) {
1426
+ const visibility = scope.visibility ?? "all";
1427
+ if (visibility !== "selected") return { visibility };
1428
+ if (!scope.repos?.length) throw new Error(`organization secret with visibility "selected" needs a non-empty repos list`);
1429
+ const ids = [];
1430
+ for (const name of scope.repos) ids.push(await this.repoId(scope.name, name));
1431
+ return {
1432
+ visibility,
1433
+ selected_repository_ids: ids
1434
+ };
395
1435
  }
1436
+ repoId(org, name) {
1437
+ const full = `${org}/${name}`;
1438
+ let id = this.repoIds.get(full);
1439
+ if (!id) {
1440
+ id = this.client.repos.getRepo(full).then((repo) => repo.id, (err) => {
1441
+ throw new Error(`could not resolve repository ${full}: ${err instanceof Error ? err.message : String(err)}`);
1442
+ });
1443
+ this.repoIds.set(full, id);
1444
+ }
1445
+ return id;
1446
+ }
1447
+ /** Deletes the named secret in the given scope. */
396
1448
  async deleteSecret(scope, name) {
397
- await this.client.secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
1449
+ await this.clientFor(scope).secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
398
1450
  }
399
1451
  };
400
1452
  function toGHScope(scope) {
@@ -404,8 +1456,46 @@ function toGHScope(scope) {
404
1456
  };
405
1457
  return scope;
406
1458
  }
407
- //#endregion
408
- //#region src/capabilities/labels.ts
1459
+ // src/capabilities/files.ts
1460
+ /** Reads one file; `null` when it does not exist. `version` is the blob sha `writeFile` needs to replace exactly this one. */
1461
+ /** A fine-grained token only reaches the repositories and permissions it was given; say which one is missing. */
1462
+ function noAccess(repo, action, cause) {
1463
+ return new Error(`the token cannot ${action} files in ${repo} (403): give the user-sync token (HOLOCRON_USER_SYNC_TOKEN, or holocron new token github.user-sync) access to that repository with Contents: ${action === "read" ? "read" : "read and write"}`, { cause });
1464
+ }
1465
+ async function readRepoFile(client, repo, path) {
1466
+ try {
1467
+ const file = await client.git.getContents(repo, path);
1468
+ if (file.encoding !== "base64") throw new Error(`${repo}/${path}: unexpected encoding \`${file.encoding}\``);
1469
+ return {
1470
+ content: Buffer.from(file.content, "base64").toString("utf-8"),
1471
+ version: file.sha
1472
+ };
1473
+ } catch (err) {
1474
+ if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "read", err);
1475
+ if (!(err instanceof ProviderApiError && err.status === 404)) throw err;
1476
+ }
1477
+ try {
1478
+ await client.repos.getRepo(repo);
1479
+ } catch (err) {
1480
+ 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 });
1481
+ throw err;
1482
+ }
1483
+ return null;
1484
+ }
1485
+ /** Creates or replaces one file in a commit; GitHub refuses (409) a `version` that has since changed. */
1486
+ async function writeRepoFile(client, repo, path, content, opts) {
1487
+ try {
1488
+ await client.git.putContents(repo, path, {
1489
+ message: opts.message,
1490
+ content,
1491
+ ...opts.version ? { sha: opts.version } : {}
1492
+ });
1493
+ } catch (err) {
1494
+ if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "write", err);
1495
+ throw err;
1496
+ }
1497
+ }
1498
+ // src/capabilities/labels.ts
409
1499
  /**
410
1500
  * Idempotently synchronise a repo's labels to the canonical set.
411
1501
  *
@@ -443,14 +1533,202 @@ async function syncLabels(client, repo, canonical, stale) {
443
1533
  }
444
1534
  return `${created} created, ${updated} updated, ${deleted} deleted`;
445
1535
  }
446
- //#endregion
447
- //#region src/capabilities/properties.ts
448
- async function syncProperties(client, repo, values) {
449
- await client.properties.setProperties(repo, values);
1536
+ // src/capabilities/property-fix-hints.ts
1537
+ /** GitHub's wording when a custom property value is not one the organization's definition allows. */
1538
+ const NOT_ALLOWED = /Value '([^']+)' is not allowed for property '([^']+)'/g;
1539
+ /**
1540
+ * What to do about each value GitHub says an organization custom property does not allow, one sentence per property,
1541
+ * read from GitHub's own message so it works even when the organization's definitions cannot be read. The values
1542
+ * belong to the organization's definition, not to the repository, so the fix is to add them there: a repository cannot
1543
+ * make its own value allowed. Empty when GitHub's message names no such value.
1544
+ */
1545
+ function propertyFixHints(githubMessage) {
1546
+ const byProperty = /* @__PURE__ */ new Map();
1547
+ for (const [, value, property] of githubMessage.matchAll(NOT_ALLOWED)) {
1548
+ const values = byProperty.get(property) ?? [];
1549
+ if (!values.includes(value)) values.push(value);
1550
+ byProperty.set(property, values);
1551
+ }
1552
+ 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 setup again`);
1553
+ }
1554
+ // src/capabilities/rejected-properties.ts
1555
+ /** The values `setup` sends for `true_false` properties; anything else is rejected. */
1556
+ const BOOLEANS = /* @__PURE__ */ new Set(["true", "false"]);
1557
+ /**
1558
+ * What is wrong with `values` when compared with the organization's property definitions, one sentence per
1559
+ * problem, in the order the properties are given: a property the organization does not define, a value a
1560
+ * `single_select` or `multi_select` property does not allow, or a `true_false` property that is not `true` or
1561
+ * `false`. A `string` or `url` property has nothing to check here. Empty when every value fits its definition.
1562
+ */
1563
+ function rejectedProperties(values, schema) {
1564
+ const byName = new Map(schema.map((property) => [property.property_name, property]));
1565
+ const problems = [];
1566
+ for (const [name, value] of Object.entries(values)) {
1567
+ const definition = byName.get(name);
1568
+ if (!definition) {
1569
+ problems.push(`${name} is not a custom property of the organization`);
1570
+ continue;
1571
+ }
1572
+ const sent = Array.isArray(value) ? value : [value];
1573
+ if (definition.value_type === "true_false") {
1574
+ for (const item of sent.filter((item) => !BOOLEANS.has(item))) problems.push(`${name} is "${item}", but it takes only true or false`);
1575
+ continue;
1576
+ }
1577
+ const allowed = definition.allowed_values ?? [];
1578
+ if (definition.value_type !== "single_select" && definition.value_type !== "multi_select") continue;
1579
+ 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(", ")})`);
1580
+ }
1581
+ return problems;
1582
+ }
1583
+ // src/capabilities/properties.ts
1584
+ /** GitHub's own message from a 422 body (`{"message": ..., "errors": [...]}`), or the start of the body when it is not JSON. */
1585
+ function githubMessage(details) {
1586
+ const raw = String(details ?? "").trim();
1587
+ try {
1588
+ const body = JSON.parse(raw);
1589
+ const errors = Array.isArray(body.errors) ? body.errors.map((e) => JSON.stringify(e)).join(", ") : "";
1590
+ const message = typeof body.message === "string" ? body.message : raw;
1591
+ return errors ? `${message}: ${errors}` : message;
1592
+ } catch {
1593
+ return raw.slice(0, 300);
1594
+ }
1595
+ }
1596
+ /** The organization's property definitions, or `undefined` when they cannot be read (permission, an older client). */
1597
+ async function orgSchema(client, repo) {
1598
+ try {
1599
+ return await client.properties.orgSchema(repo.split("/")[0]);
1600
+ } catch {
1601
+ return;
1602
+ }
1603
+ }
1604
+ /**
1605
+ * The values `values` holds that the organization's property definitions do not allow, one sentence each, or
1606
+ * `undefined` when the definitions cannot be read (the token lacks the organization's custom properties permission, or
1607
+ * the organization defines none). `client` should be the organization-scoped one when there is one, since reading the
1608
+ * definitions is an organization permission a repository token often lacks. Read-only.
1609
+ */
1610
+ async function checkPropertyValues(client, repo, values) {
1611
+ const schema = await orgSchema(client, repo);
1612
+ return schema ? rejectedProperties(values, schema) : void 0;
1613
+ }
1614
+ /**
1615
+ * Sets the repo's custom properties. When GitHub answers 422 the error says which value it refused and what to do
1616
+ * about it: the values are compared with the organization's property definitions (read-only, with `schemaClient`, the
1617
+ * organization-scoped client when there is one), GitHub's own message is added, and each value GitHub says a property
1618
+ * does not allow gets the fix, which does not need the definitions to be readable. One refused value fails the whole
1619
+ * request, so none of the properties is set.
1620
+ */
1621
+ async function syncProperties(client, repo, values, schemaClient = client) {
1622
+ try {
1623
+ await client.properties.setProperties(repo, values);
1624
+ } catch (err) {
1625
+ if (!(err instanceof ProviderApiError$1) || err.status !== 422) throw err;
1626
+ const problems = await checkPropertyValues(schemaClient, repo, values) ?? [];
1627
+ const said = githubMessage(err.details);
1628
+ const fixes = propertyFixHints(said);
1629
+ 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 });
1630
+ }
450
1631
  return `${Object.keys(values).length} properties set`;
451
1632
  }
452
- //#endregion
453
- //#region src/capabilities/teams.ts
1633
+ /** The values the organization's property `name` allows; `undefined` when it cannot be read, is not defined or takes any value. */
1634
+ async function getOrgPropertyValues(client, repo, name) {
1635
+ return ((await orgSchema(client, repo))?.find((p) => p.property_name === name))?.allowed_values ?? void 0;
1636
+ }
1637
+ /**
1638
+ * Adds the missing `values` to the organization's property `name` and returns them. GitHub replaces the whole
1639
+ * definition on a write, so the current one is read and sent back with the allowed values extended, in their order,
1640
+ * and nothing removed. A write is made only when something is missing.
1641
+ */
1642
+ async function addOrgPropertyValues(client, repo, name, values) {
1643
+ const org = repo.split("/")[0];
1644
+ const current = (await client.properties.orgSchema(org)).find((p) => p.property_name === name);
1645
+ if (!current) throw new Error(`the organization ${org} has no custom property "${name}"; create it first`);
1646
+ if (!current.allowed_values) throw new Error(`the custom property "${name}" takes any value; it has nothing to extend`);
1647
+ const have = new Set(current.allowed_values);
1648
+ const added = [...new Set(values)].filter((v) => !have.has(v));
1649
+ if (added.length === 0) return [];
1650
+ await client.properties.setOrgProperty(org, {
1651
+ ...current,
1652
+ allowed_values: [...current.allowed_values, ...added]
1653
+ });
1654
+ return added;
1655
+ }
1656
+ // src/capabilities/read-repo.ts
1657
+ /** The settings `setup` writes, which `GET /repos/{repo}` reports under the same names. */
1658
+ const SETTING_KEYS = [
1659
+ "allow_squash_merge",
1660
+ "allow_merge_commit",
1661
+ "allow_rebase_merge",
1662
+ "allow_auto_merge",
1663
+ "allow_update_branch",
1664
+ "delete_branch_on_merge",
1665
+ "has_issues",
1666
+ "has_discussions",
1667
+ "has_projects",
1668
+ "has_wiki"
1669
+ ];
1670
+ /** The repository's description, homepage, default branch, topics and settings, from one read. */
1671
+ async function readRepoInfo(client, repo) {
1672
+ const data = await client.repos.getRepo(repo);
1673
+ const settings = { default_branch: data.default_branch };
1674
+ for (const key of SETTING_KEYS) {
1675
+ const value = data[key];
1676
+ if (value !== void 0) settings[key] = value;
1677
+ }
1678
+ return {
1679
+ description: data.description ?? null,
1680
+ homepage: data.homepage ?? null,
1681
+ defaultBranch: data.default_branch,
1682
+ topics: data.topics ?? [],
1683
+ settings
1684
+ };
1685
+ }
1686
+ /** A ruleset reduced to what `setup` writes: its enforcement, rule types and required status checks. */
1687
+ function toRulesetDetail(data) {
1688
+ const rules = data.rules ?? [];
1689
+ const checks = rules.find((r) => r.type === "required_status_checks")?.parameters?.required_status_checks;
1690
+ return {
1691
+ name: data.name,
1692
+ enforcement: data.enforcement,
1693
+ ruleTypes: rules.map((r) => r.type),
1694
+ requiredChecks: Array.isArray(checks) ? checks.map((c) => String(c.context)) : []
1695
+ };
1696
+ }
1697
+ /** The value, or `undefined` when GitHub refuses the read (permission, plan, feature absent). Other errors still throw. */
1698
+ async function readable(read) {
1699
+ try {
1700
+ return await read();
1701
+ } catch (err) {
1702
+ if (err instanceof ProviderApiError$1) return void 0;
1703
+ throw err;
1704
+ }
1705
+ }
1706
+ /** Which security features are on. One GitHub refuses to report is left out, not reported as off. */
1707
+ async function readSecurity(client, repo) {
1708
+ const [data, alerts, fixes, reporting, codeScanning] = await Promise.all([
1709
+ readable(() => client.repos.getRepo(repo)),
1710
+ readable(() => client.security.vulnerabilityAlertsEnabled(repo)),
1711
+ readable(() => client.security.automatedSecurityFixesEnabled(repo)),
1712
+ readable(() => client.security.privateVulnerabilityReportingEnabled(repo)),
1713
+ readable(() => client.security.codeScanningDefaultSetupState(repo))
1714
+ ]);
1715
+ const analysis = data?.security_and_analysis;
1716
+ const on = (feature) => {
1717
+ const status = analysis?.[feature]?.status;
1718
+ return status === void 0 ? void 0 : status === "enabled";
1719
+ };
1720
+ const found = {
1721
+ vulnerabilityAlerts: alerts,
1722
+ automatedSecurityFixes: fixes,
1723
+ secretScanning: on("secret_scanning"),
1724
+ secretScanningPushProtection: on("secret_scanning_push_protection"),
1725
+ privateVulnerabilityReporting: reporting,
1726
+ codeScanningDefaultSetup: codeScanning === void 0 ? void 0 : codeScanning === "configured"
1727
+ };
1728
+ return Object.fromEntries(Object.entries(found).filter(([, v]) => v !== void 0));
1729
+ }
1730
+ // src/capabilities/teams.ts
1731
+ /** Turns a team entry into slug and permission form; a bare slug gets `push`. */
454
1732
  function normalizeTeamEntry(entry) {
455
1733
  if (typeof entry === "string") return {
456
1734
  slug: entry,
@@ -458,6 +1736,7 @@ function normalizeTeamEntry(entry) {
458
1736
  };
459
1737
  return entry;
460
1738
  }
1739
+ /** Grants each team access to the repo and reports the outcome; throws when every team fails. */
461
1740
  async function syncTeams(client, repo, teams) {
462
1741
  const [org = "", name = ""] = repo.split("/", 2);
463
1742
  const normalized = teams.map(normalizeTeamEntry);
@@ -470,17 +1749,132 @@ async function syncTeams(client, repo, teams) {
470
1749
  }
471
1750
  return `${succeeded} team${succeeded === 1 ? "" : "s"} synced`;
472
1751
  }
473
- //#endregion
474
- //#region src/capabilities/topics.ts
1752
+ // src/capabilities/topics.ts
475
1753
  async function syncTopics(client, repo, topics) {
476
1754
  await client.topics.setTopics(repo, topics);
477
1755
  return `${topics.length} topics set`;
478
1756
  }
479
- //#endregion
480
- //#region src/capabilities/source.ts
1757
+ // src/capabilities/variables.ts
1758
+ /** GitHub's rule for a variable name: letters, digits and underscores, not starting with a digit or `GITHUB_`. */
1759
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1760
+ /** Longest name GitHub accepts. */
1761
+ const MAX_NAME = 256;
1762
+ function assertNames(names) {
1763
+ 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_`);
1764
+ }
1765
+ /** GitHub treats variable names as case-insensitive, so every comparison goes through this. */
1766
+ const key = (name) => name.toUpperCase();
1767
+ /**
1768
+ * Bring one scope's variables in line with the declaration: create what is missing, update what differs, leave what
1769
+ * already matches. Variables that exist but are not declared are left alone, or deleted with `prune`, except an
1770
+ * organization variable limited to selected repositories, which this client cannot manage and so is never deleted.
1771
+ * Returns a one-line summary, naming the ones left so drift is visible. In a dry run it only describes the changes.
1772
+ */
1773
+ async function reconcile(client, repo, scope, wanted, opts) {
1774
+ assertNames(wanted.map((w) => w.name));
1775
+ const existing = new Map((await client.variables.listVariables(repo, scope)).map((v) => [key(v.name), v]));
1776
+ const counts = {
1777
+ created: 0,
1778
+ updated: 0,
1779
+ unchanged: 0,
1780
+ deleted: 0
1781
+ };
1782
+ for (const want of wanted) {
1783
+ const have = existing.get(key(want.name));
1784
+ const body = {
1785
+ name: want.name,
1786
+ value: want.value
1787
+ };
1788
+ if (want.visibility) body["visibility"] = want.visibility;
1789
+ if (!have) {
1790
+ if (opts.dryRun) {
1791
+ counts.created++;
1792
+ continue;
1793
+ }
1794
+ try {
1795
+ await client.variables.createVariable(repo, scope, body);
1796
+ counts.created++;
1797
+ continue;
1798
+ } catch (err) {
1799
+ if (!(err instanceof ProviderApiError && (err.status === 409 || err.status === 422))) throw err;
1800
+ }
1801
+ } else if (have.value === want.value && (!want.visibility || have.visibility === want.visibility)) {
1802
+ counts.unchanged++;
1803
+ continue;
1804
+ }
1805
+ if (!opts.dryRun) await client.variables.updateVariable(repo, scope, have?.name ?? want.name, body);
1806
+ counts.updated++;
1807
+ }
1808
+ const declared = new Set(wanted.map((w) => key(w.name)));
1809
+ const undeclared = [...existing.entries()].filter(([k]) => !declared.has(k)).map(([, v]) => v);
1810
+ const unmanaged = undeclared.filter((v) => v.visibility === "selected");
1811
+ if (opts.prune) for (const v of undeclared.filter((u) => u.visibility !== "selected")) {
1812
+ if (!opts.dryRun) await client.variables.deleteVariable(repo, scope, v.name);
1813
+ counts.deleted++;
1814
+ }
1815
+ const parts = opts.dryRun ? [
1816
+ `would create ${counts.created}`,
1817
+ `would update ${counts.updated}`,
1818
+ `${counts.unchanged} unchanged`
1819
+ ] : [
1820
+ `${counts.created} created`,
1821
+ `${counts.updated} updated`,
1822
+ `${counts.unchanged} unchanged`
1823
+ ];
1824
+ if (counts.deleted > 0) parts.push(`${counts.deleted} ${opts.dryRun ? "would be deleted" : "deleted"}`);
1825
+ const left = opts.prune ? unmanaged : undeclared;
1826
+ if (left.length > 0) {
1827
+ const why = opts.prune ? "limited to selected repositories, not managed" : "not declared, left alone";
1828
+ parts.push(`${left.length} ${why}: ${left.map((v) => v.name).join(", ")}`);
1829
+ }
1830
+ return parts.join(", ");
1831
+ }
1832
+ /** Reconcile a repository's Actions variables. */
1833
+ function syncVariables(client, repo, variables, opts = {}) {
1834
+ return reconcile(client, repo, { kind: "repo" }, Object.entries(variables).map(([name, value]) => ({
1835
+ name,
1836
+ value
1837
+ })), {
1838
+ prune: opts.prune === true,
1839
+ dryRun: opts.dryRun === true
1840
+ });
1841
+ }
1842
+ /** Reconcile an organization's Actions variables. `repo` is only the coordinate the client is bound to. */
1843
+ function syncOrgVariables(client, repo, org, variables, opts = {}) {
1844
+ const wanted = Object.entries(variables).map(([name, v]) => ({
1845
+ name,
1846
+ value: v.value,
1847
+ visibility: v.visibility
1848
+ }));
1849
+ return reconcile(client, repo, {
1850
+ kind: "organization",
1851
+ org
1852
+ }, wanted, {
1853
+ prune: opts.prune === true,
1854
+ dryRun: opts.dryRun === true
1855
+ });
1856
+ }
1857
+ // src/capabilities/source.ts
1858
+ function toPullRequest(raw) {
1859
+ return {
1860
+ number: raw.number,
1861
+ title: raw.title,
1862
+ state: raw.state,
1863
+ merged: raw.merged_at !== null,
1864
+ branch: raw.head.ref,
1865
+ headSha: raw.head.sha,
1866
+ url: raw.html_url
1867
+ };
1868
+ }
1869
+ /**
1870
+ * The GitHub implementation of the `source` capability: repo settings, rulesets, branch protection, security
1871
+ * features and local workflow files.
1872
+ */
481
1873
  var GitHubSource = class {
482
1874
  client;
1875
+ /** Capability key, always `"source"`. */
483
1876
  key = "source";
1877
+ /** Provider name, always `"github"`. */
484
1878
  providerName = "github";
485
1879
  owner;
486
1880
  name;
@@ -488,6 +1882,7 @@ var GitHubSource = class {
488
1882
  repoRoot;
489
1883
  workflowDir;
490
1884
  orgClient;
1885
+ userSyncClient;
491
1886
  baseUrl;
492
1887
  fetchImpl;
493
1888
  constructor(client, opts) {
@@ -496,15 +1891,20 @@ var GitHubSource = class {
496
1891
  this.owner = owner;
497
1892
  this.name = name;
498
1893
  this.repo = opts.repo;
499
- this.repoRoot = opts.repoRoot;
500
- this.workflowDir = join(opts.repoRoot, ".github", "workflows");
1894
+ this.repoRoot = findRootSync(opts.repoRoot, ".git", {
1895
+ fallback: "start",
1896
+ realpath: true
1897
+ });
1898
+ this.workflowDir = join(this.repoRoot, ".github", "workflows");
501
1899
  this.orgClient = opts.orgClient;
1900
+ this.userSyncClient = opts.userSyncClient;
502
1901
  this.baseUrl = opts.baseUrl;
503
1902
  this.fetchImpl = opts.fetch;
504
1903
  }
505
1904
  async whoami() {
506
1905
  return { login: (await this.client.user.getCurrentUser()).login };
507
1906
  }
1907
+ /** Returns the repo's owner, name and default branch. */
508
1908
  async getRepo() {
509
1909
  const data = await this.client.repos.getRepo(this.repo);
510
1910
  return {
@@ -513,27 +1913,34 @@ var GitHubSource = class {
513
1913
  defaultBranch: data.default_branch
514
1914
  };
515
1915
  }
1916
+ /** Lists the repo's rulesets. */
516
1917
  async listRulesets() {
517
1918
  return this.client.rulesets.listRulesets(this.repo);
518
1919
  }
1920
+ /** Creates a ruleset from the given payload. */
519
1921
  async createRuleset(payload) {
520
1922
  return this.client.rulesets.createRuleset(this.repo, payload);
521
1923
  }
1924
+ /** Updates the ruleset with the given id from the payload. */
522
1925
  async updateRuleset(id, payload) {
523
1926
  return this.client.rulesets.updateRuleset(this.repo, id, payload);
524
1927
  }
1928
+ /** Updates the repo's settings. */
525
1929
  async updateRepoSettings(settings) {
526
1930
  await this.client.repos.updateRepo(this.repo, settings);
527
1931
  }
528
1932
  async protectBranch(branch, payload) {
529
1933
  await this.client.branches.protectBranch(this.repo, branch, payload);
530
1934
  }
1935
+ /** Enables Dependabot vulnerability alerts. */
531
1936
  async enableVulnerabilityAlerts() {
532
1937
  await this.client.security.enableVulnerabilityAlerts(this.repo);
533
1938
  }
1939
+ /** Enables Dependabot automated security fixes. */
534
1940
  async enableAutomatedSecurityFixes() {
535
1941
  await this.client.security.enableAutomatedSecurityFixes(this.repo);
536
1942
  }
1943
+ /** Enables secret scanning. */
537
1944
  async enableSecretScanning() {
538
1945
  await this.client.security.enableSecretScanning(this.repo);
539
1946
  }
@@ -546,9 +1953,11 @@ var GitHubSource = class {
546
1953
  async enableDependencyGraph() {
547
1954
  await this.client.security.enableDependencyGraph(this.repo);
548
1955
  }
1956
+ /** Enables private vulnerability reporting. */
549
1957
  async enablePrivateVulnerabilityReporting() {
550
1958
  await this.client.security.enablePrivateVulnerabilityReporting(this.repo);
551
1959
  }
1960
+ /** Lists the `.yml` and `.yaml` files in `.github/workflows`, sorted by name; empty when the folder is missing. */
552
1961
  async listWorkflowFiles() {
553
1962
  try {
554
1963
  return (await readdir(this.workflowDir)).filter((e) => e.endsWith(".yml") || e.endsWith(".yaml")).sort();
@@ -557,6 +1966,7 @@ var GitHubSource = class {
557
1966
  throw err;
558
1967
  }
559
1968
  }
1969
+ /** Reads a workflow file's text, or `null` when it does not exist. */
560
1970
  async readWorkflowFile(name) {
561
1971
  try {
562
1972
  return await readFile(join(this.workflowDir, name), "utf8");
@@ -565,10 +1975,12 @@ var GitHubSource = class {
565
1975
  throw err;
566
1976
  }
567
1977
  }
1978
+ /** Writes a workflow file, creating `.github/workflows` first when needed. */
568
1979
  async writeWorkflowFile(name, contents) {
569
1980
  await ensureDir(this.workflowDir);
570
1981
  await writeFile(join(this.workflowDir, name), contents, "utf8");
571
1982
  }
1983
+ /** Removes a workflow file; a file that is already gone is not an error. */
572
1984
  async removeWorkflowFile(name) {
573
1985
  try {
574
1986
  await rm(join(this.workflowDir, name));
@@ -586,14 +1998,83 @@ var GitHubSource = class {
586
1998
  return syncLabels(this.client, this.repo, canonical, stale);
587
1999
  }
588
2000
  async syncProperties(values) {
589
- return syncProperties(this.client, this.repo, values);
2001
+ return syncProperties(this.client, this.repo, values, this.orgClient ?? this.client);
2002
+ }
2003
+ async getOrgPropertyValues(name) {
2004
+ return getOrgPropertyValues(this.orgClient ?? this.client, this.repo, name);
2005
+ }
2006
+ async addOrgPropertyValues(name, values) {
2007
+ return addOrgPropertyValues(this.orgClient ?? this.client, this.repo, name, values);
2008
+ }
2009
+ async checkPropertyValues(values) {
2010
+ return checkPropertyValues(this.orgClient ?? this.client, this.repo, values);
590
2011
  }
591
2012
  async syncTeams(teams) {
592
2013
  return syncTeams(this.orgClient ?? this.client, this.repo, teams);
593
2014
  }
2015
+ async teamMembers(teamSlug, org = this.owner) {
2016
+ return (await (this.orgClient ?? this.client).teams.members(org, teamSlug)).map((m) => ({
2017
+ id: String(m.id),
2018
+ login: m.login
2019
+ }));
2020
+ }
2021
+ async readFile(path, repo = this.repo) {
2022
+ return readRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, repo, path);
2023
+ }
2024
+ async writeFile(path, content, opts) {
2025
+ return writeRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, opts.repo ?? this.repo, path, content, opts);
2026
+ }
594
2027
  async syncTopics(topics) {
595
2028
  return syncTopics(this.client, this.repo, topics);
596
2029
  }
2030
+ async syncVariables(variables, opts) {
2031
+ return syncVariables(this.client, this.repo, variables, opts);
2032
+ }
2033
+ async syncOrgVariables(org, variables, opts) {
2034
+ if (!this.orgClient) throw new Error("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron new token github.org <token>`");
2035
+ return syncOrgVariables(this.orgClient, this.repo, org, variables, opts);
2036
+ }
2037
+ async listVariables() {
2038
+ const found = await this.client.variables.listVariables(this.repo, { kind: "repo" });
2039
+ return Object.fromEntries(found.map((v) => [v.name, v.value]));
2040
+ }
2041
+ async listOrgVariables(org) {
2042
+ if (!this.orgClient) throw new MissingTokenError("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron new token github.org <token>`");
2043
+ const found = await this.orgClient.variables.listVariables(this.repo, {
2044
+ kind: "organization",
2045
+ org
2046
+ });
2047
+ return Object.fromEntries(found.map((v) => [v.name, {
2048
+ value: v.value,
2049
+ visibility: v.visibility ?? "all"
2050
+ }]));
2051
+ }
2052
+ async getRepoInfo() {
2053
+ return readRepoInfo(this.client, this.repo);
2054
+ }
2055
+ async getProperties() {
2056
+ const found = await this.client.properties.getValues(this.repo);
2057
+ return Object.fromEntries(found.flatMap((p) => p.value === null ? [] : [[p.property_name, p.value]]));
2058
+ }
2059
+ async listLabels() {
2060
+ return (await this.client.labels.listLabels(this.repo)).map((l) => ({
2061
+ name: l.name,
2062
+ color: l.color,
2063
+ description: l.description ?? ""
2064
+ }));
2065
+ }
2066
+ async listTeamAccess() {
2067
+ return (await (this.orgClient ?? this.client).teams.listForRepo(this.repo)).map((t) => ({
2068
+ slug: t.slug,
2069
+ permission: t.permission
2070
+ }));
2071
+ }
2072
+ async getRulesetDetail(id) {
2073
+ return toRulesetDetail(await this.client.rulesets.getRuleset(this.repo, id));
2074
+ }
2075
+ async getSecurity() {
2076
+ return readSecurity(this.client, this.repo);
2077
+ }
597
2078
  async syncDescription(description) {
598
2079
  await this.client.repos.updateRepo(this.repo, { description });
599
2080
  return "description updated";
@@ -603,15 +2084,27 @@ var GitHubSource = class {
603
2084
  return "homepage updated";
604
2085
  }
605
2086
  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
- };
2087
+ return toPullRequest(await this.client.pulls.getPullRequest(repo ?? this.repo, number));
2088
+ }
2089
+ async listPullRequests(state, repo) {
2090
+ return (await this.client.pulls.listPullRequests(repo ?? this.repo, state)).map(toPullRequest);
2091
+ }
2092
+ async listBranches(repo) {
2093
+ return (await this.client.branches.listBranches(repo ?? this.repo)).map((b) => ({
2094
+ name: b.name,
2095
+ sha: b.commit.sha
2096
+ }));
2097
+ }
2098
+ async deleteBranch(name, repo) {
2099
+ try {
2100
+ await this.client.branches.removeBranch(repo ?? this.repo, name);
2101
+ } catch (err) {
2102
+ if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("does not exist")) return;
2103
+ throw err;
2104
+ }
2105
+ }
2106
+ async listPullRequestCommits(number, repo) {
2107
+ return (await this.client.pulls.listCommits(repo ?? this.repo, number)).map((commit) => commit.sha);
615
2108
  }
616
2109
  async configurePages(config, token) {
617
2110
  const pagesClient = createGitHubClient$1({
@@ -650,18 +2143,386 @@ async function ensureDir(path) {
650
2143
  throw err;
651
2144
  }
652
2145
  }
653
- //#endregion
654
- //#region src/verify-token.ts
2146
+ // src/probes/token/expectations.ts
2147
+ /** The same rules in every layer that runs the workflow: a laptop (`local` inherits `dev`) and CI. */
2148
+ function everywhere(rules) {
2149
+ return { permissions: {
2150
+ local: rules,
2151
+ dev: rules,
2152
+ ci: rules
2153
+ } };
2154
+ }
2155
+ /**
2156
+ * What each GitHub token is meant to carry, by its `envName`, the same as the GitHub tokens table in
2157
+ * `docs/src/content/docs/tokens.mdx`. This is the plugin's `tokens` declaration, read through `tokenExpectations()`;
2158
+ * change it and that table together.
2159
+ *
2160
+ * Every token CI uses (the `sync-repo`, `sync-secrets`, `reconcile-projects` and `check-tokens` workflows read all of
2161
+ * them) is declared with the same permissions in `local`, `dev` and `ci`, so the probe checks that a `ci` token is no
2162
+ * broader than its `dev` one rather than assuming a split the workflows do not allow. The release token is held by CI
2163
+ * only, so it has no `local` or `dev` entry. A narrow token must not reach administration or secrets; the admin token
2164
+ * must not reach organization secrets (that is `HOLOCRON_SECRETS_TOKEN`'s alone).
2165
+ */
2166
+ const EXPECTED = {
2167
+ HOLOCRON_READ_TOKEN: everywhere({
2168
+ contents: "required",
2169
+ actions: "required",
2170
+ administration: "absent",
2171
+ secrets: "absent",
2172
+ environments: "absent"
2173
+ }),
2174
+ HOLOCRON_ISSUES_TOKEN: everywhere({
2175
+ issues: "required",
2176
+ administration: "absent",
2177
+ secrets: "absent",
2178
+ environments: "absent"
2179
+ }),
2180
+ HOLOCRON_SYNC_TOKEN: everywhere({
2181
+ actions: "required",
2182
+ contents: "required",
2183
+ "pull-requests": "required",
2184
+ administration: "absent",
2185
+ secrets: "absent"
2186
+ }),
2187
+ HOLOCRON_RELEASE_TOKEN: { permissions: { ci: {
2188
+ contents: "required",
2189
+ issues: "required",
2190
+ "pull-requests": "required",
2191
+ administration: "absent",
2192
+ secrets: "absent"
2193
+ } } },
2194
+ HOLOCRON_ADMIN_TOKEN: everywhere({
2195
+ administration: "required",
2196
+ secrets: "required",
2197
+ environments: "required",
2198
+ issues: "required",
2199
+ "custom-properties": "required",
2200
+ "actions-variables": "required",
2201
+ "org-secrets": "absent"
2202
+ }),
2203
+ HOLOCRON_SECRETS_TOKEN: everywhere({
2204
+ "org-secrets": "required",
2205
+ administration: "absent"
2206
+ }),
2207
+ HOLOCRON_DEPLOY_TOKEN: everywhere({
2208
+ pages: "required",
2209
+ administration: "required",
2210
+ secrets: "absent",
2211
+ "org-secrets": "absent"
2212
+ }),
2213
+ HOLOCRON_USER_SYNC_TOKEN: everywhere({
2214
+ "user-sync-contents": "required",
2215
+ administration: "absent",
2216
+ secrets: "absent"
2217
+ }),
2218
+ HOLOCRON_ORG_TOKEN: everywhere({
2219
+ administration: "required",
2220
+ "org-members": "required",
2221
+ "org-variables": "required",
2222
+ "org-secrets": "absent"
2223
+ })
2224
+ };
2225
+ // src/tokens.ts
2226
+ /** Adds what a layer's token must and must not be able to do (`EXPECTED`, by `envName`) to a token. */
2227
+ function declared(token) {
2228
+ return {
2229
+ ...token,
2230
+ expect: EXPECTED[token.envName]
2231
+ };
2232
+ }
2233
+ /**
2234
+ * Every token this plugin takes, for `holocron doctor tokens`, with what each layer's token must and must not be able
2235
+ * to do. The release token is `optional`: only CI's release workflow reads it (from the org secret), so a development
2236
+ * environment has no reason to hold it.
2237
+ */
2238
+ const tokens = [
2239
+ declared(resolveReadToken.token),
2240
+ declared(resolveIssuesToken.token),
2241
+ declared(resolveSyncToken.token),
2242
+ declared({
2243
+ ...resolveReleaseToken.token,
2244
+ optional: true
2245
+ }),
2246
+ declared(resolveAdminToken.token),
2247
+ declared(resolveSecretsToken.token),
2248
+ declared(resolveDeployToken.token),
2249
+ declared(resolveUserSyncToken.token),
2250
+ declared(resolveOrgToken.token)
2251
+ ];
2252
+ // src/probes/token/parse-token-args.ts
2253
+ /** Reads the words after the probe name: `--layer <word>` and `--token-name <VAR>`, in either spelling. */
2254
+ function parseTokenArgs(args) {
2255
+ let layer;
2256
+ let tokenName;
2257
+ for (let i = 0; i < args.length; i++) {
2258
+ const arg = args[i];
2259
+ if (arg === "--layer") layer = args[++i];
2260
+ else if (arg.startsWith("--layer=")) layer = arg.slice(8);
2261
+ else if (arg === "--token-name") tokenName = args[++i];
2262
+ else if (arg.startsWith("--token-name=")) tokenName = arg.slice(13);
2263
+ }
2264
+ return {
2265
+ ...layer ? { layer } : {},
2266
+ ...tokenName ? { tokenName } : {}
2267
+ };
2268
+ }
2269
+ // src/probes/token/permissions.ts
2270
+ /**
2271
+ * Every permission the GitHub plugin's tokens are declared against, by the name the declaration uses, with one read
2272
+ * that needs it. A read proves the token can see the area, not that it can write to it: GitHub exposes no list of a
2273
+ * fine-grained token's permissions, so the write half of `read/write` is taken from the permission table in
2274
+ * `docs/src/content/docs/tokens.mdx`. On a public repository the `contents`, `actions`, `issues`, `pull-requests` and `environments`
2275
+ * reads succeed for any token, so only the other permissions can prove a permission absent. Names with more than one
2276
+ * word are hyphenated, not underscored: `holocron probe docs tokens` strips `_` (Markdown emphasis) from a table cell
2277
+ * before it compares the id with the declaration.
2278
+ */
2279
+ const PERMISSIONS = {
2280
+ contents: {
2281
+ label: "Contents",
2282
+ path: "/repos/{r}/contents",
2283
+ publicRead: true
2284
+ },
2285
+ actions: {
2286
+ label: "Actions",
2287
+ path: "/repos/{r}/actions/runs?per_page=1",
2288
+ publicRead: true
2289
+ },
2290
+ issues: {
2291
+ label: "Issues",
2292
+ path: "/repos/{r}/issues?per_page=1",
2293
+ publicRead: true
2294
+ },
2295
+ "pull-requests": {
2296
+ label: "Pull requests",
2297
+ path: "/repos/{r}/pulls?per_page=1",
2298
+ publicRead: true
2299
+ },
2300
+ administration: {
2301
+ label: "Administration",
2302
+ path: "/repos/{r}/actions/permissions"
2303
+ },
2304
+ secrets: {
2305
+ label: "Secrets",
2306
+ path: "/repos/{r}/actions/secrets?per_page=1"
2307
+ },
2308
+ environments: {
2309
+ label: "Environments",
2310
+ path: "/repos/{r}/environments?per_page=1",
2311
+ publicRead: true
2312
+ },
2313
+ "actions-variables": {
2314
+ label: "Variables",
2315
+ path: "/repos/{r}/actions/variables?per_page=1"
2316
+ },
2317
+ "custom-properties": {
2318
+ label: "Custom properties",
2319
+ path: "/repos/{r}/properties/values"
2320
+ },
2321
+ pages: {
2322
+ label: "Pages",
2323
+ path: "/repos/{r}/pages"
2324
+ },
2325
+ "org-members": {
2326
+ label: "Organization: Members",
2327
+ path: "/orgs/{o}/members?per_page=1"
2328
+ },
2329
+ "org-variables": {
2330
+ label: "Organization: Variables",
2331
+ path: "/orgs/{o}/actions/variables?per_page=1"
2332
+ },
2333
+ "org-secrets": {
2334
+ label: "Organization: Secrets",
2335
+ path: "/orgs/{o}/actions/secrets?per_page=1"
2336
+ },
2337
+ "user-sync-contents": {
2338
+ label: "Contents (.github-private)",
2339
+ path: "/repos/{o}/.github-private/contents"
2340
+ }
2341
+ };
2342
+ // src/probes/token/token.ts
2343
+ /** The token checked when none is named: the one the plugin reads with by default. */
2344
+ const DEFAULT_TOKEN = "HOLOCRON_READ_TOKEN";
2345
+ /** Does one GET with the token; the response headers are returned for the caller that wants them. */
2346
+ async function get(ctx, token, path) {
2347
+ return ctx.fetch(`${ctx.baseUrl}${path}`, { headers: {
2348
+ authorization: `Bearer ${token}`,
2349
+ accept: "application/vnd.github+json",
2350
+ "x-github-api-version": "2022-11-28",
2351
+ "user-agent": "holocron-probe"
2352
+ } });
2353
+ }
2354
+ /**
2355
+ * One read for one permission. GitHub answers a token without the permission with a 403, or a 404 for something it will
2356
+ * not admit exists, so both are `none`; a 2xx is `has`. Any other status, or a request that did not go through, is an
2357
+ * `error`, which is a failure whatever the layer expects.
2358
+ */
2359
+ async function reach(ctx, token, permission) {
2360
+ const [owner = ""] = ctx.repo.split("/");
2361
+ const path = PERMISSIONS[permission].path.replaceAll("{r}", ctx.repo).replaceAll("{o}", owner);
2362
+ try {
2363
+ const res = await get(ctx, token, path);
2364
+ if (res.ok) return {
2365
+ kind: "has",
2366
+ detail: `read ${path.split("?")[0]}`
2367
+ };
2368
+ if (res.status === 403 || res.status === 404) return {
2369
+ kind: "none",
2370
+ detail: `no access (${res.status})`
2371
+ };
2372
+ return {
2373
+ kind: "error",
2374
+ detail: `${res.status} ${res.statusText}`.trim()
2375
+ };
2376
+ } catch (err) {
2377
+ return {
2378
+ kind: "error",
2379
+ detail: errorMessage(err)
2380
+ };
2381
+ }
2382
+ }
655
2383
  /**
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.
2384
+ * One row for what was tried. With no rule it only describes (`ok` for access, `info` for none). Under a layer's rule it
2385
+ * judges: a required permission the token lacks fails, and one the layer must not have fails when the token has it; a
2386
+ * permission that is rightly absent is a bare `ok`.
2387
+ */
2388
+ function rowFor({ label, permission, outcome }, rule) {
2389
+ if (outcome.kind === "error") return {
2390
+ status: "fail",
2391
+ label,
2392
+ detail: outcome.detail
2393
+ };
2394
+ const has = outcome.kind === "has";
2395
+ if (rule === "required") return {
2396
+ status: has ? "ok" : "fail",
2397
+ label,
2398
+ detail: outcome.detail
2399
+ };
2400
+ if (rule === "absent" && has && PERMISSIONS[permission].publicRead) return {
2401
+ status: "info",
2402
+ label,
2403
+ detail: `${outcome.detail}, which any token can on a public repository: not proof either way`
2404
+ };
2405
+ if (rule === "absent") return has ? {
2406
+ status: "fail",
2407
+ label,
2408
+ detail: outcome.detail
2409
+ } : {
2410
+ status: "ok",
2411
+ label
2412
+ };
2413
+ return {
2414
+ status: has ? "ok" : "info",
2415
+ label,
2416
+ detail: outcome.detail
2417
+ };
2418
+ }
2419
+ /**
2420
+ * The `token` probe: `holocron probe github token` tries each permission Holocron uses with one of the plugin's nine
2421
+ * tokens and reports which ones the token reaches. It only reads (one GET per permission), so it changes nothing, and a
2422
+ * read proves the token can see an area, not that it can write there.
2423
+ *
2424
+ * GitHub lists scopes only for a classic token (the `X-OAuth-Scopes` header on any response), so a classic token's scopes
2425
+ * are shown. A fine-grained token has no list, so the permissions are found by trying them; the first row says which kind
2426
+ * was read. With `--environment <layer>` the CLI runs the probe once for each token the Environment holds and hands it
2427
+ * `--token-name <envName>` (and the token itself as `cliToken`); by hand, `--token-name` picks which token the plugin
2428
+ * resolves (default `HOLOCRON_READ_TOKEN`).
659
2429
  *
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.
2430
+ * With no layer the token is only described. When the CLI passes `--layer` (because `--environment` named `ci`, `dev` or
2431
+ * `local`), the results are judged against what that token is meant to carry in that layer (the plugin's `tokens`
2432
+ * declaration, read with `tokenExpectations()`) and shown in two groups, what it should have and what it should not,
2433
+ * where a missing or extra permission fails.
664
2434
  */
2435
+ function tokenProbe(ctx) {
2436
+ return {
2437
+ summary: "Try each GitHub permission Holocron uses (contents, actions, issues, administration, secrets, ...) with one of the plugin's tokens and report which it reaches; for a ci or dev Environment, check it against what that layer should carry. Read-only.",
2438
+ writes: false,
2439
+ acceptsToken: true,
2440
+ async run(probe) {
2441
+ const { layer, tokenName } = parseTokenArgs(probe.args ?? []);
2442
+ const envName = tokenName ?? DEFAULT_TOKEN;
2443
+ const token = ctx.resolve(envName);
2444
+ if (token === void 0) {
2445
+ probe.say({
2446
+ status: "fail",
2447
+ label: envName,
2448
+ detail: `not a GitHub token; use one of: ${ctx.tokenNames.join(", ")}`
2449
+ });
2450
+ return;
2451
+ }
2452
+ probe.secret(token);
2453
+ let identity;
2454
+ try {
2455
+ identity = await get(ctx, token, "/user");
2456
+ } catch (err) {
2457
+ probe.say({
2458
+ status: "fail",
2459
+ label: "token",
2460
+ detail: errorMessage(err)
2461
+ });
2462
+ return;
2463
+ }
2464
+ if (identity.status === 401) {
2465
+ probe.say({
2466
+ status: "fail",
2467
+ label: "token",
2468
+ detail: "rejected by GitHub (401): expired, revoked or not a token"
2469
+ });
2470
+ return;
2471
+ }
2472
+ const scopes = identity.headers.get("x-oauth-scopes");
2473
+ probe.say({
2474
+ status: "ok",
2475
+ label: "token",
2476
+ detail: scopes === null ? "fine-grained (GitHub lists no permissions for one, so each is tried with a read)" : `classic (scopes: ${scopes === "" ? "none" : scopes}); each permission is also tried with a read`
2477
+ });
2478
+ const declared = layer ? tokenExpectations(tokens, {
2479
+ layer,
2480
+ envName
2481
+ })[0] : void 0;
2482
+ if (layer && !declared) probe.say({
2483
+ status: "info",
2484
+ label: layer,
2485
+ detail: "no expectations are declared for this token there, so it is only described"
2486
+ });
2487
+ const rules = declared?.rules ?? {};
2488
+ const names = declared ? Object.keys(rules) : Object.keys(PERMISSIONS);
2489
+ const tried = [];
2490
+ for (const permission of names) tried.push({
2491
+ label: PERMISSIONS[permission].label,
2492
+ permission,
2493
+ outcome: await reach(ctx, token, permission)
2494
+ });
2495
+ if (!declared) {
2496
+ for (const t of tried) probe.say(rowFor(t, void 0));
2497
+ return;
2498
+ }
2499
+ for (const [heading, rule] of [["Should have", "required"], ["Should not have", "absent"]]) {
2500
+ const inGroup = tried.filter((t) => rules[t.permission] === rule);
2501
+ if (inGroup.length === 0) continue;
2502
+ probe.say({
2503
+ status: "heading",
2504
+ label: heading
2505
+ });
2506
+ for (const t of inGroup) probe.say(rowFor(t, rule));
2507
+ }
2508
+ }
2509
+ };
2510
+ }
2511
+ // src/token-resolvers.ts
2512
+ /** Each token's resolver by its environment variable (`HOLOCRON_ADMIN_TOKEN`), which is what `--token-name` carries. */
2513
+ const TOKEN_RESOLVERS = {
2514
+ HOLOCRON_READ_TOKEN: resolveReadToken,
2515
+ HOLOCRON_ISSUES_TOKEN: resolveIssuesToken,
2516
+ HOLOCRON_SYNC_TOKEN: resolveSyncToken,
2517
+ HOLOCRON_RELEASE_TOKEN: resolveReleaseToken,
2518
+ HOLOCRON_ADMIN_TOKEN: resolveAdminToken,
2519
+ HOLOCRON_SECRETS_TOKEN: resolveSecretsToken,
2520
+ HOLOCRON_DEPLOY_TOKEN: resolveDeployToken,
2521
+ HOLOCRON_USER_SYNC_TOKEN: resolveUserSyncToken,
2522
+ HOLOCRON_ORG_TOKEN: resolveOrgToken
2523
+ };
2524
+ // src/verify-token.ts
2525
+ /** Checks a token against `GET /user` and returns who it belongs to, or why it failed. */
665
2526
  async function verifyToken(token, opts = {}) {
666
2527
  const client = createGitHubClient({
667
2528
  token,
@@ -669,49 +2530,61 @@ async function verifyToken(token, opts = {}) {
669
2530
  fetch: opts.fetch
670
2531
  });
671
2532
  try {
672
- const me = await client.user.getCurrentUser();
2533
+ const { user: me, expiresAt } = await client.user.getCurrentUserWithExpiry();
673
2534
  return {
674
2535
  ok: true,
675
- subject: `user @ ${me.login ?? me.email ?? "unknown"}`
2536
+ subject: `user @ ${me.login ?? me.email ?? "unknown"}`,
2537
+ ...expiresAt ? { expiresAt } : {}
676
2538
  };
677
2539
  } catch (err) {
678
2540
  return {
679
2541
  ok: false,
680
- message: err instanceof Error ? err.message : String(err)
2542
+ message: errorMessage(err)
681
2543
  };
682
2544
  }
683
2545
  }
684
- //#endregion
685
- //#region src/index.ts
686
- function source(ctx, orgClient) {
2546
+ // src/index.ts
2547
+ /** Builds the `source` capability; the optional clients cover org-level and user-sync access. */
2548
+ function source(ctx, orgClient, userSyncClient) {
687
2549
  return new GitHubSource(ctx.client, {
688
2550
  repo: ctx.repo,
689
2551
  repoRoot: ctx.repoRoot,
690
2552
  orgClient,
2553
+ userSyncClient,
691
2554
  baseUrl: ctx.options.baseUrl,
692
2555
  fetch: ctx.options.fetch
693
2556
  });
694
2557
  }
695
- function secrets(ctx) {
696
- return new GitHubSecrets(ctx.client, { repo: ctx.repo });
2558
+ /** Builds the `secrets` capability; the optional client handles organization secrets. */
2559
+ function secrets(ctx, orgClient) {
2560
+ return new GitHubSecrets(ctx.client, { repo: ctx.repo }, orgClient);
697
2561
  }
2562
+ /** Builds the `environments` capability. */
698
2563
  function environments(ctx) {
699
2564
  return new GitHubEnvironments(ctx.client, { repo: ctx.repo });
700
2565
  }
2566
+ /** Builds the `ci` capability. */
701
2567
  function ci(ctx) {
702
2568
  return new GitHubCi(ctx.client, { repo: ctx.repo });
703
2569
  }
2570
+ /** Builds the `issues` capability, passing the lifecycle labels when configured. */
704
2571
  function issues(ctx) {
705
2572
  const opts = { repo: ctx.repo };
706
2573
  if (ctx.options.labels !== void 0) opts.labels = ctx.options.labels;
707
2574
  return new GitHubIssues(ctx.client, opts);
708
2575
  }
2576
+ /** Builds the `projects` capability: GitHub Projects v2 over GraphQL, with items keyed relative to the configured repo. */
2577
+ function projects(ctx) {
2578
+ return new GitHubProjects(ctx.client.graphql, { repo: ctx.repo });
2579
+ }
2580
+ /** Creates the plugin, resolving a token per capability when it is first used. */
709
2581
  function createPlugin(options) {
710
2582
  function makeCtx(resolver) {
2583
+ const token = resolver(options);
711
2584
  return {
712
2585
  options,
713
2586
  client: createGitHubClient$1({
714
- token: resolver(options),
2587
+ token,
715
2588
  baseUrl: options.baseUrl,
716
2589
  fetch: options.fetch
717
2590
  }),
@@ -719,10 +2592,45 @@ function createPlugin(options) {
719
2592
  repoRoot: options.repoRoot ?? process.cwd()
720
2593
  };
721
2594
  }
2595
+ /** A token, or undefined when that feature token is simply not configured. */
2596
+ function tryResolve(resolver) {
2597
+ try {
2598
+ return resolver(options);
2599
+ } catch (err) {
2600
+ if (err instanceof AuthError) return void 0;
2601
+ throw err;
2602
+ }
2603
+ }
2604
+ /**
2605
+ * Organization secrets need an org-resource-owner token (`github.secrets`);
2606
+ * repo and environment secrets need `github.admin`. Each scope prefers its own
2607
+ * token and falls back to the other, so a setup with only one of them keeps
2608
+ * working, and an explicit `--token` serves both. With neither configured this
2609
+ * fails exactly as the admin-token capabilities do.
2610
+ */
2611
+ function makeSecrets() {
2612
+ const secretsToken = tryResolve(resolveSecretsToken);
2613
+ const adminToken = tryResolve(resolveAdminToken);
2614
+ if (!secretsToken && !adminToken) resolveAdminToken(options);
2615
+ const clientFor = (token) => createGitHubClient$1({
2616
+ token,
2617
+ baseUrl: options.baseUrl,
2618
+ fetch: options.fetch
2619
+ });
2620
+ const repoClient = clientFor(adminToken ?? secretsToken);
2621
+ const orgClient = clientFor(secretsToken ?? adminToken);
2622
+ return secrets({
2623
+ options,
2624
+ client: repoClient,
2625
+ repo: options.repo,
2626
+ repoRoot: options.repoRoot ?? process.cwd()
2627
+ }, orgClient);
2628
+ }
722
2629
  function makeOrgClient() {
723
2630
  try {
2631
+ const token = resolveOrgToken(options);
724
2632
  return createGitHubClient$1({
725
- token: resolveOrgToken(options),
2633
+ token,
726
2634
  baseUrl: options.baseUrl,
727
2635
  fetch: options.fetch
728
2636
  });
@@ -730,21 +2638,49 @@ function createPlugin(options) {
730
2638
  return;
731
2639
  }
732
2640
  }
2641
+ /** The token for the user-sync files; undefined when it is not configured, and the org token is used instead. */
2642
+ function makeUserSyncClient() {
2643
+ const token = tryResolve(resolveUserSyncToken);
2644
+ return token ? createGitHubClient$1({
2645
+ token,
2646
+ baseUrl: options.baseUrl,
2647
+ fetch: options.fetch
2648
+ }) : void 0;
2649
+ }
733
2650
  return {
734
2651
  name: "@theholocron/holocron-plugin-github",
2652
+ probes: {
2653
+ token: tokenProbe({
2654
+ repo: options.repo,
2655
+ baseUrl: (options.baseUrl ?? "https://api.github.com").replace(/\/$/, ""),
2656
+ fetch: options.fetch ?? fetch,
2657
+ resolve: (envName) => TOKEN_RESOLVERS[envName]?.(options),
2658
+ tokenNames: Object.keys(TOKEN_RESOLVERS)
2659
+ }),
2660
+ connection: connectionProbe({
2661
+ provider: "GitHub",
2662
+ url: options.baseUrl ?? "https://api.github.com",
2663
+ fetch: options.fetch,
2664
+ token: () => resolveReadToken(options),
2665
+ verify: (token) => verifyToken(token, {
2666
+ baseUrl: options.baseUrl,
2667
+ fetch: options.fetch
2668
+ })
2669
+ })
2670
+ },
735
2671
  capabilities: {
736
- source: () => source(makeCtx(resolveAdminToken), makeOrgClient()),
2672
+ source: () => source(makeCtx(resolveAdminToken), makeOrgClient(), makeUserSyncClient()),
737
2673
  ci: () => ci(makeCtx(resolveReadToken)),
738
- secrets: () => secrets(makeCtx(resolveAdminToken)),
2674
+ secrets: () => makeSecrets(),
739
2675
  environments: () => environments(makeCtx(resolveAdminToken)),
740
- issues: () => issues(makeCtx(resolveIssuesToken))
2676
+ issues: () => issues(makeCtx(resolveIssuesToken)),
2677
+ projects: () => projects(makeCtx(resolveOrgToken))
741
2678
  }
742
2679
  };
743
2680
  }
744
2681
  /**
745
- * One-line hint printed by `holocron auth set github` when no token
2682
+ * One-line hint printed by `holocron new token github` when no token
746
2683
  * is supplied or the supplied token is rejected.
747
2684
  */
748
- 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 };
2685
+ 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 new token github.<feature> <PAT>";
2686
+ 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 };