@theholocron/holocron-plugin-github 5.0.0-alpha.374 → 5.0.0-alpha.375
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 +3 -1
- package/dist/index.mjs +131 -24
- package/package.json +7 -7
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
|
|
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 =
|
|
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
|
|
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: `#${
|
|
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.
|
|
3
|
+
"version": "5.0.0-alpha.375",
|
|
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.
|
|
47
|
+
"@theholocron/cli": "5.0.0-alpha.375",
|
|
48
48
|
"@theholocron/eslint-config": "^8.9.0-alpha.12",
|
|
49
49
|
"@theholocron/fs-utils": "^1.17.0-alpha.2",
|
|
50
|
-
"@theholocron/
|
|
50
|
+
"@theholocron/github-client": "^1.58.0-alpha.21",
|
|
51
51
|
"@theholocron/misc-utils": "^1.17.0-alpha.2",
|
|
52
|
+
"@theholocron/http-client": "^1.58.0-alpha.21",
|
|
52
53
|
"@theholocron/tsconfig": "^8.9.0-alpha.12",
|
|
53
|
-
"@theholocron/
|
|
54
|
-
"@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/tsdown-config": "^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.
|
|
61
|
+
"@theholocron/cli": "5.0.0-alpha.375"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
64
|
"node": ">=22"
|