@theholocron/holocron-plugin-github 5.0.0-alpha.339 → 5.0.0-alpha.340
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/index.d.mts +68 -3
- package/dist/index.mjs +504 -2
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# `@theholocron/holocron-plugin-github`
|
|
2
2
|
|
|
3
|
-
GitHub plugin for [Holocron](../cli). Implements
|
|
4
|
-
against the GitHub REST
|
|
3
|
+
GitHub plugin for [Holocron](../cli). Implements six capabilities
|
|
4
|
+
against the GitHub REST and GraphQL APIs:
|
|
5
5
|
|
|
6
6
|
| Capability | What this plugin does |
|
|
7
7
|
| -------------- | ---------------------------------------------------------------- |
|
|
@@ -10,6 +10,7 @@ against the GitHub REST API:
|
|
|
10
10
|
| `secrets` | GH Actions secrets (repo + environment + organization) |
|
|
11
11
|
| `environments` | Named deployment environments (reviewers, wait timers) |
|
|
12
12
|
| `issues` | GitHub Issues as a tracker (with lifecycle slots) |
|
|
13
|
+
| `projects` | GitHub Projects v2 boards (GraphQL) |
|
|
13
14
|
|
|
14
15
|
## Install
|
|
15
16
|
|
|
@@ -34,6 +35,7 @@ Each capability resolves its own fine-grained token so a compromised credential
|
|
|
34
35
|
| `HOLOCRON_SYNC_TOKEN` | `github.sync` | `sync-github` command |
|
|
35
36
|
| `HOLOCRON_RELEASE_TOKEN` | `github.release` | semantic-release, GitHub releases |
|
|
36
37
|
| `HOLOCRON_ADMIN_TOKEN` | `github.admin` | `source`, `secrets`, `environments` |
|
|
38
|
+
| `HOLOCRON_ORG_TOKEN` | `github.org` | `projects` |
|
|
37
39
|
|
|
38
40
|
Store tokens once via the keyring so they are picked up automatically:
|
|
39
41
|
|
|
@@ -64,6 +66,29 @@ See the [token reference](https://docs.theholocron.dev/holocron/tokens/) for ful
|
|
|
64
66
|
|
|
65
67
|
```
|
|
66
68
|
|
|
69
|
+
## `projects` capability
|
|
70
|
+
|
|
71
|
+
GitHub Projects v2 boards over GraphQL, for `holocron project ...` (SPEC-0020, #1744). It uses the org token
|
|
72
|
+
(`HOLOCRON_ORG_TOKEN`, keyring `github.org`) with the **Projects** organization permission. Items are keyed `#N` in the
|
|
73
|
+
configured `repo` and `owner/repo#N` elsewhere.
|
|
74
|
+
|
|
75
|
+
| Method | What it does |
|
|
76
|
+
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
77
|
+
| `list` | Every project of the organization, open and closed. `find` is the exact name among the open ones. |
|
|
78
|
+
| `create` | Copies the board `template.source` names (fields, options, views, automation rules, no items), or creates a project and applies the template. |
|
|
79
|
+
| `close` | `updateProjectV2` with `closed: true`. Reversible. |
|
|
80
|
+
| `read` | Statuses, other fields, board and table views and automation rules. |
|
|
81
|
+
| `ensure` | Adds what the template lists and the board lacks (options, fields, views, filters). Never deletes. Rules and view grouping the API cannot set come back as `todos`. |
|
|
82
|
+
| `items` | Each issue or pull request with its status, category, whether it is open and its open blockers. |
|
|
83
|
+
| `setStatus` | Sets an item's status. Idempotent: `transitioned: false` when it is already there. |
|
|
84
|
+
| `move` | Puts open items on another board with status and priority (only where the target has none); closed and merged ones stay and are set to done when theirs is not a done one. |
|
|
85
|
+
| `whoami` | How many boards the token sees, for `holocron doctor`. |
|
|
86
|
+
|
|
87
|
+
`ensure` is safe on a field that has items. `updateProjectV2Field` replaces a single select field's whole option list,
|
|
88
|
+
and an option sent without its `id` blanks the field on every item, so `ensure` reads the field first, sends every
|
|
89
|
+
existing option with its `id` in the template's order (new ones without), and stops when the field cannot be read. It
|
|
90
|
+
also keeps the item values in memory and, if a re-read shows any gone, writes them back by option name and fails.
|
|
91
|
+
|
|
67
92
|
## Status
|
|
68
93
|
|
|
69
94
|
**`v2.0.0-alpha.0`** — published on npm under the `alpha` dist-tag.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Auth, AuthError, Ci, CiRun, CiRunFilter, Environment, Environments, Issue, IssueSearchFilter, Issues, LabelDef, LifecycleResult, LifecycleSlot, PagesConfig, PluginToken, PullRequest, RepoRef, RepoSettings, ResolveTokenInput, Ruleset, SecretScope, Secrets, Source, SourceBranch, SourceFile, SourceRepoInfo, SourceRulesetDetail, SourceSecurity, SourceTeamMember, TeamEntry, TeamEntry as TeamEntry$1, TeamPermission, TeamPermission as TeamPermission$1, TrackerDoctorReport, TrackerUser, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess, createFeatureResolver } from "@theholocron/cli";
|
|
2
|
-
import { GitHubClient, GitHubClientOptions, createGitHubClient } from "@theholocron/github-client";
|
|
1
|
+
import { Auth, AuthError, BoardItem, BoardState, BoardSummary, BoardTemplate, Ci, CiRun, CiRunFilter, EnsureReport, Environment, Environments, Issue, IssueSearchFilter, Issues, LabelDef, LifecycleResult, LifecycleSlot, MoveReport, PagesConfig, PluginToken, Projects, PullRequest, RepoRef, RepoSettings, ResolveTokenInput, Ruleset, SecretScope, Secrets, Source, SourceBranch, SourceFile, SourceRepoInfo, SourceRulesetDetail, SourceSecurity, SourceTeamMember, TeamEntry, TeamEntry as TeamEntry$1, TeamPermission, TeamPermission as TeamPermission$1, TrackerDoctorReport, TrackerUser, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess, createFeatureResolver } from "@theholocron/cli";
|
|
2
|
+
import { GitHubClient, GitHubClientOptions, GraphQLClient, createGitHubClient } from "@theholocron/github-client";
|
|
3
3
|
/** Resolves the token for read-only GitHub access (`HOLOCRON_READ_TOKEN`, keyring key `github.read`). */
|
|
4
4
|
export declare const resolveReadToken: import("@theholocron/cli").FeatureResolver;
|
|
5
5
|
/** Resolves the token for issue operations (`HOLOCRON_ISSUES_TOKEN`, keyring key `github.issues`). */
|
|
@@ -126,6 +126,68 @@ interface CanonicalLabel {
|
|
|
126
126
|
* - Leaves unrecognised labels that aren't in `stale` untouched.
|
|
127
127
|
*/
|
|
128
128
|
export declare function syncLabels(client: GitHubClient, repo: string, canonical: ReadonlyArray<CanonicalLabel>, stale: ReadonlyArray<string>): Promise<string>;
|
|
129
|
+
/** Options of the GitHub `projects` capability. */
|
|
130
|
+
interface ProjectsOptions {
|
|
131
|
+
/** The repository the provider is configured for, as `owner/name`; its items get the short key `#N`. */
|
|
132
|
+
repo: string;
|
|
133
|
+
}
|
|
134
|
+
/** The GitHub implementation of the `projects` capability, backed by Projects v2. */
|
|
135
|
+
export declare class GitHubProjects implements Projects {
|
|
136
|
+
private readonly graphql;
|
|
137
|
+
/** Capability key, always `"projects"`. */
|
|
138
|
+
readonly key: "projects";
|
|
139
|
+
/** Provider name, always `"github"`. */
|
|
140
|
+
readonly providerName = "github";
|
|
141
|
+
private readonly repo;
|
|
142
|
+
constructor(graphql: GraphQLClient, opts: ProjectsOptions);
|
|
143
|
+
/** Every board of the organization, open and closed. */
|
|
144
|
+
list(owner: string): Promise<BoardSummary[]>;
|
|
145
|
+
/** The open board with exactly this name. */
|
|
146
|
+
find(owner: string, name: string): Promise<BoardSummary | undefined>;
|
|
147
|
+
/**
|
|
148
|
+
* Makes a board. With `template.source` naming an open board it is copied (fields, options, views and automation
|
|
149
|
+
* rules, no items); otherwise the board is created and brought in line with the template.
|
|
150
|
+
*/
|
|
151
|
+
create(owner: string, name: string, template: BoardTemplate): Promise<BoardSummary>;
|
|
152
|
+
/** Closes the board; `updateProjectV2` with `closed: true` is the only way, and reversible. */
|
|
153
|
+
close(_owner: string, board: BoardSummary): Promise<void>;
|
|
154
|
+
/** The live board: statuses, other fields, views and automation rules. */
|
|
155
|
+
read(_owner: string, board: BoardSummary): Promise<BoardState>;
|
|
156
|
+
/**
|
|
157
|
+
* Brings the board in line with the template. Never deletes a field, option or view, and never sends an option
|
|
158
|
+
* list without ids. Stops (throws) when the status field cannot be read.
|
|
159
|
+
*/
|
|
160
|
+
ensure(_owner: string, board: BoardSummary, template: BoardTemplate): Promise<EnsureReport>;
|
|
161
|
+
/**
|
|
162
|
+
* Makes a single select field hold the wanted options in order, every existing one sent with its id. Options the
|
|
163
|
+
* template does not mention stay, after the wanted ones. Returns what changed (`Blocked added`, `order set`).
|
|
164
|
+
*/
|
|
165
|
+
private ensureOptions;
|
|
166
|
+
private createField;
|
|
167
|
+
private createView;
|
|
168
|
+
private updateView;
|
|
169
|
+
/** The board's issues and pull requests with their status, whether they are open and their open blockers. */
|
|
170
|
+
items(_owner: string, board: BoardSummary, template: BoardTemplate): Promise<BoardItem[]>;
|
|
171
|
+
/** Puts an item in a status; `transitioned` is false when it already is there. */
|
|
172
|
+
setStatus(_owner: string, board: BoardSummary, key: string, status: string): Promise<LifecycleResult>;
|
|
173
|
+
/**
|
|
174
|
+
* Puts the open items of `from` on `to` with their status and priority, only where the target has no value;
|
|
175
|
+
* closed and merged ones stay, and are set to the template's done status when theirs is not a done one.
|
|
176
|
+
*/
|
|
177
|
+
move(_owner: string, from: BoardSummary, to: BoardSummary, template: BoardTemplate): Promise<MoveReport>;
|
|
178
|
+
/** A `holocron doctor` smoke check: how many boards the token sees in the configured repository's organization. */
|
|
179
|
+
whoami(): Promise<{
|
|
180
|
+
ok: boolean;
|
|
181
|
+
boards: number;
|
|
182
|
+
}>;
|
|
183
|
+
/** Sets a single select value by option name; a field or option the board lacks is skipped. */
|
|
184
|
+
private setOption;
|
|
185
|
+
private ownerId;
|
|
186
|
+
private readRaw;
|
|
187
|
+
private fetchItems;
|
|
188
|
+
private keyOf;
|
|
189
|
+
private parseKey;
|
|
190
|
+
}
|
|
129
191
|
interface SecretsOptions {
|
|
130
192
|
repo: string;
|
|
131
193
|
}
|
|
@@ -326,6 +388,8 @@ export declare function environments(ctx: PluginContext): Environments;
|
|
|
326
388
|
export declare function ci(ctx: PluginContext): Ci;
|
|
327
389
|
/** Builds the `issues` capability, passing the lifecycle labels when configured. */
|
|
328
390
|
export declare function issues(ctx: PluginContext): Issues;
|
|
391
|
+
/** Builds the `projects` capability: GitHub Projects v2 over GraphQL, with items keyed relative to the configured repo. */
|
|
392
|
+
export declare function projects(ctx: PluginContext): Projects;
|
|
329
393
|
/** Creates the plugin, resolving a token per capability when it is first used. */
|
|
330
394
|
export declare function createPlugin(options: GitHubPluginOptions): {
|
|
331
395
|
name: string;
|
|
@@ -338,6 +402,7 @@ export declare function createPlugin(options: GitHubPluginOptions): {
|
|
|
338
402
|
secrets: () => Secrets;
|
|
339
403
|
environments: () => Environments;
|
|
340
404
|
issues: () => Issues;
|
|
405
|
+
projects: () => Projects;
|
|
341
406
|
};
|
|
342
407
|
};
|
|
343
408
|
/**
|
|
@@ -347,4 +412,4 @@ export declare function createPlugin(options: GitHubPluginOptions): {
|
|
|
347
412
|
export declare const AUTH_HINT: string;
|
|
348
413
|
/** Every token this plugin takes, for `holocron doctor tokens`. */
|
|
349
414
|
export declare const tokens: PluginToken[];
|
|
350
|
-
export { type Auth, AuthError, type CanonicalLabel, type GitHubClientOptions, type NormalizedTeamEntry, type ResolveTokenInput, type TeamEntry, type TeamPermission, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createFeatureResolver, createGitHubClient };
|
|
415
|
+
export { type Auth, AuthError, type CanonicalLabel, type GitHubClientOptions, type NormalizedTeamEntry, type ProjectsOptions, type ResolveTokenInput, type TeamEntry, type TeamPermission, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createFeatureResolver, createGitHubClient };
|
package/dist/index.mjs
CHANGED
|
@@ -376,6 +376,503 @@ function parseIssueNumber(key, owner, repoName) {
|
|
|
376
376
|
if (!/^\d+$/.test(num)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
|
|
377
377
|
return parseInt(num, 10);
|
|
378
378
|
}
|
|
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
|
+
};
|
|
379
876
|
// src/sodium.ts
|
|
380
877
|
/**
|
|
381
878
|
* libsodium sealed-box encryption for GitHub Actions secrets.
|
|
@@ -1166,6 +1663,10 @@ function issues(ctx) {
|
|
|
1166
1663
|
if (ctx.options.labels !== void 0) opts.labels = ctx.options.labels;
|
|
1167
1664
|
return new GitHubIssues(ctx.client, opts);
|
|
1168
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
|
+
}
|
|
1169
1670
|
/** Creates the plugin, resolving a token per capability when it is first used. */
|
|
1170
1671
|
function createPlugin(options) {
|
|
1171
1672
|
function makeCtx(resolver) {
|
|
@@ -1253,7 +1754,8 @@ function createPlugin(options) {
|
|
|
1253
1754
|
ci: () => ci(makeCtx(resolveReadToken)),
|
|
1254
1755
|
secrets: () => makeSecrets(),
|
|
1255
1756
|
environments: () => environments(makeCtx(resolveAdminToken)),
|
|
1256
|
-
issues: () => issues(makeCtx(resolveIssuesToken))
|
|
1757
|
+
issues: () => issues(makeCtx(resolveIssuesToken)),
|
|
1758
|
+
projects: () => projects(makeCtx(resolveOrgToken))
|
|
1257
1759
|
}
|
|
1258
1760
|
};
|
|
1259
1761
|
}
|
|
@@ -1274,4 +1776,4 @@ const tokens = [
|
|
|
1274
1776
|
resolveUserSyncToken,
|
|
1275
1777
|
resolveOrgToken
|
|
1276
1778
|
].map((resolver) => resolver.token);
|
|
1277
|
-
export { AUTH_HINT, AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubSecrets, GitHubSource, ci, createFeatureResolver, createGitHubClient, createPlugin, encryptSecret, environments, issues, normalizeTeamEntry, resolveAdminToken, resolveDeployToken, resolveIssuesToken, resolveOrgToken, resolveReadToken, resolveReleaseToken, resolveSecretsToken, resolveSyncToken, resolveUserSyncToken, secrets, source, syncLabels, syncTeams, tokens, verifyToken };
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/holocron-plugin-github",
|
|
3
|
-
"version": "5.0.0-alpha.
|
|
3
|
+
"version": "5.0.0-alpha.340",
|
|
4
4
|
"description": "Holocron plugin for GitHub. Implements source, ci, secrets, environments, and issues capabilities against the GitHub REST API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ci",
|
|
@@ -44,21 +44,21 @@
|
|
|
44
44
|
"tsx": "4.23.15",
|
|
45
45
|
"typescript": "^5.9.3",
|
|
46
46
|
"vitest": "^5.0.5",
|
|
47
|
-
"@theholocron/cli": "5.0.0-alpha.339",
|
|
48
|
-
"@theholocron/eslint-config": "^8.9.0-alpha.8",
|
|
49
47
|
"@theholocron/fs-utils": "^1.17.0-alpha.1",
|
|
50
|
-
"@theholocron/
|
|
51
|
-
"@theholocron/http-client": "^1.58.0-alpha.
|
|
52
|
-
"@theholocron/tsdown-config": "^8.9.0-alpha.8",
|
|
53
|
-
"@theholocron/vitest-config": "^8.9.0-alpha.8",
|
|
48
|
+
"@theholocron/eslint-config": "^8.9.0-alpha.8",
|
|
49
|
+
"@theholocron/http-client": "^1.58.0-alpha.8",
|
|
54
50
|
"@theholocron/misc-utils": "^1.17.0-alpha.1",
|
|
55
|
-
"@theholocron/
|
|
51
|
+
"@theholocron/cli": "5.0.0-alpha.340",
|
|
52
|
+
"@theholocron/vitest-config": "^8.9.0-alpha.8",
|
|
53
|
+
"@theholocron/tsdown-config": "^8.9.0-alpha.8",
|
|
54
|
+
"@theholocron/tsconfig": "^8.9.0-alpha.8",
|
|
55
|
+
"@theholocron/github-client": "^1.58.0-alpha.8"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@theholocron/fs-utils": "^1.16.0",
|
|
59
59
|
"@theholocron/github-client": "^1.58.0",
|
|
60
60
|
"@theholocron/misc-utils": "^1.16.0",
|
|
61
|
-
"@theholocron/cli": "5.0.0-alpha.
|
|
61
|
+
"@theholocron/cli": "5.0.0-alpha.340"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
64
|
"node": ">=22"
|