@theholocron/holocron-plugin-github 5.0.0-alpha.37 → 5.0.0-alpha.371
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 +236 -71
- package/dist/index.mjs +1308 -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,173 @@ var GitHubEnvironments = class {
|
|
|
119
151
|
}));
|
|
120
152
|
}
|
|
121
153
|
};
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
// src/capabilities/issues-migration.ts
|
|
155
|
+
const MARKER = /<!-- holocron:migrated-from (\S+) -->/;
|
|
156
|
+
const PRIORITY_LABEL = /^priority\s*[:/-]\s*(urgent|high|medium|low)$/i;
|
|
157
|
+
const LINK_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) {
|
|
158
|
+
issue(number: $number) { parent { number } blockedBy(first: 50) { nodes { number } } } } }`;
|
|
159
|
+
/** Splits `priority: high` labels from the rest. */
|
|
160
|
+
function splitLabels(names) {
|
|
161
|
+
let priority;
|
|
162
|
+
const labels = [];
|
|
163
|
+
for (const name of names) {
|
|
164
|
+
const match = PRIORITY_LABEL.exec(name);
|
|
165
|
+
if (match) priority = match[1].toLowerCase();
|
|
166
|
+
else labels.push(name);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
labels,
|
|
170
|
+
...priority ? { priority } : {}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function categoryOf(issue) {
|
|
174
|
+
if (issue.state === "closed") return "done";
|
|
175
|
+
const names = issue.labels.map((l) => l.name.toLowerCase());
|
|
176
|
+
if (names.some((n) => n === "status:in-review" || n === "status: in review")) return "in-review";
|
|
177
|
+
if (names.some((n) => n === "status:in-progress" || n === "status: in progress")) return "in-progress";
|
|
178
|
+
return "open";
|
|
179
|
+
}
|
|
180
|
+
/** Reads the issues `selector` names, with their comments, priority label, parent and blockers. */
|
|
181
|
+
async function exportIssues(client, repo, selector) {
|
|
182
|
+
const [owner = "", name = ""] = repo.split("/", 2);
|
|
183
|
+
let raw;
|
|
184
|
+
if (selector.keys) raw = await Promise.all(selector.keys.map((k) => client.issues.getIssue(repo, parseNumber(k))));
|
|
185
|
+
else if (selector.milestone) {
|
|
186
|
+
const milestone = await findMilestone(client, repo, selector.milestone);
|
|
187
|
+
if (!milestone) throw new Error(`Milestone "${selector.milestone}" not found in ${repo}.`);
|
|
188
|
+
raw = await listAll(client, repo, { milestone: String(milestone.number) });
|
|
189
|
+
} else if (selector.all) raw = await listAll(client, repo, {});
|
|
190
|
+
else throw new Error("export needs keys, a milestone or all");
|
|
191
|
+
const issues = raw.filter((i) => !i.pull_request && !(selector.openOnly && i.state === "closed"));
|
|
192
|
+
const records = [];
|
|
193
|
+
for (const issue of issues) {
|
|
194
|
+
const comments = await client.issues.listComments(repo, issue.number);
|
|
195
|
+
const links = (await client.graphql.query(LINK_QUERY, {
|
|
196
|
+
owner,
|
|
197
|
+
name,
|
|
198
|
+
number: issue.number
|
|
199
|
+
}).catch(() => void 0))?.repository.issue;
|
|
200
|
+
const { labels, priority } = splitLabels(issue.labels.map((l) => l.name));
|
|
201
|
+
const body = issue.body?.replace(MARKER, "").trimEnd();
|
|
202
|
+
records.push({
|
|
203
|
+
key: `#${issue.number}`,
|
|
204
|
+
url: issue.html_url,
|
|
205
|
+
title: issue.title,
|
|
206
|
+
...body ? { body } : {},
|
|
207
|
+
statusCategory: categoryOf(issue),
|
|
208
|
+
closed: issue.state === "closed",
|
|
209
|
+
...priority ? { priority } : {},
|
|
210
|
+
labels: labels.filter((l) => !l.toLowerCase().startsWith("status:")),
|
|
211
|
+
...issue.assignee ? { assignee: {
|
|
212
|
+
id: issue.assignee.login,
|
|
213
|
+
displayName: issue.assignee.name ?? issue.assignee.login,
|
|
214
|
+
...issue.assignee.email ? { emailAddress: issue.assignee.email } : {}
|
|
215
|
+
} } : {},
|
|
216
|
+
...issue.milestone ? { milestone: issue.milestone.title } : {},
|
|
217
|
+
...links?.parent ? { parent: `#${links.parent.number}` } : {},
|
|
218
|
+
blockedBy: (links?.blockedBy.nodes ?? []).map((n) => `#${n.number}`),
|
|
219
|
+
comments: comments.map((c) => ({
|
|
220
|
+
...c.user ? { author: c.user.login } : {},
|
|
221
|
+
body: c.body,
|
|
222
|
+
createdAt: c.created_at
|
|
223
|
+
})),
|
|
224
|
+
createdAt: issue.created_at ?? issue.updated_at,
|
|
225
|
+
updatedAt: issue.updated_at
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return records;
|
|
229
|
+
}
|
|
230
|
+
/** Makes the records as issues, skipping any whose source address an earlier run already recorded. */
|
|
231
|
+
async function importIssues(client, repo, records, options = {}) {
|
|
232
|
+
const existing = /* @__PURE__ */ new Map();
|
|
233
|
+
for (const issue of await listAll(client, repo, {})) {
|
|
234
|
+
const url = issue.body ? MARKER.exec(issue.body)?.[1] : void 0;
|
|
235
|
+
if (url) existing.set(url, issue.number);
|
|
236
|
+
}
|
|
237
|
+
const results = [];
|
|
238
|
+
for (const record of records) {
|
|
239
|
+
const found = existing.get(record.url);
|
|
240
|
+
if (found !== void 0) {
|
|
241
|
+
results.push({
|
|
242
|
+
source: record.key,
|
|
243
|
+
key: `#${found}`,
|
|
244
|
+
created: false,
|
|
245
|
+
dropped: []
|
|
246
|
+
});
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const dropped = ["createdAt (GitHub sets it; the original date is in the text)"];
|
|
250
|
+
if (record.assignee) dropped.push("assignee (not mapped)");
|
|
251
|
+
if (record.parent) dropped.push("parent (not set on GitHub)");
|
|
252
|
+
if (record.blockedBy.length > 0) dropped.push("blockedBy (not set on GitHub)");
|
|
253
|
+
if (options.dryRun) {
|
|
254
|
+
results.push({
|
|
255
|
+
source: record.key,
|
|
256
|
+
created: true,
|
|
257
|
+
dropped
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const milestoneName = options.milestone ?? record.milestone;
|
|
262
|
+
const milestone = milestoneName ? await ensureMilestone(client, repo, milestoneName) : void 0;
|
|
263
|
+
const labels = [...record.labels, ...record.priority && record.priority !== "none" ? [`priority: ${record.priority}`] : []];
|
|
264
|
+
const created = await client.issues.createIssue(repo, {
|
|
265
|
+
title: record.title,
|
|
266
|
+
body: withProvenance(record),
|
|
267
|
+
...labels.length > 0 ? { labels } : {},
|
|
268
|
+
...milestone !== void 0 ? { milestone } : {}
|
|
269
|
+
});
|
|
270
|
+
for (const comment of record.comments) {
|
|
271
|
+
const who = comment.author ? `**${comment.author}**` : "Someone";
|
|
272
|
+
await client.issues.createComment(repo, created.number, `${who} commented on ${comment.createdAt}:\n\n${comment.body}`);
|
|
273
|
+
}
|
|
274
|
+
if (record.closed) await client.issues.updateIssue(repo, created.number, {
|
|
275
|
+
state: "closed",
|
|
276
|
+
state_reason: "completed"
|
|
277
|
+
});
|
|
278
|
+
results.push({
|
|
279
|
+
source: record.key,
|
|
280
|
+
key: `#${created.number}`,
|
|
281
|
+
created: true,
|
|
282
|
+
dropped
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return results;
|
|
286
|
+
}
|
|
287
|
+
function withProvenance(record) {
|
|
288
|
+
return `${record.body ? `${record.body}\n\n` : ""}---\n_Migrated from [${record.key}](${record.url}); created ${record.createdAt}._\n<!-- holocron:migrated-from ${record.url} -->`;
|
|
289
|
+
}
|
|
290
|
+
function parseNumber(key) {
|
|
291
|
+
const n = key.trim().replace(/^.*#/, "");
|
|
292
|
+
if (!/^\d+$/.test(n)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
|
|
293
|
+
return parseInt(n, 10);
|
|
294
|
+
}
|
|
295
|
+
async function listAll(client, repo, extra) {
|
|
296
|
+
const all = [];
|
|
297
|
+
for (let page = 1;; page++) {
|
|
298
|
+
const batch = await client.issues.listIssues(repo, {
|
|
299
|
+
state: "all",
|
|
300
|
+
per_page: 100,
|
|
301
|
+
page,
|
|
302
|
+
...extra
|
|
303
|
+
});
|
|
304
|
+
all.push(...batch);
|
|
305
|
+
if (batch.length < 100) return all;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function findMilestone(client, repo, ref) {
|
|
309
|
+
return (await client.issues.listMilestones(repo, { state: "all" })).find((m) => String(m.number) === ref || m.title.toLowerCase() === ref.toLowerCase());
|
|
310
|
+
}
|
|
311
|
+
async function ensureMilestone(client, repo, title) {
|
|
312
|
+
return (await findMilestone(client, repo, title))?.number ?? (await client.issues.createMilestone(repo, title)).number;
|
|
313
|
+
}
|
|
314
|
+
// src/capabilities/issues.ts
|
|
315
|
+
/** The GitHub implementation of the `issues` capability, backed by GitHub Issues and status labels. */
|
|
124
316
|
var GitHubIssues = class {
|
|
125
317
|
client;
|
|
318
|
+
/** Capability key, always `"issues"`. */
|
|
126
319
|
key = "issues";
|
|
320
|
+
/** Provider name, always `"github"`. */
|
|
127
321
|
providerName = "github";
|
|
128
322
|
owner;
|
|
129
323
|
repoName;
|
|
@@ -145,6 +339,7 @@ var GitHubIssues = class {
|
|
|
145
339
|
...raw.email ? { emailAddress: raw.email } : {}
|
|
146
340
|
};
|
|
147
341
|
}
|
|
342
|
+
/** Searches the repo's issues, newest update first, leaving out pull requests. */
|
|
148
343
|
async search(filter) {
|
|
149
344
|
const params = {
|
|
150
345
|
state: filter.openOnly ? "open" : "all",
|
|
@@ -164,11 +359,13 @@ var GitHubIssues = class {
|
|
|
164
359
|
assignee: params.assignee
|
|
165
360
|
})).filter((i) => !i.pull_request).map((i) => this.mapIssue(i));
|
|
166
361
|
}
|
|
362
|
+
/** Fetches one issue by its key. */
|
|
167
363
|
async get(key) {
|
|
168
364
|
const number = parseIssueNumber(key, this.owner, this.repoName);
|
|
169
365
|
const raw = await this.client.issues.getIssue(this.repo, number);
|
|
170
366
|
return this.mapIssue(raw);
|
|
171
367
|
}
|
|
368
|
+
/** Creates an issue and returns its key as `#<number>`; the milestone is resolved from its title. */
|
|
172
369
|
async create(input) {
|
|
173
370
|
const body = { title: input.summary };
|
|
174
371
|
if (input.body) body.body = input.body;
|
|
@@ -216,10 +413,12 @@ var GitHubIssues = class {
|
|
|
216
413
|
via: `label set to ${targetLabel}`
|
|
217
414
|
};
|
|
218
415
|
}
|
|
416
|
+
/** Adds a comment to the issue with the given key. */
|
|
219
417
|
async comment(key, body) {
|
|
220
418
|
const number = parseIssueNumber(key, this.owner, this.repoName);
|
|
221
419
|
await this.client.issues.createComment(this.repo, number, body);
|
|
222
420
|
}
|
|
421
|
+
/** Checks the authenticated user, the repo and the label-backed lifecycle slots, and reports what resolves. */
|
|
223
422
|
async doctor() {
|
|
224
423
|
const me = await this.getMyself();
|
|
225
424
|
const repo = await this.client.repos.getRepo(this.repo);
|
|
@@ -288,6 +487,14 @@ var GitHubIssues = class {
|
|
|
288
487
|
lifecycle
|
|
289
488
|
};
|
|
290
489
|
}
|
|
490
|
+
/** Reads issues with their comments, priority label, parent and blockers, for `holocron migrate`. */
|
|
491
|
+
export(selector) {
|
|
492
|
+
return exportIssues(this.client, this.repo, selector);
|
|
493
|
+
}
|
|
494
|
+
/** Makes the records as issues; one already made by an earlier run is skipped. */
|
|
495
|
+
import(records, options) {
|
|
496
|
+
return importIssues(this.client, this.repo, records, options);
|
|
497
|
+
}
|
|
291
498
|
async resolveMilestone(ref) {
|
|
292
499
|
if (/^\d+$/.test(ref)) return parseInt(ref, 10);
|
|
293
500
|
const match = (await this.client.issues.listMilestones(this.repo, { state: "all" })).find((m) => m.title.toLowerCase() === ref.toLowerCase());
|
|
@@ -337,8 +544,518 @@ function parseIssueNumber(key, owner, repoName) {
|
|
|
337
544
|
if (!/^\d+$/.test(num)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
|
|
338
545
|
return parseInt(num, 10);
|
|
339
546
|
}
|
|
340
|
-
|
|
341
|
-
|
|
547
|
+
// src/capabilities/projects.ts
|
|
548
|
+
/** The name of the single select field that holds an item's status. */
|
|
549
|
+
const STATUS_FIELD = "Status";
|
|
550
|
+
/** The name of the field `move` carries over besides the status. */
|
|
551
|
+
const PRIORITY_FIELD = "Priority";
|
|
552
|
+
const P_LEVELS = {
|
|
553
|
+
p0: "urgent",
|
|
554
|
+
p1: "high",
|
|
555
|
+
p2: "medium",
|
|
556
|
+
p3: "low"
|
|
557
|
+
};
|
|
558
|
+
/** The neutral priority a `Priority` option stands for, by its name; undefined for a name that is not one of them. */
|
|
559
|
+
function priorityOf(value) {
|
|
560
|
+
const name = value?.trim().toLowerCase();
|
|
561
|
+
if (name === "urgent" || name === "high" || name === "medium" || name === "low") return name;
|
|
562
|
+
return name ? P_LEVELS[name] : void 0;
|
|
563
|
+
}
|
|
564
|
+
const PROJECT_FIELDS = "id number title closed url";
|
|
565
|
+
const BOARD_QUERY = `query($id: ID!) { node(id: $id) { ... on ProjectV2 {
|
|
566
|
+
${PROJECT_FIELDS}
|
|
567
|
+
fields(first: 100) { nodes {
|
|
568
|
+
... on ProjectV2FieldCommon { id name dataType }
|
|
569
|
+
... on ProjectV2SingleSelectField { options { id name color description } }
|
|
570
|
+
} }
|
|
571
|
+
views(first: 50) { nodes { id name layout filter groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } } } }
|
|
572
|
+
workflows(first: 50) { nodes { id name enabled } }
|
|
573
|
+
} } }`;
|
|
574
|
+
const ITEMS_QUERY = `query($id: ID!, $after: String) { node(id: $id) { ... on ProjectV2 {
|
|
575
|
+
items(first: 100, after: $after) {
|
|
576
|
+
pageInfo { hasNextPage endCursor }
|
|
577
|
+
nodes {
|
|
578
|
+
id
|
|
579
|
+
fieldValues(first: 30) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } } } }
|
|
580
|
+
content {
|
|
581
|
+
__typename
|
|
582
|
+
... on Issue { id number title state repository { nameWithOwner } blockedBy(first: 50) { nodes { number state repository { nameWithOwner } } } }
|
|
583
|
+
... on PullRequest { id number title state repository { nameWithOwner } }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} } }`;
|
|
588
|
+
const LIST_QUERY = `query($owner: String!, $after: String) { organization(login: $owner) { id projectsV2(first: 100, after: $after) {
|
|
589
|
+
pageInfo { hasNextPage endCursor }
|
|
590
|
+
nodes { ${PROJECT_FIELDS} }
|
|
591
|
+
} } }`;
|
|
592
|
+
const OWNER_QUERY = "query($owner: String!) { organization(login: $owner) { id } }";
|
|
593
|
+
const SET_VALUE = `mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
|
|
594
|
+
updateProjectV2ItemFieldValue(input: { projectId: $project, itemId: $item, fieldId: $field, value: { singleSelectOptionId: $option } }) { projectV2Item { id } }
|
|
595
|
+
}`;
|
|
596
|
+
const UPDATE_OPTIONS = `mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
|
|
597
|
+
updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) { projectV2Field { ... on ProjectV2SingleSelectField { id } } }
|
|
598
|
+
}`;
|
|
599
|
+
const DATA_TYPES = {
|
|
600
|
+
single_select: "SINGLE_SELECT",
|
|
601
|
+
text: "TEXT",
|
|
602
|
+
number: "NUMBER",
|
|
603
|
+
date: "DATE",
|
|
604
|
+
iteration: "ITERATION"
|
|
605
|
+
};
|
|
606
|
+
function fieldTypeOf(dataType) {
|
|
607
|
+
return Object.keys(DATA_TYPES).find((t) => DATA_TYPES[t] === dataType);
|
|
608
|
+
}
|
|
609
|
+
function toSummary(n) {
|
|
610
|
+
return {
|
|
611
|
+
id: n.id,
|
|
612
|
+
number: n.number,
|
|
613
|
+
name: n.title,
|
|
614
|
+
url: n.url,
|
|
615
|
+
closed: n.closed
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function toOptionInput(o) {
|
|
619
|
+
return {
|
|
620
|
+
...o.id ? { id: o.id } : {},
|
|
621
|
+
name: o.name,
|
|
622
|
+
color: (o.color ?? "GRAY").toUpperCase(),
|
|
623
|
+
description: o.description ?? ""
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/** The GitHub implementation of the `projects` capability, backed by Projects v2. */
|
|
627
|
+
var GitHubProjects = class {
|
|
628
|
+
graphql;
|
|
629
|
+
/** Capability key, always `"projects"`. */
|
|
630
|
+
key = "projects";
|
|
631
|
+
/** Provider name, always `"github"`. */
|
|
632
|
+
providerName = "github";
|
|
633
|
+
repo;
|
|
634
|
+
constructor(graphql, opts) {
|
|
635
|
+
this.graphql = graphql;
|
|
636
|
+
this.repo = opts.repo;
|
|
637
|
+
}
|
|
638
|
+
/** Every board of the organization, open and closed. */
|
|
639
|
+
async list(owner) {
|
|
640
|
+
const boards = [];
|
|
641
|
+
let after = null;
|
|
642
|
+
for (;;) {
|
|
643
|
+
const page = (await this.graphql.query(LIST_QUERY, {
|
|
644
|
+
owner,
|
|
645
|
+
after
|
|
646
|
+
})).organization.projectsV2;
|
|
647
|
+
boards.push(...page.nodes.map(toSummary));
|
|
648
|
+
if (!page.pageInfo.hasNextPage) return boards;
|
|
649
|
+
after = page.pageInfo.endCursor;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** The open board with exactly this name. */
|
|
653
|
+
async find(owner, name) {
|
|
654
|
+
return (await this.list(owner)).find((b) => !b.closed && b.name === name);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Makes a board. With `template.source` naming an open board it is copied (fields, options, views and automation
|
|
658
|
+
* rules, no items); otherwise the board is created and brought in line with the template.
|
|
659
|
+
*/
|
|
660
|
+
async create(owner, name, template) {
|
|
661
|
+
const ownerId = await this.ownerId(owner);
|
|
662
|
+
const source = template.source ? await this.find(owner, template.source) : void 0;
|
|
663
|
+
if (source) return toSummary((await this.graphql.query(`mutation($project: ID!, $owner: ID!, $title: String!) {
|
|
664
|
+
copyProjectV2(input: { projectId: $project, ownerId: $owner, title: $title, includeDraftIssues: false }) { projectV2 { ${PROJECT_FIELDS} } }
|
|
665
|
+
}`, {
|
|
666
|
+
project: source.id,
|
|
667
|
+
owner: ownerId,
|
|
668
|
+
title: name
|
|
669
|
+
})).copyProjectV2.projectV2);
|
|
670
|
+
const board = toSummary((await this.graphql.query(`mutation($owner: ID!, $title: String!) {
|
|
671
|
+
createProjectV2(input: { ownerId: $owner, title: $title }) { projectV2 { ${PROJECT_FIELDS} } }
|
|
672
|
+
}`, {
|
|
673
|
+
owner: ownerId,
|
|
674
|
+
title: name
|
|
675
|
+
})).createProjectV2.projectV2);
|
|
676
|
+
await this.ensure(owner, board, template);
|
|
677
|
+
return board;
|
|
678
|
+
}
|
|
679
|
+
/** Closes the board; `updateProjectV2` with `closed: true` is the only way, and reversible. */
|
|
680
|
+
async close(_owner, board) {
|
|
681
|
+
await this.graphql.query(`mutation($project: ID!) { updateProjectV2(input: { projectId: $project, closed: true }) { projectV2 { id } } }`, { project: board.id });
|
|
682
|
+
}
|
|
683
|
+
/** The live board: statuses, other fields, views and automation rules. */
|
|
684
|
+
async read(_owner, board) {
|
|
685
|
+
const raw = await this.readRaw(board.id);
|
|
686
|
+
const status = raw.fields.find((f) => f.name === STATUS_FIELD);
|
|
687
|
+
const fields = [];
|
|
688
|
+
for (const f of raw.fields) {
|
|
689
|
+
const type = fieldTypeOf(f.dataType);
|
|
690
|
+
if (!type || f.name === STATUS_FIELD) continue;
|
|
691
|
+
fields.push({
|
|
692
|
+
name: f.name,
|
|
693
|
+
type,
|
|
694
|
+
...f.options ? { options: f.options.map((o) => o.name) } : {}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
const views = [];
|
|
698
|
+
for (const v of raw.views) {
|
|
699
|
+
const layout = v.layout === "BOARD_LAYOUT" ? "board" : v.layout === "TABLE_LAYOUT" ? "table" : void 0;
|
|
700
|
+
if (!layout) continue;
|
|
701
|
+
views.push({
|
|
702
|
+
name: v.name,
|
|
703
|
+
layout,
|
|
704
|
+
...v.filter ? { filter: v.filter } : {},
|
|
705
|
+
...v.groupBy[0] ? { groupBy: v.groupBy[0] } : {}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
board: raw.summary,
|
|
710
|
+
statuses: (status?.options ?? []).map((o) => o.name),
|
|
711
|
+
fields,
|
|
712
|
+
views,
|
|
713
|
+
rules: raw.rules.map(({ name, enabled }) => ({
|
|
714
|
+
name,
|
|
715
|
+
enabled
|
|
716
|
+
}))
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Brings the board in line with the template. Never deletes a field, option or view, and never sends an option
|
|
721
|
+
* list without ids. Stops (throws) when the status field cannot be read.
|
|
722
|
+
*/
|
|
723
|
+
async ensure(_owner, board, template) {
|
|
724
|
+
const raw = await this.readRaw(board.id);
|
|
725
|
+
const status = raw.fields.find((f) => f.name === STATUS_FIELD && f.options);
|
|
726
|
+
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`);
|
|
727
|
+
const changes = [];
|
|
728
|
+
const todos = [];
|
|
729
|
+
const statusChanges = await this.ensureOptions(board, status, template.statuses.map((s) => ({
|
|
730
|
+
name: s.name,
|
|
731
|
+
color: s.color,
|
|
732
|
+
description: s.description
|
|
733
|
+
})));
|
|
734
|
+
changes.push(...statusChanges.map((c) => `status ${c}`));
|
|
735
|
+
for (const want of template.fields) {
|
|
736
|
+
const have = raw.fields.find((f) => f.name === want.name);
|
|
737
|
+
if (!have) {
|
|
738
|
+
await this.createField(board.id, want);
|
|
739
|
+
changes.push(`field ${want.name} created`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (fieldTypeOf(have.dataType) !== want.type) {
|
|
743
|
+
todos.push(`field "${want.name}" is ${have.dataType} but the template says ${want.type}; change it by hand`);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (want.type === "single_select" && have.options && want.options) {
|
|
747
|
+
const diff = await this.ensureOptions(board, have, want.options.map((name) => ({ name })));
|
|
748
|
+
changes.push(...diff.map((c) => `field ${want.name} option ${c}`));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
for (const want of template.views) {
|
|
752
|
+
const have = raw.views.find((v) => v.name === want.name);
|
|
753
|
+
const layout = want.layout === "board" ? "BOARD_LAYOUT" : "TABLE_LAYOUT";
|
|
754
|
+
let viewId = have?.id;
|
|
755
|
+
if (!viewId) {
|
|
756
|
+
viewId = await this.createView(board.id, want.name, layout);
|
|
757
|
+
changes.push(`view ${want.name} created`);
|
|
758
|
+
} else if (have && have.layout !== layout) {
|
|
759
|
+
await this.updateView(viewId, { layout });
|
|
760
|
+
changes.push(`view ${want.name} layout set to ${want.layout}`);
|
|
761
|
+
}
|
|
762
|
+
if (want.filter !== void 0 && want.filter !== (have?.filter ?? "")) {
|
|
763
|
+
await this.updateView(viewId, { filter: want.filter });
|
|
764
|
+
changes.push(`view ${want.name} filter set`);
|
|
765
|
+
}
|
|
766
|
+
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`);
|
|
767
|
+
}
|
|
768
|
+
for (const rule of template.rules ?? []) {
|
|
769
|
+
const have = raw.rules.find((r) => r.name === rule);
|
|
770
|
+
if (!have) todos.push(`create the automation rule "${rule}"; the API cannot create one`);
|
|
771
|
+
else if (!have.enabled) todos.push(`turn on the automation rule "${rule}"; the API cannot switch one`);
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
created: false,
|
|
775
|
+
changes,
|
|
776
|
+
todos
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Makes a single select field hold the wanted options in order, every existing one sent with its id. Options the
|
|
781
|
+
* template does not mention stay, after the wanted ones. Returns what changed (`Blocked added`, `order set`).
|
|
782
|
+
*/
|
|
783
|
+
async ensureOptions(board, field, wanted) {
|
|
784
|
+
/* v8 ignore next -- every caller passes a field it found with options; the fallback only satisfies the type */
|
|
785
|
+
const existing = field.options ?? [];
|
|
786
|
+
const next = [];
|
|
787
|
+
const changes = [];
|
|
788
|
+
for (const w of wanted) {
|
|
789
|
+
const have = existing.find((o) => o.name === w.name);
|
|
790
|
+
if (have) {
|
|
791
|
+
next.push({
|
|
792
|
+
id: have.id,
|
|
793
|
+
name: have.name,
|
|
794
|
+
color: (w.color ?? have.color).toUpperCase(),
|
|
795
|
+
description: w.description ?? have.description
|
|
796
|
+
});
|
|
797
|
+
if (next[next.length - 1].color !== have.color || next[next.length - 1].description !== have.description) changes.push(`${have.name} updated`);
|
|
798
|
+
} else {
|
|
799
|
+
next.push({
|
|
800
|
+
name: w.name,
|
|
801
|
+
color: (w.color ?? "GRAY").toUpperCase(),
|
|
802
|
+
description: w.description ?? ""
|
|
803
|
+
});
|
|
804
|
+
changes.push(`${w.name} added`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
for (const have of existing) if (!next.some((n) => n.id === have.id)) next.push({ ...have });
|
|
808
|
+
const sameOrder = next.length === existing.length && next.every((n, i) => n.id === existing[i]?.id);
|
|
809
|
+
if (changes.length === 0 && sameOrder) return [];
|
|
810
|
+
if (sameOrder === false && !changes.some((c) => c.endsWith("added"))) changes.push("order set");
|
|
811
|
+
const before = await this.fetchItems(board.id);
|
|
812
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
813
|
+
for (const item of before) {
|
|
814
|
+
const value = item.values[field.name];
|
|
815
|
+
if (value) snapshot.set(item.id, value);
|
|
816
|
+
}
|
|
817
|
+
await this.graphql.query(UPDATE_OPTIONS, {
|
|
818
|
+
field: field.id,
|
|
819
|
+
options: next.map(toOptionInput)
|
|
820
|
+
});
|
|
821
|
+
const afterField = (await this.readRaw(board.id)).fields.find((f) => f.id === field.id);
|
|
822
|
+
const afterItems = await this.fetchItems(board.id);
|
|
823
|
+
const afterValues = new Map(afterItems.map((i) => [i.id, i.values[field.name]]));
|
|
824
|
+
const lost = [...snapshot].filter(([itemId]) => !afterValues.get(itemId));
|
|
825
|
+
if (lost.length > 0) {
|
|
826
|
+
let restored = 0;
|
|
827
|
+
for (const [itemId, name] of lost) {
|
|
828
|
+
const option = afterField?.options?.find((o) => o.name === name);
|
|
829
|
+
if (!option) continue;
|
|
830
|
+
await this.graphql.query(SET_VALUE, {
|
|
831
|
+
project: board.id,
|
|
832
|
+
item: itemId,
|
|
833
|
+
field: field.id,
|
|
834
|
+
option: option.id
|
|
835
|
+
});
|
|
836
|
+
restored++;
|
|
837
|
+
}
|
|
838
|
+
throw new Error(`updating "${field.name}" on board "${board.name}" blanked ${lost.length} item value(s); wrote ${restored} back by option name`);
|
|
839
|
+
}
|
|
840
|
+
return changes;
|
|
841
|
+
}
|
|
842
|
+
async createField(projectId, want) {
|
|
843
|
+
await this.graphql.query(`mutation($project: ID!, $name: String!, $type: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]) {
|
|
844
|
+
createProjectV2Field(input: { projectId: $project, name: $name, dataType: $type, singleSelectOptions: $options }) { projectV2Field { ... on ProjectV2SingleSelectField { id } } }
|
|
845
|
+
}`, {
|
|
846
|
+
project: projectId,
|
|
847
|
+
name: want.name,
|
|
848
|
+
type: DATA_TYPES[want.type],
|
|
849
|
+
options: want.type === "single_select" ? (want.options ?? []).map((name) => toOptionInput({ name })) : void 0
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
async createView(projectId, name, layout) {
|
|
853
|
+
return (await this.graphql.query(`mutation($project: ID!, $name: String!, $layout: ProjectV2ViewLayout!) {
|
|
854
|
+
createProjectV2View(input: { projectId: $project, name: $name, layout: $layout }) { projectV2View { id } }
|
|
855
|
+
}`, {
|
|
856
|
+
project: projectId,
|
|
857
|
+
name,
|
|
858
|
+
layout
|
|
859
|
+
})).createProjectV2View.projectV2View.id;
|
|
860
|
+
}
|
|
861
|
+
async updateView(viewId, change) {
|
|
862
|
+
await this.graphql.query(`mutation($view: ID!, $layout: ProjectV2ViewLayout, $filter: String) {
|
|
863
|
+
updateProjectV2View(input: { viewId: $view, layout: $layout, filter: $filter }) { projectV2View { id } }
|
|
864
|
+
}`, {
|
|
865
|
+
view: viewId,
|
|
866
|
+
layout: change.layout,
|
|
867
|
+
filter: change.filter
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
/** The board's issues and pull requests with their status, whether they are open and their open blockers. */
|
|
871
|
+
async items(_owner, board, template) {
|
|
872
|
+
const categories = new Map(template.statuses.map((s) => [s.name, s.category]));
|
|
873
|
+
return (await this.fetchItems(board.id)).map((i) => {
|
|
874
|
+
const status = i.values[STATUS_FIELD];
|
|
875
|
+
const category = status ? categories.get(status) : void 0;
|
|
876
|
+
const priority = priorityOf(i.values[PRIORITY_FIELD]);
|
|
877
|
+
return {
|
|
878
|
+
key: this.keyOf(i.repo, i.number),
|
|
879
|
+
title: i.title,
|
|
880
|
+
open: i.open,
|
|
881
|
+
...status ? { status } : {},
|
|
882
|
+
...category ? { category } : {},
|
|
883
|
+
blockedBy: i.blockers.map((b) => this.keyOf(b.repo, b.number)),
|
|
884
|
+
...priority ? { priority } : {}
|
|
885
|
+
};
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
/** Puts an item in a status; `transitioned` is false when it already is there. */
|
|
889
|
+
async setStatus(_owner, board, key, status) {
|
|
890
|
+
const ref = this.parseKey(key);
|
|
891
|
+
const field = (await this.readRaw(board.id)).fields.find((f) => f.name === STATUS_FIELD && f.options);
|
|
892
|
+
const option = field?.options?.find((o) => o.name === status);
|
|
893
|
+
if (!field || !option) throw new Error(`board "${board.name}" has no status "${status}"`);
|
|
894
|
+
const item = (await this.fetchItems(board.id)).find((i) => i.repo === ref.repo && i.number === ref.number);
|
|
895
|
+
if (!item) throw new Error(`${key} is not on board "${board.name}"`);
|
|
896
|
+
if (item.values[STATUS_FIELD] === status) return {
|
|
897
|
+
transitioned: false,
|
|
898
|
+
status
|
|
899
|
+
};
|
|
900
|
+
await this.graphql.query(SET_VALUE, {
|
|
901
|
+
project: board.id,
|
|
902
|
+
item: item.id,
|
|
903
|
+
field: field.id,
|
|
904
|
+
option: option.id
|
|
905
|
+
});
|
|
906
|
+
return {
|
|
907
|
+
transitioned: true,
|
|
908
|
+
status,
|
|
909
|
+
via: "project field set"
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Puts the open items of `from` on `to` with their status and priority, only where the target has no value;
|
|
914
|
+
* closed and merged ones stay, and are set to the template's done status when theirs is not a done one.
|
|
915
|
+
*/
|
|
916
|
+
async move(_owner, from, to, template) {
|
|
917
|
+
const report = {
|
|
918
|
+
moved: [],
|
|
919
|
+
kept: [],
|
|
920
|
+
left: []
|
|
921
|
+
};
|
|
922
|
+
const [source, target, fromRaw, toRaw] = await Promise.all([
|
|
923
|
+
this.fetchItems(from.id),
|
|
924
|
+
this.fetchItems(to.id),
|
|
925
|
+
this.readRaw(from.id),
|
|
926
|
+
this.readRaw(to.id)
|
|
927
|
+
]);
|
|
928
|
+
const done = template.statuses.find((s) => s.category === "done")?.name;
|
|
929
|
+
const doneNames = new Set(template.statuses.filter((s) => s.category === "done").map((s) => s.name));
|
|
930
|
+
const carried = [STATUS_FIELD, PRIORITY_FIELD];
|
|
931
|
+
for (const item of source) {
|
|
932
|
+
const key = this.keyOf(item.repo, item.number);
|
|
933
|
+
if (!item.open) {
|
|
934
|
+
const current = item.values[STATUS_FIELD];
|
|
935
|
+
if (done && (!current || !doneNames.has(current))) await this.setOption(from.id, fromRaw, item.id, STATUS_FIELD, done);
|
|
936
|
+
report.left.push(key);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
let targetItem = target.find((t) => t.contentId === item.contentId);
|
|
940
|
+
if (targetItem) report.kept.push(key);
|
|
941
|
+
else {
|
|
942
|
+
const data = await this.graphql.query(`mutation($project: ID!, $content: ID!) { addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { item { id } } }`, {
|
|
943
|
+
project: to.id,
|
|
944
|
+
content: item.contentId
|
|
945
|
+
});
|
|
946
|
+
targetItem = {
|
|
947
|
+
...item,
|
|
948
|
+
id: data.addProjectV2ItemById.item.id,
|
|
949
|
+
values: {}
|
|
950
|
+
};
|
|
951
|
+
report.moved.push(key);
|
|
952
|
+
}
|
|
953
|
+
for (const name of carried) {
|
|
954
|
+
const value = item.values[name];
|
|
955
|
+
if (value && !targetItem.values[name]) await this.setOption(to.id, toRaw, targetItem.id, name, value);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return report;
|
|
959
|
+
}
|
|
960
|
+
/** A `holocron doctor` smoke check: how many boards the token sees in the configured repository's organization. */
|
|
961
|
+
async whoami() {
|
|
962
|
+
try {
|
|
963
|
+
return {
|
|
964
|
+
ok: true,
|
|
965
|
+
boards: (await this.list(this.repo.split("/")[0] ?? "")).length
|
|
966
|
+
};
|
|
967
|
+
} catch {
|
|
968
|
+
return {
|
|
969
|
+
ok: false,
|
|
970
|
+
boards: 0
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/** Sets a single select value by option name; a field or option the board lacks is skipped. */
|
|
975
|
+
async setOption(projectId, raw, itemId, fieldName, optionName) {
|
|
976
|
+
const field = raw.fields.find((f) => f.name === fieldName && f.options);
|
|
977
|
+
const option = field?.options?.find((o) => o.name === optionName);
|
|
978
|
+
if (!field || !option) return;
|
|
979
|
+
await this.graphql.query(SET_VALUE, {
|
|
980
|
+
project: projectId,
|
|
981
|
+
item: itemId,
|
|
982
|
+
field: field.id,
|
|
983
|
+
option: option.id
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
async ownerId(owner) {
|
|
987
|
+
return (await this.graphql.query(OWNER_QUERY, { owner })).organization.id;
|
|
988
|
+
}
|
|
989
|
+
async readRaw(id) {
|
|
990
|
+
const n = (await this.graphql.query(BOARD_QUERY, { id })).node;
|
|
991
|
+
if (!n) throw new Error(`board ${id} not found`);
|
|
992
|
+
return {
|
|
993
|
+
summary: toSummary(n),
|
|
994
|
+
fields: n.fields.nodes.filter((f) => f.id).map((f) => ({
|
|
995
|
+
id: f.id,
|
|
996
|
+
name: f.name,
|
|
997
|
+
dataType: f.dataType,
|
|
998
|
+
...f.options ? { options: f.options } : {}
|
|
999
|
+
})),
|
|
1000
|
+
views: n.views.nodes.map((v) => ({
|
|
1001
|
+
id: v.id,
|
|
1002
|
+
name: v.name,
|
|
1003
|
+
layout: v.layout,
|
|
1004
|
+
filter: v.filter,
|
|
1005
|
+
groupBy: (v.groupByFields?.nodes ?? []).map((g) => g.name)
|
|
1006
|
+
})),
|
|
1007
|
+
rules: n.workflows.nodes.map((w) => ({
|
|
1008
|
+
id: w.id,
|
|
1009
|
+
name: w.name,
|
|
1010
|
+
enabled: w.enabled
|
|
1011
|
+
}))
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
async fetchItems(id) {
|
|
1015
|
+
const items = [];
|
|
1016
|
+
let after = null;
|
|
1017
|
+
for (;;) {
|
|
1018
|
+
const page = (await this.graphql.query(ITEMS_QUERY, {
|
|
1019
|
+
id,
|
|
1020
|
+
after
|
|
1021
|
+
})).node.items;
|
|
1022
|
+
for (const n of page.nodes) {
|
|
1023
|
+
const c = n.content;
|
|
1024
|
+
if (!c || !c.repository) continue;
|
|
1025
|
+
const values = {};
|
|
1026
|
+
for (const v of n.fieldValues.nodes) if (v.name && v.field?.name) values[v.field.name] = v.name;
|
|
1027
|
+
items.push({
|
|
1028
|
+
id: n.id,
|
|
1029
|
+
contentId: c.id,
|
|
1030
|
+
repo: c.repository.nameWithOwner,
|
|
1031
|
+
number: c.number,
|
|
1032
|
+
title: c.title,
|
|
1033
|
+
open: c.state === "OPEN",
|
|
1034
|
+
values,
|
|
1035
|
+
blockers: (c.blockedBy?.nodes ?? []).filter((b) => b.state === "OPEN").map((b) => ({
|
|
1036
|
+
repo: b.repository.nameWithOwner,
|
|
1037
|
+
number: b.number
|
|
1038
|
+
}))
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
if (!page.pageInfo.hasNextPage) return items;
|
|
1042
|
+
after = page.pageInfo.endCursor;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
keyOf(repo, number) {
|
|
1046
|
+
return repo === this.repo ? `#${number}` : `${repo}#${number}`;
|
|
1047
|
+
}
|
|
1048
|
+
parseKey(key) {
|
|
1049
|
+
const m = /^(?:([^#\s]+))?#?(\d+)$/.exec(key.trim());
|
|
1050
|
+
const hasRepo = key.includes("#") && m?.[1];
|
|
1051
|
+
if (!m) throw new Error(`not an issue key: ${key}`);
|
|
1052
|
+
return {
|
|
1053
|
+
repo: hasRepo ? m[1] : this.repo,
|
|
1054
|
+
number: Number(m[2])
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
// src/sodium.ts
|
|
342
1059
|
/**
|
|
343
1060
|
* libsodium sealed-box encryption for GitHub Actions secrets.
|
|
344
1061
|
*
|
|
@@ -369,32 +1086,46 @@ async function encryptSecret(publicKeyBase64, value) {
|
|
|
369
1086
|
const ciphertext = sodium.crypto_box_seal(plaintext, publicKey);
|
|
370
1087
|
return sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
|
|
371
1088
|
}
|
|
372
|
-
|
|
373
|
-
|
|
1089
|
+
// src/capabilities/secrets.ts
|
|
1090
|
+
/** The GitHub implementation of the `secrets` capability, covering repository, environment and organization secrets. */
|
|
374
1091
|
var GitHubSecrets = class {
|
|
375
1092
|
client;
|
|
376
1093
|
opts;
|
|
1094
|
+
orgClient;
|
|
1095
|
+
/** Capability key, always `"secrets"`. */
|
|
377
1096
|
key = "secrets";
|
|
1097
|
+
/** Provider name, always `"github"`. */
|
|
378
1098
|
providerName = "github";
|
|
379
|
-
|
|
1099
|
+
/**
|
|
1100
|
+
* `client` handles repo and environment scopes; `orgClient`, when given, handles
|
|
1101
|
+
* the organization scope. They differ when a dedicated org-only token exists:
|
|
1102
|
+
* it can write org secrets but not repo ones, and `github.admin` is the reverse.
|
|
1103
|
+
*/
|
|
1104
|
+
constructor(client, opts, orgClient) {
|
|
380
1105
|
this.client = client;
|
|
381
1106
|
this.opts = opts;
|
|
1107
|
+
this.orgClient = orgClient;
|
|
1108
|
+
}
|
|
1109
|
+
clientFor(scope) {
|
|
1110
|
+
return scope.kind === "organization" ? this.orgClient ?? this.client : this.client;
|
|
382
1111
|
}
|
|
383
1112
|
async listSecrets(scope) {
|
|
384
|
-
return this.
|
|
1113
|
+
return this.clientFor(scope).secrets.listSecrets(this.opts.repo, toGHScope(scope));
|
|
385
1114
|
}
|
|
386
1115
|
async setSecret(scope, name, value) {
|
|
387
1116
|
const ghScope = toGHScope(scope);
|
|
388
|
-
const
|
|
1117
|
+
const client = this.clientFor(scope);
|
|
1118
|
+
const pk = await client.secrets.getPublicKey(this.opts.repo, ghScope);
|
|
389
1119
|
const body = {
|
|
390
1120
|
encrypted_value: await encryptSecret(pk.key, value),
|
|
391
1121
|
key_id: pk.key_id
|
|
392
1122
|
};
|
|
393
1123
|
if (scope.kind === "organization") body.visibility = "all";
|
|
394
|
-
await
|
|
1124
|
+
await client.secrets.putSecret(this.opts.repo, ghScope, name, body);
|
|
395
1125
|
}
|
|
1126
|
+
/** Deletes the named secret in the given scope. */
|
|
396
1127
|
async deleteSecret(scope, name) {
|
|
397
|
-
await this.
|
|
1128
|
+
await this.clientFor(scope).secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
|
|
398
1129
|
}
|
|
399
1130
|
};
|
|
400
1131
|
function toGHScope(scope) {
|
|
@@ -404,8 +1135,46 @@ function toGHScope(scope) {
|
|
|
404
1135
|
};
|
|
405
1136
|
return scope;
|
|
406
1137
|
}
|
|
407
|
-
|
|
408
|
-
|
|
1138
|
+
// src/capabilities/files.ts
|
|
1139
|
+
/** Reads one file; `null` when it does not exist. `version` is the blob sha `writeFile` needs to replace exactly this one. */
|
|
1140
|
+
/** A fine-grained token only reaches the repositories and permissions it was given; say which one is missing. */
|
|
1141
|
+
function noAccess(repo, action, cause) {
|
|
1142
|
+
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 });
|
|
1143
|
+
}
|
|
1144
|
+
async function readRepoFile(client, repo, path) {
|
|
1145
|
+
try {
|
|
1146
|
+
const file = await client.git.getContents(repo, path);
|
|
1147
|
+
if (file.encoding !== "base64") throw new Error(`${repo}/${path}: unexpected encoding \`${file.encoding}\``);
|
|
1148
|
+
return {
|
|
1149
|
+
content: Buffer.from(file.content, "base64").toString("utf-8"),
|
|
1150
|
+
version: file.sha
|
|
1151
|
+
};
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "read", err);
|
|
1154
|
+
if (!(err instanceof ProviderApiError && err.status === 404)) throw err;
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
await client.repos.getRepo(repo);
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
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 });
|
|
1160
|
+
throw err;
|
|
1161
|
+
}
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
/** Creates or replaces one file in a commit; GitHub refuses (409) a `version` that has since changed. */
|
|
1165
|
+
async function writeRepoFile(client, repo, path, content, opts) {
|
|
1166
|
+
try {
|
|
1167
|
+
await client.git.putContents(repo, path, {
|
|
1168
|
+
message: opts.message,
|
|
1169
|
+
content,
|
|
1170
|
+
...opts.version ? { sha: opts.version } : {}
|
|
1171
|
+
});
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "write", err);
|
|
1174
|
+
throw err;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// src/capabilities/labels.ts
|
|
409
1178
|
/**
|
|
410
1179
|
* Idempotently synchronise a repo's labels to the canonical set.
|
|
411
1180
|
*
|
|
@@ -443,14 +1212,202 @@ async function syncLabels(client, repo, canonical, stale) {
|
|
|
443
1212
|
}
|
|
444
1213
|
return `${created} created, ${updated} updated, ${deleted} deleted`;
|
|
445
1214
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
1215
|
+
// src/capabilities/property-fix-hints.ts
|
|
1216
|
+
/** GitHub's wording when a custom property value is not one the organization's definition allows. */
|
|
1217
|
+
const NOT_ALLOWED = /Value '([^']+)' is not allowed for property '([^']+)'/g;
|
|
1218
|
+
/**
|
|
1219
|
+
* What to do about each value GitHub says an organization custom property does not allow, one sentence per property,
|
|
1220
|
+
* read from GitHub's own message so it works even when the organization's definitions cannot be read. The values
|
|
1221
|
+
* belong to the organization's definition, not to the repository, so the fix is to add them there: a repository cannot
|
|
1222
|
+
* make its own value allowed. Empty when GitHub's message names no such value.
|
|
1223
|
+
*/
|
|
1224
|
+
function propertyFixHints(githubMessage) {
|
|
1225
|
+
const byProperty = /* @__PURE__ */ new Map();
|
|
1226
|
+
for (const [, value, property] of githubMessage.matchAll(NOT_ALLOWED)) {
|
|
1227
|
+
const values = byProperty.get(property) ?? [];
|
|
1228
|
+
if (!values.includes(value)) values.push(value);
|
|
1229
|
+
byProperty.set(property, values);
|
|
1230
|
+
}
|
|
1231
|
+
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`);
|
|
1232
|
+
}
|
|
1233
|
+
// src/capabilities/rejected-properties.ts
|
|
1234
|
+
/** The values `repo setup` sends for `true_false` properties; anything else is rejected. */
|
|
1235
|
+
const BOOLEANS = /* @__PURE__ */ new Set(["true", "false"]);
|
|
1236
|
+
/**
|
|
1237
|
+
* What is wrong with `values` when compared with the organization's property definitions, one sentence per
|
|
1238
|
+
* problem, in the order the properties are given: a property the organization does not define, a value a
|
|
1239
|
+
* `single_select` or `multi_select` property does not allow, or a `true_false` property that is not `true` or
|
|
1240
|
+
* `false`. A `string` or `url` property has nothing to check here. Empty when every value fits its definition.
|
|
1241
|
+
*/
|
|
1242
|
+
function rejectedProperties(values, schema) {
|
|
1243
|
+
const byName = new Map(schema.map((property) => [property.property_name, property]));
|
|
1244
|
+
const problems = [];
|
|
1245
|
+
for (const [name, value] of Object.entries(values)) {
|
|
1246
|
+
const definition = byName.get(name);
|
|
1247
|
+
if (!definition) {
|
|
1248
|
+
problems.push(`${name} is not a custom property of the organization`);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const sent = Array.isArray(value) ? value : [value];
|
|
1252
|
+
if (definition.value_type === "true_false") {
|
|
1253
|
+
for (const item of sent.filter((item) => !BOOLEANS.has(item))) problems.push(`${name} is "${item}", but it takes only true or false`);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const allowed = definition.allowed_values ?? [];
|
|
1257
|
+
if (definition.value_type !== "single_select" && definition.value_type !== "multi_select") continue;
|
|
1258
|
+
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(", ")})`);
|
|
1259
|
+
}
|
|
1260
|
+
return problems;
|
|
1261
|
+
}
|
|
1262
|
+
// src/capabilities/properties.ts
|
|
1263
|
+
/** GitHub's own message from a 422 body (`{"message": ..., "errors": [...]}`), or the start of the body when it is not JSON. */
|
|
1264
|
+
function githubMessage(details) {
|
|
1265
|
+
const raw = String(details ?? "").trim();
|
|
1266
|
+
try {
|
|
1267
|
+
const body = JSON.parse(raw);
|
|
1268
|
+
const errors = Array.isArray(body.errors) ? body.errors.map((e) => JSON.stringify(e)).join(", ") : "";
|
|
1269
|
+
const message = typeof body.message === "string" ? body.message : raw;
|
|
1270
|
+
return errors ? `${message}: ${errors}` : message;
|
|
1271
|
+
} catch {
|
|
1272
|
+
return raw.slice(0, 300);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/** The organization's property definitions, or `undefined` when they cannot be read (permission, an older client). */
|
|
1276
|
+
async function orgSchema(client, repo) {
|
|
1277
|
+
try {
|
|
1278
|
+
return await client.properties.orgSchema(repo.split("/")[0]);
|
|
1279
|
+
} catch {
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* The values `values` holds that the organization's property definitions do not allow, one sentence each, or
|
|
1285
|
+
* `undefined` when the definitions cannot be read (the token lacks the organization's custom properties permission, or
|
|
1286
|
+
* the organization defines none). `client` should be the organization-scoped one when there is one, since reading the
|
|
1287
|
+
* definitions is an organization permission a repository token often lacks. Read-only.
|
|
1288
|
+
*/
|
|
1289
|
+
async function checkPropertyValues(client, repo, values) {
|
|
1290
|
+
const schema = await orgSchema(client, repo);
|
|
1291
|
+
return schema ? rejectedProperties(values, schema) : void 0;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Sets the repo's custom properties. When GitHub answers 422 the error says which value it refused and what to do
|
|
1295
|
+
* about it: the values are compared with the organization's property definitions (read-only, with `schemaClient`, the
|
|
1296
|
+
* organization-scoped client when there is one), GitHub's own message is added, and each value GitHub says a property
|
|
1297
|
+
* does not allow gets the fix, which does not need the definitions to be readable. One refused value fails the whole
|
|
1298
|
+
* request, so none of the properties is set.
|
|
1299
|
+
*/
|
|
1300
|
+
async function syncProperties(client, repo, values, schemaClient = client) {
|
|
1301
|
+
try {
|
|
1302
|
+
await client.properties.setProperties(repo, values);
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
if (!(err instanceof ProviderApiError$1) || err.status !== 422) throw err;
|
|
1305
|
+
const problems = await checkPropertyValues(schemaClient, repo, values) ?? [];
|
|
1306
|
+
const said = githubMessage(err.details);
|
|
1307
|
+
const fixes = propertyFixHints(said);
|
|
1308
|
+
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 });
|
|
1309
|
+
}
|
|
450
1310
|
return `${Object.keys(values).length} properties set`;
|
|
451
1311
|
}
|
|
452
|
-
|
|
453
|
-
|
|
1312
|
+
/** The values the organization's property `name` allows; `undefined` when it cannot be read, is not defined or takes any value. */
|
|
1313
|
+
async function getOrgPropertyValues(client, repo, name) {
|
|
1314
|
+
return ((await orgSchema(client, repo))?.find((p) => p.property_name === name))?.allowed_values ?? void 0;
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Adds the missing `values` to the organization's property `name` and returns them. GitHub replaces the whole
|
|
1318
|
+
* definition on a write, so the current one is read and sent back with the allowed values extended, in their order,
|
|
1319
|
+
* and nothing removed. A write is made only when something is missing.
|
|
1320
|
+
*/
|
|
1321
|
+
async function addOrgPropertyValues(client, repo, name, values) {
|
|
1322
|
+
const org = repo.split("/")[0];
|
|
1323
|
+
const current = (await client.properties.orgSchema(org)).find((p) => p.property_name === name);
|
|
1324
|
+
if (!current) throw new Error(`the organization ${org} has no custom property "${name}"; create it first`);
|
|
1325
|
+
if (!current.allowed_values) throw new Error(`the custom property "${name}" takes any value; it has nothing to extend`);
|
|
1326
|
+
const have = new Set(current.allowed_values);
|
|
1327
|
+
const added = [...new Set(values)].filter((v) => !have.has(v));
|
|
1328
|
+
if (added.length === 0) return [];
|
|
1329
|
+
await client.properties.setOrgProperty(org, {
|
|
1330
|
+
...current,
|
|
1331
|
+
allowed_values: [...current.allowed_values, ...added]
|
|
1332
|
+
});
|
|
1333
|
+
return added;
|
|
1334
|
+
}
|
|
1335
|
+
// src/capabilities/read-repo.ts
|
|
1336
|
+
/** The settings `repo setup` writes, which `GET /repos/{repo}` reports under the same names. */
|
|
1337
|
+
const SETTING_KEYS = [
|
|
1338
|
+
"allow_squash_merge",
|
|
1339
|
+
"allow_merge_commit",
|
|
1340
|
+
"allow_rebase_merge",
|
|
1341
|
+
"allow_auto_merge",
|
|
1342
|
+
"allow_update_branch",
|
|
1343
|
+
"delete_branch_on_merge",
|
|
1344
|
+
"has_issues",
|
|
1345
|
+
"has_discussions",
|
|
1346
|
+
"has_projects",
|
|
1347
|
+
"has_wiki"
|
|
1348
|
+
];
|
|
1349
|
+
/** The repository's description, homepage, default branch, topics and settings, from one read. */
|
|
1350
|
+
async function readRepoInfo(client, repo) {
|
|
1351
|
+
const data = await client.repos.getRepo(repo);
|
|
1352
|
+
const settings = { default_branch: data.default_branch };
|
|
1353
|
+
for (const key of SETTING_KEYS) {
|
|
1354
|
+
const value = data[key];
|
|
1355
|
+
if (value !== void 0) settings[key] = value;
|
|
1356
|
+
}
|
|
1357
|
+
return {
|
|
1358
|
+
description: data.description ?? null,
|
|
1359
|
+
homepage: data.homepage ?? null,
|
|
1360
|
+
defaultBranch: data.default_branch,
|
|
1361
|
+
topics: data.topics ?? [],
|
|
1362
|
+
settings
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
/** A ruleset reduced to what `repo setup` writes: its enforcement, rule types and required status checks. */
|
|
1366
|
+
function toRulesetDetail(data) {
|
|
1367
|
+
const rules = data.rules ?? [];
|
|
1368
|
+
const checks = rules.find((r) => r.type === "required_status_checks")?.parameters?.required_status_checks;
|
|
1369
|
+
return {
|
|
1370
|
+
name: data.name,
|
|
1371
|
+
enforcement: data.enforcement,
|
|
1372
|
+
ruleTypes: rules.map((r) => r.type),
|
|
1373
|
+
requiredChecks: Array.isArray(checks) ? checks.map((c) => String(c.context)) : []
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
/** The value, or `undefined` when GitHub refuses the read (permission, plan, feature absent). Other errors still throw. */
|
|
1377
|
+
async function readable(read) {
|
|
1378
|
+
try {
|
|
1379
|
+
return await read();
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
if (err instanceof ProviderApiError$1) return void 0;
|
|
1382
|
+
throw err;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/** Which security features are on. One GitHub refuses to report is left out, not reported as off. */
|
|
1386
|
+
async function readSecurity(client, repo) {
|
|
1387
|
+
const [data, alerts, fixes, reporting, codeScanning] = await Promise.all([
|
|
1388
|
+
readable(() => client.repos.getRepo(repo)),
|
|
1389
|
+
readable(() => client.security.vulnerabilityAlertsEnabled(repo)),
|
|
1390
|
+
readable(() => client.security.automatedSecurityFixesEnabled(repo)),
|
|
1391
|
+
readable(() => client.security.privateVulnerabilityReportingEnabled(repo)),
|
|
1392
|
+
readable(() => client.security.codeScanningDefaultSetupState(repo))
|
|
1393
|
+
]);
|
|
1394
|
+
const analysis = data?.security_and_analysis;
|
|
1395
|
+
const on = (feature) => {
|
|
1396
|
+
const status = analysis?.[feature]?.status;
|
|
1397
|
+
return status === void 0 ? void 0 : status === "enabled";
|
|
1398
|
+
};
|
|
1399
|
+
const found = {
|
|
1400
|
+
vulnerabilityAlerts: alerts,
|
|
1401
|
+
automatedSecurityFixes: fixes,
|
|
1402
|
+
secretScanning: on("secret_scanning"),
|
|
1403
|
+
secretScanningPushProtection: on("secret_scanning_push_protection"),
|
|
1404
|
+
privateVulnerabilityReporting: reporting,
|
|
1405
|
+
codeScanningDefaultSetup: codeScanning === void 0 ? void 0 : codeScanning === "configured"
|
|
1406
|
+
};
|
|
1407
|
+
return Object.fromEntries(Object.entries(found).filter(([, v]) => v !== void 0));
|
|
1408
|
+
}
|
|
1409
|
+
// src/capabilities/teams.ts
|
|
1410
|
+
/** Turns a team entry into slug and permission form; a bare slug gets `push`. */
|
|
454
1411
|
function normalizeTeamEntry(entry) {
|
|
455
1412
|
if (typeof entry === "string") return {
|
|
456
1413
|
slug: entry,
|
|
@@ -458,6 +1415,7 @@ function normalizeTeamEntry(entry) {
|
|
|
458
1415
|
};
|
|
459
1416
|
return entry;
|
|
460
1417
|
}
|
|
1418
|
+
/** Grants each team access to the repo and reports the outcome; throws when every team fails. */
|
|
461
1419
|
async function syncTeams(client, repo, teams) {
|
|
462
1420
|
const [org = "", name = ""] = repo.split("/", 2);
|
|
463
1421
|
const normalized = teams.map(normalizeTeamEntry);
|
|
@@ -470,17 +1428,132 @@ async function syncTeams(client, repo, teams) {
|
|
|
470
1428
|
}
|
|
471
1429
|
return `${succeeded} team${succeeded === 1 ? "" : "s"} synced`;
|
|
472
1430
|
}
|
|
473
|
-
|
|
474
|
-
//#region src/capabilities/topics.ts
|
|
1431
|
+
// src/capabilities/topics.ts
|
|
475
1432
|
async function syncTopics(client, repo, topics) {
|
|
476
1433
|
await client.topics.setTopics(repo, topics);
|
|
477
1434
|
return `${topics.length} topics set`;
|
|
478
1435
|
}
|
|
479
|
-
|
|
480
|
-
|
|
1436
|
+
// src/capabilities/variables.ts
|
|
1437
|
+
/** GitHub's rule for a variable name: letters, digits and underscores, not starting with a digit or `GITHUB_`. */
|
|
1438
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1439
|
+
/** Longest name GitHub accepts. */
|
|
1440
|
+
const MAX_NAME = 256;
|
|
1441
|
+
function assertNames(names) {
|
|
1442
|
+
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_`);
|
|
1443
|
+
}
|
|
1444
|
+
/** GitHub treats variable names as case-insensitive, so every comparison goes through this. */
|
|
1445
|
+
const key = (name) => name.toUpperCase();
|
|
1446
|
+
/**
|
|
1447
|
+
* Bring one scope's variables in line with the declaration: create what is missing, update what differs, leave what
|
|
1448
|
+
* already matches. Variables that exist but are not declared are left alone, or deleted with `prune`, except an
|
|
1449
|
+
* organization variable limited to selected repositories, which this client cannot manage and so is never deleted.
|
|
1450
|
+
* Returns a one-line summary, naming the ones left so drift is visible. In a dry run it only describes the changes.
|
|
1451
|
+
*/
|
|
1452
|
+
async function reconcile(client, repo, scope, wanted, opts) {
|
|
1453
|
+
assertNames(wanted.map((w) => w.name));
|
|
1454
|
+
const existing = new Map((await client.variables.listVariables(repo, scope)).map((v) => [key(v.name), v]));
|
|
1455
|
+
const counts = {
|
|
1456
|
+
created: 0,
|
|
1457
|
+
updated: 0,
|
|
1458
|
+
unchanged: 0,
|
|
1459
|
+
deleted: 0
|
|
1460
|
+
};
|
|
1461
|
+
for (const want of wanted) {
|
|
1462
|
+
const have = existing.get(key(want.name));
|
|
1463
|
+
const body = {
|
|
1464
|
+
name: want.name,
|
|
1465
|
+
value: want.value
|
|
1466
|
+
};
|
|
1467
|
+
if (want.visibility) body["visibility"] = want.visibility;
|
|
1468
|
+
if (!have) {
|
|
1469
|
+
if (opts.dryRun) {
|
|
1470
|
+
counts.created++;
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
await client.variables.createVariable(repo, scope, body);
|
|
1475
|
+
counts.created++;
|
|
1476
|
+
continue;
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
if (!(err instanceof ProviderApiError && (err.status === 409 || err.status === 422))) throw err;
|
|
1479
|
+
}
|
|
1480
|
+
} else if (have.value === want.value && (!want.visibility || have.visibility === want.visibility)) {
|
|
1481
|
+
counts.unchanged++;
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (!opts.dryRun) await client.variables.updateVariable(repo, scope, have?.name ?? want.name, body);
|
|
1485
|
+
counts.updated++;
|
|
1486
|
+
}
|
|
1487
|
+
const declared = new Set(wanted.map((w) => key(w.name)));
|
|
1488
|
+
const undeclared = [...existing.entries()].filter(([k]) => !declared.has(k)).map(([, v]) => v);
|
|
1489
|
+
const unmanaged = undeclared.filter((v) => v.visibility === "selected");
|
|
1490
|
+
if (opts.prune) for (const v of undeclared.filter((u) => u.visibility !== "selected")) {
|
|
1491
|
+
if (!opts.dryRun) await client.variables.deleteVariable(repo, scope, v.name);
|
|
1492
|
+
counts.deleted++;
|
|
1493
|
+
}
|
|
1494
|
+
const parts = opts.dryRun ? [
|
|
1495
|
+
`would create ${counts.created}`,
|
|
1496
|
+
`would update ${counts.updated}`,
|
|
1497
|
+
`${counts.unchanged} unchanged`
|
|
1498
|
+
] : [
|
|
1499
|
+
`${counts.created} created`,
|
|
1500
|
+
`${counts.updated} updated`,
|
|
1501
|
+
`${counts.unchanged} unchanged`
|
|
1502
|
+
];
|
|
1503
|
+
if (counts.deleted > 0) parts.push(`${counts.deleted} ${opts.dryRun ? "would be deleted" : "deleted"}`);
|
|
1504
|
+
const left = opts.prune ? unmanaged : undeclared;
|
|
1505
|
+
if (left.length > 0) {
|
|
1506
|
+
const why = opts.prune ? "limited to selected repositories, not managed" : "not declared, left alone";
|
|
1507
|
+
parts.push(`${left.length} ${why}: ${left.map((v) => v.name).join(", ")}`);
|
|
1508
|
+
}
|
|
1509
|
+
return parts.join(", ");
|
|
1510
|
+
}
|
|
1511
|
+
/** Reconcile a repository's Actions variables. */
|
|
1512
|
+
function syncVariables(client, repo, variables, opts = {}) {
|
|
1513
|
+
return reconcile(client, repo, { kind: "repo" }, Object.entries(variables).map(([name, value]) => ({
|
|
1514
|
+
name,
|
|
1515
|
+
value
|
|
1516
|
+
})), {
|
|
1517
|
+
prune: opts.prune === true,
|
|
1518
|
+
dryRun: opts.dryRun === true
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
/** Reconcile an organization's Actions variables. `repo` is only the coordinate the client is bound to. */
|
|
1522
|
+
function syncOrgVariables(client, repo, org, variables, opts = {}) {
|
|
1523
|
+
const wanted = Object.entries(variables).map(([name, v]) => ({
|
|
1524
|
+
name,
|
|
1525
|
+
value: v.value,
|
|
1526
|
+
visibility: v.visibility
|
|
1527
|
+
}));
|
|
1528
|
+
return reconcile(client, repo, {
|
|
1529
|
+
kind: "organization",
|
|
1530
|
+
org
|
|
1531
|
+
}, wanted, {
|
|
1532
|
+
prune: opts.prune === true,
|
|
1533
|
+
dryRun: opts.dryRun === true
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
// src/capabilities/source.ts
|
|
1537
|
+
function toPullRequest(raw) {
|
|
1538
|
+
return {
|
|
1539
|
+
number: raw.number,
|
|
1540
|
+
title: raw.title,
|
|
1541
|
+
state: raw.state,
|
|
1542
|
+
merged: raw.merged_at !== null,
|
|
1543
|
+
branch: raw.head.ref,
|
|
1544
|
+
headSha: raw.head.sha,
|
|
1545
|
+
url: raw.html_url
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* The GitHub implementation of the `source` capability: repo settings, rulesets, branch protection, security
|
|
1550
|
+
* features and local workflow files.
|
|
1551
|
+
*/
|
|
481
1552
|
var GitHubSource = class {
|
|
482
1553
|
client;
|
|
1554
|
+
/** Capability key, always `"source"`. */
|
|
483
1555
|
key = "source";
|
|
1556
|
+
/** Provider name, always `"github"`. */
|
|
484
1557
|
providerName = "github";
|
|
485
1558
|
owner;
|
|
486
1559
|
name;
|
|
@@ -488,6 +1561,7 @@ var GitHubSource = class {
|
|
|
488
1561
|
repoRoot;
|
|
489
1562
|
workflowDir;
|
|
490
1563
|
orgClient;
|
|
1564
|
+
userSyncClient;
|
|
491
1565
|
baseUrl;
|
|
492
1566
|
fetchImpl;
|
|
493
1567
|
constructor(client, opts) {
|
|
@@ -496,15 +1570,20 @@ var GitHubSource = class {
|
|
|
496
1570
|
this.owner = owner;
|
|
497
1571
|
this.name = name;
|
|
498
1572
|
this.repo = opts.repo;
|
|
499
|
-
this.repoRoot = opts.repoRoot
|
|
500
|
-
|
|
1573
|
+
this.repoRoot = findRootSync(opts.repoRoot, ".git", {
|
|
1574
|
+
fallback: "start",
|
|
1575
|
+
realpath: true
|
|
1576
|
+
});
|
|
1577
|
+
this.workflowDir = join(this.repoRoot, ".github", "workflows");
|
|
501
1578
|
this.orgClient = opts.orgClient;
|
|
1579
|
+
this.userSyncClient = opts.userSyncClient;
|
|
502
1580
|
this.baseUrl = opts.baseUrl;
|
|
503
1581
|
this.fetchImpl = opts.fetch;
|
|
504
1582
|
}
|
|
505
1583
|
async whoami() {
|
|
506
1584
|
return { login: (await this.client.user.getCurrentUser()).login };
|
|
507
1585
|
}
|
|
1586
|
+
/** Returns the repo's owner, name and default branch. */
|
|
508
1587
|
async getRepo() {
|
|
509
1588
|
const data = await this.client.repos.getRepo(this.repo);
|
|
510
1589
|
return {
|
|
@@ -513,27 +1592,34 @@ var GitHubSource = class {
|
|
|
513
1592
|
defaultBranch: data.default_branch
|
|
514
1593
|
};
|
|
515
1594
|
}
|
|
1595
|
+
/** Lists the repo's rulesets. */
|
|
516
1596
|
async listRulesets() {
|
|
517
1597
|
return this.client.rulesets.listRulesets(this.repo);
|
|
518
1598
|
}
|
|
1599
|
+
/** Creates a ruleset from the given payload. */
|
|
519
1600
|
async createRuleset(payload) {
|
|
520
1601
|
return this.client.rulesets.createRuleset(this.repo, payload);
|
|
521
1602
|
}
|
|
1603
|
+
/** Updates the ruleset with the given id from the payload. */
|
|
522
1604
|
async updateRuleset(id, payload) {
|
|
523
1605
|
return this.client.rulesets.updateRuleset(this.repo, id, payload);
|
|
524
1606
|
}
|
|
1607
|
+
/** Updates the repo's settings. */
|
|
525
1608
|
async updateRepoSettings(settings) {
|
|
526
1609
|
await this.client.repos.updateRepo(this.repo, settings);
|
|
527
1610
|
}
|
|
528
1611
|
async protectBranch(branch, payload) {
|
|
529
1612
|
await this.client.branches.protectBranch(this.repo, branch, payload);
|
|
530
1613
|
}
|
|
1614
|
+
/** Enables Dependabot vulnerability alerts. */
|
|
531
1615
|
async enableVulnerabilityAlerts() {
|
|
532
1616
|
await this.client.security.enableVulnerabilityAlerts(this.repo);
|
|
533
1617
|
}
|
|
1618
|
+
/** Enables Dependabot automated security fixes. */
|
|
534
1619
|
async enableAutomatedSecurityFixes() {
|
|
535
1620
|
await this.client.security.enableAutomatedSecurityFixes(this.repo);
|
|
536
1621
|
}
|
|
1622
|
+
/** Enables secret scanning. */
|
|
537
1623
|
async enableSecretScanning() {
|
|
538
1624
|
await this.client.security.enableSecretScanning(this.repo);
|
|
539
1625
|
}
|
|
@@ -546,9 +1632,11 @@ var GitHubSource = class {
|
|
|
546
1632
|
async enableDependencyGraph() {
|
|
547
1633
|
await this.client.security.enableDependencyGraph(this.repo);
|
|
548
1634
|
}
|
|
1635
|
+
/** Enables private vulnerability reporting. */
|
|
549
1636
|
async enablePrivateVulnerabilityReporting() {
|
|
550
1637
|
await this.client.security.enablePrivateVulnerabilityReporting(this.repo);
|
|
551
1638
|
}
|
|
1639
|
+
/** Lists the `.yml` and `.yaml` files in `.github/workflows`, sorted by name; empty when the folder is missing. */
|
|
552
1640
|
async listWorkflowFiles() {
|
|
553
1641
|
try {
|
|
554
1642
|
return (await readdir(this.workflowDir)).filter((e) => e.endsWith(".yml") || e.endsWith(".yaml")).sort();
|
|
@@ -557,6 +1645,7 @@ var GitHubSource = class {
|
|
|
557
1645
|
throw err;
|
|
558
1646
|
}
|
|
559
1647
|
}
|
|
1648
|
+
/** Reads a workflow file's text, or `null` when it does not exist. */
|
|
560
1649
|
async readWorkflowFile(name) {
|
|
561
1650
|
try {
|
|
562
1651
|
return await readFile(join(this.workflowDir, name), "utf8");
|
|
@@ -565,10 +1654,12 @@ var GitHubSource = class {
|
|
|
565
1654
|
throw err;
|
|
566
1655
|
}
|
|
567
1656
|
}
|
|
1657
|
+
/** Writes a workflow file, creating `.github/workflows` first when needed. */
|
|
568
1658
|
async writeWorkflowFile(name, contents) {
|
|
569
1659
|
await ensureDir(this.workflowDir);
|
|
570
1660
|
await writeFile(join(this.workflowDir, name), contents, "utf8");
|
|
571
1661
|
}
|
|
1662
|
+
/** Removes a workflow file; a file that is already gone is not an error. */
|
|
572
1663
|
async removeWorkflowFile(name) {
|
|
573
1664
|
try {
|
|
574
1665
|
await rm(join(this.workflowDir, name));
|
|
@@ -586,14 +1677,83 @@ var GitHubSource = class {
|
|
|
586
1677
|
return syncLabels(this.client, this.repo, canonical, stale);
|
|
587
1678
|
}
|
|
588
1679
|
async syncProperties(values) {
|
|
589
|
-
return syncProperties(this.client, this.repo, values);
|
|
1680
|
+
return syncProperties(this.client, this.repo, values, this.orgClient ?? this.client);
|
|
1681
|
+
}
|
|
1682
|
+
async getOrgPropertyValues(name) {
|
|
1683
|
+
return getOrgPropertyValues(this.orgClient ?? this.client, this.repo, name);
|
|
1684
|
+
}
|
|
1685
|
+
async addOrgPropertyValues(name, values) {
|
|
1686
|
+
return addOrgPropertyValues(this.orgClient ?? this.client, this.repo, name, values);
|
|
1687
|
+
}
|
|
1688
|
+
async checkPropertyValues(values) {
|
|
1689
|
+
return checkPropertyValues(this.orgClient ?? this.client, this.repo, values);
|
|
590
1690
|
}
|
|
591
1691
|
async syncTeams(teams) {
|
|
592
1692
|
return syncTeams(this.orgClient ?? this.client, this.repo, teams);
|
|
593
1693
|
}
|
|
1694
|
+
async teamMembers(teamSlug, org = this.owner) {
|
|
1695
|
+
return (await (this.orgClient ?? this.client).teams.members(org, teamSlug)).map((m) => ({
|
|
1696
|
+
id: String(m.id),
|
|
1697
|
+
login: m.login
|
|
1698
|
+
}));
|
|
1699
|
+
}
|
|
1700
|
+
async readFile(path, repo = this.repo) {
|
|
1701
|
+
return readRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, repo, path);
|
|
1702
|
+
}
|
|
1703
|
+
async writeFile(path, content, opts) {
|
|
1704
|
+
return writeRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, opts.repo ?? this.repo, path, content, opts);
|
|
1705
|
+
}
|
|
594
1706
|
async syncTopics(topics) {
|
|
595
1707
|
return syncTopics(this.client, this.repo, topics);
|
|
596
1708
|
}
|
|
1709
|
+
async syncVariables(variables, opts) {
|
|
1710
|
+
return syncVariables(this.client, this.repo, variables, opts);
|
|
1711
|
+
}
|
|
1712
|
+
async syncOrgVariables(org, variables, opts) {
|
|
1713
|
+
if (!this.orgClient) throw new Error("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
|
|
1714
|
+
return syncOrgVariables(this.orgClient, this.repo, org, variables, opts);
|
|
1715
|
+
}
|
|
1716
|
+
async listVariables() {
|
|
1717
|
+
const found = await this.client.variables.listVariables(this.repo, { kind: "repo" });
|
|
1718
|
+
return Object.fromEntries(found.map((v) => [v.name, v.value]));
|
|
1719
|
+
}
|
|
1720
|
+
async listOrgVariables(org) {
|
|
1721
|
+
if (!this.orgClient) throw new MissingTokenError("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
|
|
1722
|
+
const found = await this.orgClient.variables.listVariables(this.repo, {
|
|
1723
|
+
kind: "organization",
|
|
1724
|
+
org
|
|
1725
|
+
});
|
|
1726
|
+
return Object.fromEntries(found.map((v) => [v.name, {
|
|
1727
|
+
value: v.value,
|
|
1728
|
+
visibility: v.visibility ?? "all"
|
|
1729
|
+
}]));
|
|
1730
|
+
}
|
|
1731
|
+
async getRepoInfo() {
|
|
1732
|
+
return readRepoInfo(this.client, this.repo);
|
|
1733
|
+
}
|
|
1734
|
+
async getProperties() {
|
|
1735
|
+
const found = await this.client.properties.getValues(this.repo);
|
|
1736
|
+
return Object.fromEntries(found.flatMap((p) => p.value === null ? [] : [[p.property_name, p.value]]));
|
|
1737
|
+
}
|
|
1738
|
+
async listLabels() {
|
|
1739
|
+
return (await this.client.labels.listLabels(this.repo)).map((l) => ({
|
|
1740
|
+
name: l.name,
|
|
1741
|
+
color: l.color,
|
|
1742
|
+
description: l.description ?? ""
|
|
1743
|
+
}));
|
|
1744
|
+
}
|
|
1745
|
+
async listTeamAccess() {
|
|
1746
|
+
return (await (this.orgClient ?? this.client).teams.listForRepo(this.repo)).map((t) => ({
|
|
1747
|
+
slug: t.slug,
|
|
1748
|
+
permission: t.permission
|
|
1749
|
+
}));
|
|
1750
|
+
}
|
|
1751
|
+
async getRulesetDetail(id) {
|
|
1752
|
+
return toRulesetDetail(await this.client.rulesets.getRuleset(this.repo, id));
|
|
1753
|
+
}
|
|
1754
|
+
async getSecurity() {
|
|
1755
|
+
return readSecurity(this.client, this.repo);
|
|
1756
|
+
}
|
|
597
1757
|
async syncDescription(description) {
|
|
598
1758
|
await this.client.repos.updateRepo(this.repo, { description });
|
|
599
1759
|
return "description updated";
|
|
@@ -603,15 +1763,27 @@ var GitHubSource = class {
|
|
|
603
1763
|
return "homepage updated";
|
|
604
1764
|
}
|
|
605
1765
|
async getPullRequest(number, repo) {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1766
|
+
return toPullRequest(await this.client.pulls.getPullRequest(repo ?? this.repo, number));
|
|
1767
|
+
}
|
|
1768
|
+
async listPullRequests(state, repo) {
|
|
1769
|
+
return (await this.client.pulls.listPullRequests(repo ?? this.repo, state)).map(toPullRequest);
|
|
1770
|
+
}
|
|
1771
|
+
async listBranches(repo) {
|
|
1772
|
+
return (await this.client.branches.listBranches(repo ?? this.repo)).map((b) => ({
|
|
1773
|
+
name: b.name,
|
|
1774
|
+
sha: b.commit.sha
|
|
1775
|
+
}));
|
|
1776
|
+
}
|
|
1777
|
+
async deleteBranch(name, repo) {
|
|
1778
|
+
try {
|
|
1779
|
+
await this.client.branches.removeBranch(repo ?? this.repo, name);
|
|
1780
|
+
} catch (err) {
|
|
1781
|
+
if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("does not exist")) return;
|
|
1782
|
+
throw err;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
async listPullRequestCommits(number, repo) {
|
|
1786
|
+
return (await this.client.pulls.listCommits(repo ?? this.repo, number)).map((commit) => commit.sha);
|
|
615
1787
|
}
|
|
616
1788
|
async configurePages(config, token) {
|
|
617
1789
|
const pagesClient = createGitHubClient$1({
|
|
@@ -650,18 +1822,8 @@ async function ensureDir(path) {
|
|
|
650
1822
|
throw err;
|
|
651
1823
|
}
|
|
652
1824
|
}
|
|
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
|
-
*/
|
|
1825
|
+
// src/verify-token.ts
|
|
1826
|
+
/** Checks a token against `GET /user` and returns who it belongs to, or why it failed. */
|
|
665
1827
|
async function verifyToken(token, opts = {}) {
|
|
666
1828
|
const client = createGitHubClient({
|
|
667
1829
|
token,
|
|
@@ -669,49 +1831,61 @@ async function verifyToken(token, opts = {}) {
|
|
|
669
1831
|
fetch: opts.fetch
|
|
670
1832
|
});
|
|
671
1833
|
try {
|
|
672
|
-
const me = await client.user.
|
|
1834
|
+
const { user: me, expiresAt } = await client.user.getCurrentUserWithExpiry();
|
|
673
1835
|
return {
|
|
674
1836
|
ok: true,
|
|
675
|
-
subject: `user @ ${me.login ?? me.email ?? "unknown"}
|
|
1837
|
+
subject: `user @ ${me.login ?? me.email ?? "unknown"}`,
|
|
1838
|
+
...expiresAt ? { expiresAt } : {}
|
|
676
1839
|
};
|
|
677
1840
|
} catch (err) {
|
|
678
1841
|
return {
|
|
679
1842
|
ok: false,
|
|
680
|
-
message:
|
|
1843
|
+
message: errorMessage(err)
|
|
681
1844
|
};
|
|
682
1845
|
}
|
|
683
1846
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
function source(ctx, orgClient) {
|
|
1847
|
+
// src/index.ts
|
|
1848
|
+
/** Builds the `source` capability; the optional clients cover org-level and user-sync access. */
|
|
1849
|
+
function source(ctx, orgClient, userSyncClient) {
|
|
687
1850
|
return new GitHubSource(ctx.client, {
|
|
688
1851
|
repo: ctx.repo,
|
|
689
1852
|
repoRoot: ctx.repoRoot,
|
|
690
1853
|
orgClient,
|
|
1854
|
+
userSyncClient,
|
|
691
1855
|
baseUrl: ctx.options.baseUrl,
|
|
692
1856
|
fetch: ctx.options.fetch
|
|
693
1857
|
});
|
|
694
1858
|
}
|
|
695
|
-
|
|
696
|
-
|
|
1859
|
+
/** Builds the `secrets` capability; the optional client handles organization secrets. */
|
|
1860
|
+
function secrets(ctx, orgClient) {
|
|
1861
|
+
return new GitHubSecrets(ctx.client, { repo: ctx.repo }, orgClient);
|
|
697
1862
|
}
|
|
1863
|
+
/** Builds the `environments` capability. */
|
|
698
1864
|
function environments(ctx) {
|
|
699
1865
|
return new GitHubEnvironments(ctx.client, { repo: ctx.repo });
|
|
700
1866
|
}
|
|
1867
|
+
/** Builds the `ci` capability. */
|
|
701
1868
|
function ci(ctx) {
|
|
702
1869
|
return new GitHubCi(ctx.client, { repo: ctx.repo });
|
|
703
1870
|
}
|
|
1871
|
+
/** Builds the `issues` capability, passing the lifecycle labels when configured. */
|
|
704
1872
|
function issues(ctx) {
|
|
705
1873
|
const opts = { repo: ctx.repo };
|
|
706
1874
|
if (ctx.options.labels !== void 0) opts.labels = ctx.options.labels;
|
|
707
1875
|
return new GitHubIssues(ctx.client, opts);
|
|
708
1876
|
}
|
|
1877
|
+
/** Builds the `projects` capability: GitHub Projects v2 over GraphQL, with items keyed relative to the configured repo. */
|
|
1878
|
+
function projects(ctx) {
|
|
1879
|
+
return new GitHubProjects(ctx.client.graphql, { repo: ctx.repo });
|
|
1880
|
+
}
|
|
1881
|
+
/** Creates the plugin, resolving a token per capability when it is first used. */
|
|
709
1882
|
function createPlugin(options) {
|
|
710
1883
|
function makeCtx(resolver) {
|
|
1884
|
+
const token = resolver(options);
|
|
711
1885
|
return {
|
|
712
1886
|
options,
|
|
713
1887
|
client: createGitHubClient$1({
|
|
714
|
-
token
|
|
1888
|
+
token,
|
|
715
1889
|
baseUrl: options.baseUrl,
|
|
716
1890
|
fetch: options.fetch
|
|
717
1891
|
}),
|
|
@@ -719,10 +1893,45 @@ function createPlugin(options) {
|
|
|
719
1893
|
repoRoot: options.repoRoot ?? process.cwd()
|
|
720
1894
|
};
|
|
721
1895
|
}
|
|
1896
|
+
/** A token, or undefined when that feature token is simply not configured. */
|
|
1897
|
+
function tryResolve(resolver) {
|
|
1898
|
+
try {
|
|
1899
|
+
return resolver(options);
|
|
1900
|
+
} catch (err) {
|
|
1901
|
+
if (err instanceof AuthError) return void 0;
|
|
1902
|
+
throw err;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Organization secrets need an org-resource-owner token (`github.secrets`);
|
|
1907
|
+
* repo and environment secrets need `github.admin`. Each scope prefers its own
|
|
1908
|
+
* token and falls back to the other, so a setup with only one of them keeps
|
|
1909
|
+
* working, and an explicit `--token` serves both. With neither configured this
|
|
1910
|
+
* fails exactly as the admin-token capabilities do.
|
|
1911
|
+
*/
|
|
1912
|
+
function makeSecrets() {
|
|
1913
|
+
const secretsToken = tryResolve(resolveSecretsToken);
|
|
1914
|
+
const adminToken = tryResolve(resolveAdminToken);
|
|
1915
|
+
if (!secretsToken && !adminToken) resolveAdminToken(options);
|
|
1916
|
+
const clientFor = (token) => createGitHubClient$1({
|
|
1917
|
+
token,
|
|
1918
|
+
baseUrl: options.baseUrl,
|
|
1919
|
+
fetch: options.fetch
|
|
1920
|
+
});
|
|
1921
|
+
const repoClient = clientFor(adminToken ?? secretsToken);
|
|
1922
|
+
const orgClient = clientFor(secretsToken ?? adminToken);
|
|
1923
|
+
return secrets({
|
|
1924
|
+
options,
|
|
1925
|
+
client: repoClient,
|
|
1926
|
+
repo: options.repo,
|
|
1927
|
+
repoRoot: options.repoRoot ?? process.cwd()
|
|
1928
|
+
}, orgClient);
|
|
1929
|
+
}
|
|
722
1930
|
function makeOrgClient() {
|
|
723
1931
|
try {
|
|
1932
|
+
const token = resolveOrgToken(options);
|
|
724
1933
|
return createGitHubClient$1({
|
|
725
|
-
token
|
|
1934
|
+
token,
|
|
726
1935
|
baseUrl: options.baseUrl,
|
|
727
1936
|
fetch: options.fetch
|
|
728
1937
|
});
|
|
@@ -730,14 +1939,34 @@ function createPlugin(options) {
|
|
|
730
1939
|
return;
|
|
731
1940
|
}
|
|
732
1941
|
}
|
|
1942
|
+
/** The token for the user-sync files; undefined when it is not configured, and the org token is used instead. */
|
|
1943
|
+
function makeUserSyncClient() {
|
|
1944
|
+
const token = tryResolve(resolveUserSyncToken);
|
|
1945
|
+
return token ? createGitHubClient$1({
|
|
1946
|
+
token,
|
|
1947
|
+
baseUrl: options.baseUrl,
|
|
1948
|
+
fetch: options.fetch
|
|
1949
|
+
}) : void 0;
|
|
1950
|
+
}
|
|
733
1951
|
return {
|
|
734
1952
|
name: "@theholocron/holocron-plugin-github",
|
|
1953
|
+
probes: { connection: connectionProbe({
|
|
1954
|
+
provider: "GitHub",
|
|
1955
|
+
url: options.baseUrl ?? "https://api.github.com",
|
|
1956
|
+
fetch: options.fetch,
|
|
1957
|
+
token: () => resolveReadToken(options),
|
|
1958
|
+
verify: (token) => verifyToken(token, {
|
|
1959
|
+
baseUrl: options.baseUrl,
|
|
1960
|
+
fetch: options.fetch
|
|
1961
|
+
})
|
|
1962
|
+
}) },
|
|
735
1963
|
capabilities: {
|
|
736
|
-
source: () => source(makeCtx(resolveAdminToken), makeOrgClient()),
|
|
1964
|
+
source: () => source(makeCtx(resolveAdminToken), makeOrgClient(), makeUserSyncClient()),
|
|
737
1965
|
ci: () => ci(makeCtx(resolveReadToken)),
|
|
738
|
-
secrets: () =>
|
|
1966
|
+
secrets: () => makeSecrets(),
|
|
739
1967
|
environments: () => environments(makeCtx(resolveAdminToken)),
|
|
740
|
-
issues: () => issues(makeCtx(resolveIssuesToken))
|
|
1968
|
+
issues: () => issues(makeCtx(resolveIssuesToken)),
|
|
1969
|
+
projects: () => projects(makeCtx(resolveOrgToken))
|
|
741
1970
|
}
|
|
742
1971
|
};
|
|
743
1972
|
}
|
|
@@ -746,5 +1975,16 @@ function createPlugin(options) {
|
|
|
746
1975
|
* is supplied or the supplied token is rejected.
|
|
747
1976
|
*/
|
|
748
1977
|
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
|
-
|
|
1978
|
+
/** Every token this plugin takes, for `holocron doctor tokens`. */
|
|
1979
|
+
const tokens = [
|
|
1980
|
+
resolveReadToken,
|
|
1981
|
+
resolveIssuesToken,
|
|
1982
|
+
resolveSyncToken,
|
|
1983
|
+
resolveReleaseToken,
|
|
1984
|
+
resolveAdminToken,
|
|
1985
|
+
resolveSecretsToken,
|
|
1986
|
+
resolveDeployToken,
|
|
1987
|
+
resolveUserSyncToken,
|
|
1988
|
+
resolveOrgToken
|
|
1989
|
+
].map((resolver) => resolver.token);
|
|
1990
|
+
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 };
|