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