@theholocron/holocron-plugin-github 5.0.0-alpha.374 → 5.0.0-alpha.376

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AddedItem, Auth, AuthError, BoardItem, BoardItemInput, BoardState, BoardSummary, BoardTemplate, Ci, CiRun, CiRunFilter, EnsureReport, Environment, Environments, ImportOptions, ImportResult, Issue, IssueRecord, IssueSearchFilter, IssueSelector, 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";
1
+ import { AddedItem, Auth, AuthError, BoardItem, BoardItemInput, BoardState, BoardSummary, BoardTemplate, Ci, CiRun, CiRunFilter, EnsureReport, Environment, Environments, ImportOptions, ImportResult, Issue, IssueRecord, IssueSearchFilter, IssueSelector, Issues, LabelDef, LifecycleResult, LifecycleSlot, MoveReport, PagesConfig, PluginToken, Projects, PullRequest, RepoRef, RepoSettings, ResolveTokenInput, Ruleset, SecretScope, Secrets, Source, SourceBranch, SourceFile, SourceRepoInfo, SourceRulesetDetail, SourceSecurity, SourceTeamMember, SyncResult, TeamEntry, TeamEntry as TeamEntry$1, TeamPermission, TeamPermission as TeamPermission$1, TrackerDoctorReport, TrackerUser, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess, createFeatureResolver } from "@theholocron/cli";
2
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;
@@ -108,6 +108,8 @@ export declare class GitHubIssues implements Issues {
108
108
  export(selector: IssueSelector): Promise<IssueRecord[]>;
109
109
  /** Makes the records as issues; one already made by an earlier run is skipped. */
110
110
  import(records: IssueRecord[], options?: ImportOptions): Promise<ImportResult[]>;
111
+ /** Like `import`, and it also updates what an earlier import or sync made, for `holocron sync issues`. */
112
+ sync(records: IssueRecord[], options?: ImportOptions): Promise<SyncResult[]>;
111
113
  private resolveMilestone;
112
114
  private removeStatusLabels;
113
115
  private mapIssue;
package/dist/index.mjs CHANGED
@@ -188,7 +188,8 @@ async function exportIssues(client, repo, selector) {
188
188
  raw = await listAll(client, repo, { milestone: String(milestone.number) });
189
189
  } else if (selector.all) raw = await listAll(client, repo, {});
190
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"));
191
+ const since = selector.since ? Date.parse(selector.since) : void 0;
192
+ const issues = raw.filter((i) => !i.pull_request && !(selector.openOnly && i.state === "closed") && (since === void 0 || Date.parse(i.updated_at) >= since));
192
193
  const records = [];
193
194
  for (const issue of issues) {
194
195
  const comments = await client.issues.listComments(repo, issue.number);
@@ -199,6 +200,7 @@ async function exportIssues(client, repo, selector) {
199
200
  }).catch(() => void 0))?.repository.issue;
200
201
  const { labels, priority } = splitLabels(issue.labels.map((l) => l.name));
201
202
  const body = issue.body?.replace(MARKER, "").trimEnd();
203
+ const migratedFrom = issue.body ? MARKER.exec(issue.body)?.[1] : void 0;
202
204
  records.push({
203
205
  key: `#${issue.number}`,
204
206
  url: issue.html_url,
@@ -222,7 +224,8 @@ async function exportIssues(client, repo, selector) {
222
224
  createdAt: c.created_at
223
225
  })),
224
226
  createdAt: issue.created_at ?? issue.updated_at,
225
- updatedAt: issue.updated_at
227
+ updatedAt: issue.updated_at,
228
+ ...migratedFrom ? { migratedFrom } : {}
226
229
  });
227
230
  }
228
231
  return records;
@@ -246,10 +249,7 @@ async function importIssues(client, repo, records, options = {}) {
246
249
  });
247
250
  continue;
248
251
  }
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)");
252
+ const dropped = droppedFor(record);
253
253
  if (options.dryRun) {
254
254
  results.push({
255
255
  source: record.key,
@@ -258,32 +258,51 @@ async function importIssues(client, repo, records, options = {}) {
258
258
  });
259
259
  continue;
260
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
- });
261
+ const number = await createFromRecord(client, repo, record, options);
278
262
  results.push({
279
263
  source: record.key,
280
- key: `#${created.number}`,
264
+ key: `#${number}`,
281
265
  created: true,
282
266
  dropped
283
267
  });
