@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # `@theholocron/holocron-plugin-github`
2
2
 
3
- GitHub plugin for [Holocron](../cli). Implements five capabilities
4
- against the GitHub REST API:
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.339",
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/github-client": "^1.58.0-alpha.7",
51
- "@theholocron/http-client": "^1.58.0-alpha.7",
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/tsconfig": "^8.9.0-alpha.8"
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.339"
61
+ "@theholocron/cli": "5.0.0-alpha.340"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=22"