284
268
  }
285
269
  return results;
286
270
  }
271
+ /** What GitHub cannot hold of this record, for the report. */
272
+ function droppedFor(record) {
273
+ const dropped = ["createdAt (GitHub sets it; the original date is in the text)"];
274
+ if (record.assignee) dropped.push("assignee (not mapped)");
275
+ if (record.parent) dropped.push("parent (not set on GitHub)");
276
+ if (record.blockedBy.length > 0) dropped.push("blockedBy (not set on GitHub)");
277
+ return dropped;
278
+ }
279
+ /** The labels a record becomes on GitHub: its own, plus `priority: <level>` (GitHub's priority is a label). */
280
+ function labelsFor(record) {
281
+ return [...record.labels, ...record.priority && record.priority !== "none" ? [`priority: ${record.priority}`] : []];
282
+ }
283
+ /** The text a comment gets when it is copied: GitHub cannot post as another author or backdate. */
284
+ function renderComment(comment) {
285
+ return `${comment.author ? `**${comment.author}**` : "Someone"} commented on ${comment.createdAt}:\n\n${comment.body}`;
286
+ }
287
+ /** Makes one issue from a record, with its comments, and closes it when the record is closed; returns its number. */
288
+ async function createFromRecord(client, repo, record, options) {
289
+ const milestoneName = options.milestone ?? record.milestone;
290
+ const milestone = milestoneName ? await ensureMilestone(client, repo, milestoneName) : void 0;
291
+ const labels = labelsFor(record);
292
+ const created = await client.issues.createIssue(repo, {
293
+ title: record.title,
294
+ body: withProvenance(record),
295
+ ...labels.length > 0 ? { labels } : {},
296
+ ...milestone !== void 0 ? { milestone } : {}
297
+ });
298
+ for (const comment of record.comments) await client.issues.createComment(repo, created.number, renderComment(comment));
299
+ if (record.closed) await client.issues.updateIssue(repo, created.number, {
300
+ state: "closed",
301
+ state_reason: "completed"
302
+ });
303
+ return created.number;
304
+ }
305
+ /** The text of a migrated issue: the record's body, then where it came from and the marker a second run looks for. */
287
306
  function withProvenance(record) {
288
307
  return `${record.body ? `${record.body}\n\n` : ""}---\n_Migrated from [${record.key}](${record.url}); created ${record.createdAt}._\n<!-- holocron:migrated-from ${record.url} -->`;
289
308
  }
@@ -311,6 +330,90 @@ async function findMilestone(client, repo, ref) {
311
330
  async function ensureMilestone(client, repo, title) {
312
331
  return (await findMilestone(client, repo, title))?.number ?? (await client.issues.createMilestone(repo, title)).number;
313
332
  }
333
+ // src/capabilities/issues-sync.ts
334
+ const COMMENT_HEADER = /^(?:\*\*.*?\*\*|Someone) commented on (\S+?):\n/;
335
+ /** Whether GitHub is asking to be left alone: a 429, or a 403 that says it is a rate limit. */
336
+ function isRateLimited(err) {
337
+ return err instanceof ProviderApiError && (err.status === 429 || err.status === 403 && /rate limit/i.test(`${err.message} ${String(err.details)}`));
338
+ }
339
+ const normalize = (text) => text.replace(/\r\n/g, "\n").trim();
340
+ const sorted = (names) => names.map((n) => n.toLowerCase()).sort().join("\n");
341
+ /** Makes the records missing from the repo and updates the ones an earlier run made. See the file comment. */
342
+ async function syncIssues(client, repo, records, options = {}) {
343
+ const existing = /* @__PURE__ */ new Map();
344
+ for (const issue of await listAll(client, repo, {})) {
345
+ const url = issue.body ? MARKER.exec(issue.body)?.[1] : void 0;
346
+ if (url) existing.set(url, issue);
347
+ }
348
+ const results = [];
349
+ for (const record of records) try {
350
+ const found = existing.get(record.url);
351
+ results.push(found ? await update(client, repo, found, record, options) : await create(client, repo, record, options));
352
+ } catch (err) {
353
+ if (isRateLimited(err)) return results;
354
+ throw err;
355
+ }
356
+ return results;
357
+ }
358
+ async function create(client, repo, record, options) {
359
+ const dropped = droppedFor(record);
360
+ if (options.dryRun) return {
361
+ source: record.key,
362
+ created: true,
363
+ updated: false,
364
+ changes: [],
365
+ dropped
366
+ };
367
+ const number = await createFromRecord(client, repo, record, options);
368
+ return {
369
+ source: record.key,
370
+ key: `#${number}`,
371
+ created: true,
372
+ updated: false,
373
+ changes: [],
374
+ dropped
375
+ };
376
+ }
377
+ async function update(client, repo, issue, record, options) {
378
+ const patch = {};
379
+ const changes = [];
380
+ if (issue.title !== record.title) {
381
+ patch.title = record.title;
382
+ changes.push("title");
383
+ }
384
+ const body = withProvenance(record);
385
+ if (normalize(issue.body) !== normalize(body)) {
386
+ patch.body = body;
387
+ changes.push("description");
388
+ }
389
+ if (issue.state === "closed" !== record.closed) {
390
+ patch.state = record.closed ? "closed" : "open";
391
+ if (record.closed) patch.state_reason = "completed";
392
+ changes.push("state");
393
+ }
394
+ const current = issue.labels.map((l) => l.name);
395
+ const finalLabels = [...labelsFor(record), ...current.filter((n) => n.toLowerCase().startsWith("status:"))];
396
+ if (sorted(finalLabels) !== sorted(current)) {
397
+ patch.labels = finalLabels;
398
+ changes.push("labels");
399
+ }
400
+ const comments = await client.issues.listComments(repo, issue.number);
401
+ const seen = new Set(comments.map((c) => COMMENT_HEADER.exec(c.body)?.[1]));
402
+ const missing = record.comments.filter((c) => !seen.has(c.createdAt));
403
+ if (missing.length > 0) changes.push("comments");
404
+ if (!options.dryRun) {
405
+ if (Object.keys(patch).length > 0) await client.issues.updateIssue(repo, issue.number, patch);
406
+ for (const comment of missing) await client.issues.createComment(repo, issue.number, renderComment(comment));
407
+ }
408
+ return {
409
+ source: record.key,
410
+ key: `#${issue.number}`,
411
+ created: false,
412
+ updated: changes.length > 0,
413
+ changes,
414
+ dropped: droppedFor(record)
415
+ };
416
+ }
314
417
  // src/capabilities/issues.ts
315
418
  /** The GitHub implementation of the `issues` capability, backed by GitHub Issues and status labels. */
316
419
  var GitHubIssues = class {
@@ -495,6 +598,10 @@ var GitHubIssues = class {
495
598
  import(records, options) {
496
599
  return importIssues(this.client, this.repo, records, options);
497
600
  }
601
+ /** Like `import`, and it also updates what an earlier import or sync made, for `holocron sync issues`. */
602
+ sync(records, options) {
603
+ return syncIssues(this.client, this.repo, records, options);
604
+ }
498
605
  async resolveMilestone(ref) {
499
606
  if (/^\d+$/.test(ref)) return parseInt(ref, 10);
500
607
  const match = (await this.client.issues.listMilestones(this.repo, { state: "all" })).find((m) => m.title.toLowerCase() === ref.toLowerCase());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/holocron-plugin-github",
3
- "version": "5.0.0-alpha.374",
3
+ "version": "5.0.0-alpha.376",
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.1.0",
47
- "@theholocron/cli": "5.0.0-alpha.374",
47
+ "@theholocron/cli": "5.0.0-alpha.376",
48
48
  "@theholocron/eslint-config": "^8.9.0-alpha.12",
49
49
  "@theholocron/fs-utils": "^1.17.0-alpha.2",
50
- "@theholocron/http-client": "^1.58.0-alpha.20",
50
+ "@theholocron/github-client": "^1.58.0-alpha.22",
51
51
  "@theholocron/misc-utils": "^1.17.0-alpha.2",
52
- "@theholocron/tsconfig": "^8.9.0-alpha.12",
53
- "@theholocron/github-client": "^1.58.0-alpha.20",
52
+ "@theholocron/http-client": "^1.58.0-alpha.22",
54
53
  "@theholocron/tsdown-config": "^8.9.0-alpha.12",
55
- "@theholocron/vitest-config": "^8.9.0-alpha.12"
54
+ "@theholocron/vitest-config": "^8.9.0-alpha.12",
55
+ "@theholocron/tsconfig": "^8.9.0-alpha.12"
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.374"
61
+ "@theholocron/cli": "5.0.0-alpha.376"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=22"