@ghfs/cli 0.0.0 → 0.0.1

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/cli.mjs DELETED
@@ -1,1276 +0,0 @@
1
- #!/usr/bin/env node
2
- import { basename, dirname, join, resolve } from "node:path";
3
- import { cac } from "cac";
4
- import { existsSync } from "node:fs";
5
- import { createJiti } from "jiti";
6
- import { cancel, confirm, isCancel, multiselect, password, select } from "@clack/prompts";
7
- import { retry } from "@octokit/plugin-retry";
8
- import { throttling } from "@octokit/plugin-throttling";
9
- import { Octokit } from "octokit";
10
- import * as v from "valibot";
11
- import { parse, stringify } from "yaml";
12
- import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
13
- import { execFile } from "node:child_process";
14
- import { promisify } from "node:util";
15
-
16
- //#region src/constants.ts
17
- const CONFIG_FILE_CANDIDATES = [
18
- "ghfs.config.ts",
19
- "ghfs.config.mts",
20
- "ghfs.config.mjs",
21
- "ghfs.config.js",
22
- "ghfs.config.cjs"
23
- ];
24
- const DEFAULT_STORAGE_DIR = ".ghfs";
25
- const DEFAULT_EXECUTE_FILE = ".ghfs/execute.yml";
26
- const DEFAULT_TOKEN_ENV = ["GH_TOKEN", "GITHUB_TOKEN"];
27
- const ISSUE_DIR_NAME = "issues";
28
- const CLOSED_DIR_NAME = "closed";
29
- const SYNC_STATE_FILE_NAME = ".sync.json";
30
- const EXECUTE_SCHEMA_RELATIVE_PATH = "schema/execute.schema.json";
31
-
32
- //#endregion
33
- //#region src/config.ts
34
- async function loadUserConfig(cwd) {
35
- const configPath = findConfigFile(cwd);
36
- if (!configPath) return { config: {} };
37
- return {
38
- path: configPath,
39
- config: extractUserConfig(await createJiti(resolve(cwd, "ghfs.config.ts"), { interopDefault: true }).import(configPath))
40
- };
41
- }
42
- function extractUserConfig(loaded) {
43
- if (!loaded || typeof loaded !== "object") return {};
44
- if ("default" in loaded) {
45
- const config = loaded.default;
46
- if (config && typeof config === "object") return config;
47
- return {};
48
- }
49
- return loaded;
50
- }
51
- async function resolveConfig(options = {}) {
52
- const cwd = options.cwd ?? process.cwd();
53
- const overrides = options.overrides ?? {};
54
- const { config: userConfig } = await loadUserConfig(cwd);
55
- const merged = mergeUserConfig(userConfig, overrides);
56
- const storageDir = merged.storageDir ?? DEFAULT_STORAGE_DIR;
57
- const executeFile = merged.executeFile ?? (storageDir === DEFAULT_STORAGE_DIR ? DEFAULT_EXECUTE_FILE : join(storageDir, "execute.yml"));
58
- return {
59
- cwd,
60
- repo: merged.repo,
61
- storageDir,
62
- storageDirAbsolute: resolve(cwd, storageDir),
63
- executeFile,
64
- executeFileAbsolute: resolve(cwd, executeFile),
65
- auth: {
66
- preferGhCli: merged.auth?.preferGhCli ?? true,
67
- tokenEnv: merged.auth?.tokenEnv ?? [...DEFAULT_TOKEN_ENV]
68
- },
69
- detectRepo: {
70
- fromGit: merged.detectRepo?.fromGit ?? true,
71
- fromPackageJson: merged.detectRepo?.fromPackageJson ?? true
72
- },
73
- sync: {
74
- includeClosed: merged.sync?.includeClosed ?? true,
75
- writePrPatch: merged.sync?.writePrPatch ?? true,
76
- deleteClosedPrPatch: merged.sync?.deleteClosedPrPatch ?? true
77
- },
78
- cli: { interactiveExecuteInTTY: merged.cli?.interactiveExecuteInTTY ?? true }
79
- };
80
- }
81
- function findConfigFile(cwd) {
82
- for (const candidate of CONFIG_FILE_CANDIDATES) {
83
- const fullPath = resolve(cwd, candidate);
84
- if (existsSync(fullPath)) return fullPath;
85
- }
86
- }
87
- function mergeUserConfig(base, overrides) {
88
- return {
89
- ...base,
90
- ...overrides,
91
- auth: {
92
- ...base.auth,
93
- ...overrides.auth
94
- },
95
- detectRepo: {
96
- ...base.detectRepo,
97
- ...overrides.detectRepo
98
- },
99
- sync: {
100
- ...base.sync,
101
- ...overrides.sync
102
- },
103
- cli: {
104
- ...base.cli,
105
- ...overrides.cli
106
- }
107
- };
108
- }
109
-
110
- //#endregion
111
- //#region src/github/client.ts
112
- const BaseOctokit = Octokit.plugin(retry, throttling);
113
- function createGitHubClient(token) {
114
- return new BaseOctokit({
115
- auth: token,
116
- throttle: {
117
- onRateLimit: (retryAfter, options) => {
118
- if ((options.request.retryCount ?? 0) < 2) return true;
119
- return false;
120
- },
121
- onSecondaryRateLimit: () => {
122
- return false;
123
- }
124
- },
125
- retry: {
126
- doNotRetry: [401, 403],
127
- retries: 2
128
- }
129
- });
130
- }
131
-
132
- //#endregion
133
- //#region src/utils/fs.ts
134
- async function ensureDir(path) {
135
- await mkdir(path, { recursive: true });
136
- }
137
- async function exists(path) {
138
- try {
139
- await stat(path);
140
- return true;
141
- } catch {
142
- return false;
143
- }
144
- }
145
- async function writeTextFile(path, content) {
146
- await ensureDir(dirname(path));
147
- await writeFile(path, content, "utf8");
148
- }
149
- async function readTextFile(path) {
150
- return readFile(path, "utf8");
151
- }
152
- async function moveFile(from, to) {
153
- await ensureDir(dirname(to));
154
- await rename(from, to);
155
- }
156
- async function removeFile(path) {
157
- await rm(path, { force: true });
158
- }
159
-
160
- //#endregion
161
- //#region src/execute/validate.ts
162
- const executeOpSchema = v.looseObject({
163
- number: v.number(),
164
- action: v.picklist([
165
- "close",
166
- "reopen",
167
- "set-title",
168
- "set-body",
169
- "add-comment",
170
- "add-labels",
171
- "remove-labels",
172
- "set-labels",
173
- "add-assignees",
174
- "remove-assignees",
175
- "set-assignees",
176
- "set-milestone",
177
- "clear-milestone",
178
- "lock",
179
- "unlock",
180
- "request-reviewers",
181
- "remove-reviewers",
182
- "mark-ready-for-review",
183
- "convert-to-draft"
184
- ]),
185
- ifUnchangedSince: v.optional(v.string()),
186
- title: v.optional(v.string()),
187
- body: v.optional(v.string()),
188
- labels: v.optional(v.array(v.string())),
189
- assignees: v.optional(v.array(v.string())),
190
- milestone: v.optional(v.union([v.string(), v.number()])),
191
- reviewers: v.optional(v.array(v.string())),
192
- reason: v.optional(v.picklist([
193
- "resolved",
194
- "off-topic",
195
- "too heated",
196
- "too-heated",
197
- "spam"
198
- ]))
199
- });
200
- const executeFileSchema = v.array(executeOpSchema);
201
- async function readAndValidateExecuteFile(path) {
202
- const raw = await readTextFile(path);
203
- let parsed;
204
- try {
205
- parsed = parse(raw);
206
- } catch (error) {
207
- throw new Error(`Failed to parse execute YAML: ${error.message}`);
208
- }
209
- const parsedResult = v.safeParse(executeFileSchema, parsed);
210
- if (!parsedResult.success) {
211
- const message = parsedResult.issues.map((issue) => {
212
- const path = issue.path?.map((segment) => String(segment.key)).join(".");
213
- return `${path ? `${path}: ` : ""}${issue.message}`;
214
- }).join("; ");
215
- throw new Error(`Invalid execute file: ${message}`);
216
- }
217
- const pending = parsedResult.output;
218
- const customErrors = validateExecuteRules(pending);
219
- if (customErrors.length) throw new Error(`Invalid execute file: ${customErrors.join("; ")}`);
220
- return pending;
221
- }
222
- function validateExecuteRules(pending) {
223
- const errors = [];
224
- for (const [index, op] of pending.entries()) {
225
- const key = `[${index}]`;
226
- errors.push(...validateOperationRules(key, op));
227
- }
228
- return errors;
229
- }
230
- function validateOperationRules(key, op) {
231
- const errors = [];
232
- if (!Number.isInteger(op.number) || op.number <= 0) errors.push(`${key}: number must be a positive integer`);
233
- switch (op.action) {
234
- case "set-title":
235
- if (!isNonEmptyString(op.title)) errors.push(`${key}: set-title requires title`);
236
- break;
237
- case "set-body":
238
- case "add-comment":
239
- if (!isNonEmptyString(op.body)) errors.push(`${key}: ${op.action} requires body`);
240
- break;
241
- case "add-labels":
242
- case "remove-labels":
243
- case "set-labels":
244
- if (!isStringArray(op.labels)) errors.push(`${key}: ${op.action} requires labels[]`);
245
- break;
246
- case "add-assignees":
247
- case "remove-assignees":
248
- case "set-assignees":
249
- if (!isStringArray(op.assignees)) errors.push(`${key}: ${op.action} requires assignees[]`);
250
- break;
251
- case "set-milestone":
252
- if (!(typeof op.milestone === "string" || typeof op.milestone === "number")) errors.push(`${key}: set-milestone requires milestone`);
253
- break;
254
- case "request-reviewers":
255
- case "remove-reviewers":
256
- if (!isStringArray(op.reviewers)) errors.push(`${key}: ${op.action} requires reviewers[]`);
257
- break;
258
- default: break;
259
- }
260
- if (op.ifUnchangedSince && Number.isNaN(Date.parse(op.ifUnchangedSince))) errors.push(`${key}: ifUnchangedSince must be a valid datetime`);
261
- return errors;
262
- }
263
- function isNonEmptyString(value) {
264
- return typeof value === "string" && value.trim().length > 0;
265
- }
266
- function isStringArray(value) {
267
- return Array.isArray(value) && value.every((entry) => typeof entry === "string") && value.length > 0;
268
- }
269
-
270
- //#endregion
271
- //#region src/execute/index.ts
272
- async function executePendingChanges(options) {
273
- const allOps = await readAndValidateExecuteFile(options.executeFilePath);
274
- const interactive = process.stdin.isTTY && !options.nonInteractive && options.config.cli.interactiveExecuteInTTY;
275
- const selected = interactive ? await selectOperations(allOps) : allOps.map((op, index) => ({
276
- op,
277
- index
278
- }));
279
- const runId = createRunId();
280
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
281
- if (selected.length === 0) return {
282
- runId,
283
- createdAt,
284
- mode: options.apply ? "apply" : "dry-run",
285
- repo: options.repo,
286
- planned: 0,
287
- applied: 0,
288
- failed: 0,
289
- details: []
290
- };
291
- printPlan(selected.map((item) => item.op));
292
- if (!options.apply) return {
293
- runId,
294
- createdAt,
295
- mode: "dry-run",
296
- repo: options.repo,
297
- planned: selected.length,
298
- applied: 0,
299
- failed: 0,
300
- details: selected.map(({ op, index }) => ({
301
- op: index + 1,
302
- action: op.action,
303
- number: op.number,
304
- status: "planned",
305
- message: describeAction(op)
306
- }))
307
- };
308
- if (interactive) {
309
- if (!await confirmApply(selected.length)) throw new Error("Execution cancelled");
310
- }
311
- const { owner, repo } = splitRepo$1(options.repo);
312
- const octokit = createGitHubClient(options.token);
313
- const details = [];
314
- let applied = 0;
315
- let failed = 0;
316
- for (const { op, index } of selected) try {
317
- const target = await applyOperation(octokit, owner, repo, op);
318
- details.push({
319
- op: index + 1,
320
- action: op.action,
321
- number: op.number,
322
- target,
323
- status: "applied",
324
- message: describeAction(op)
325
- });
326
- applied += 1;
327
- } catch (error) {
328
- failed += 1;
329
- details.push({
330
- op: index + 1,
331
- action: op.action,
332
- number: op.number,
333
- status: "failed",
334
- message: error.message
335
- });
336
- if (!options.continueOnError) break;
337
- }
338
- return {
339
- runId,
340
- createdAt,
341
- mode: "apply",
342
- repo: options.repo,
343
- planned: selected.length,
344
- applied,
345
- failed,
346
- details
347
- };
348
- }
349
- async function applyOperation(octokit, owner, repo, op) {
350
- const issue = (await octokit.rest.issues.get({
351
- owner,
352
- repo,
353
- issue_number: op.number
354
- })).data;
355
- const isPull = Boolean(issue.pull_request);
356
- const target = isPull ? "pull" : "issue";
357
- if (op.ifUnchangedSince) {
358
- const remoteUpdatedAt = issue.updated_at;
359
- if (remoteUpdatedAt && new Date(remoteUpdatedAt).getTime() > new Date(op.ifUnchangedSince).getTime()) throw new Error(`Operation conflict: remote updated_at=${remoteUpdatedAt}`);
360
- }
361
- switch (op.action) {
362
- case "close":
363
- await octokit.rest.issues.update({
364
- owner,
365
- repo,
366
- issue_number: op.number,
367
- state: "closed"
368
- });
369
- break;
370
- case "reopen":
371
- await octokit.rest.issues.update({
372
- owner,
373
- repo,
374
- issue_number: op.number,
375
- state: "open"
376
- });
377
- break;
378
- case "set-title":
379
- await octokit.rest.issues.update({
380
- owner,
381
- repo,
382
- issue_number: op.number,
383
- title: op.title
384
- });
385
- break;
386
- case "set-body":
387
- await octokit.rest.issues.update({
388
- owner,
389
- repo,
390
- issue_number: op.number,
391
- body: op.body
392
- });
393
- break;
394
- case "add-comment":
395
- await octokit.rest.issues.createComment({
396
- owner,
397
- repo,
398
- issue_number: op.number,
399
- body: op.body
400
- });
401
- break;
402
- case "add-labels":
403
- await octokit.rest.issues.addLabels({
404
- owner,
405
- repo,
406
- issue_number: op.number,
407
- labels: op.labels
408
- });
409
- break;
410
- case "remove-labels":
411
- await removeLabels(octokit, owner, repo, op.number, op.labels);
412
- break;
413
- case "set-labels":
414
- await octokit.rest.issues.setLabels({
415
- owner,
416
- repo,
417
- issue_number: op.number,
418
- labels: op.labels
419
- });
420
- break;
421
- case "add-assignees":
422
- await octokit.rest.issues.addAssignees({
423
- owner,
424
- repo,
425
- issue_number: op.number,
426
- assignees: op.assignees
427
- });
428
- break;
429
- case "remove-assignees":
430
- await octokit.rest.issues.removeAssignees({
431
- owner,
432
- repo,
433
- issue_number: op.number,
434
- assignees: op.assignees
435
- });
436
- break;
437
- case "set-assignees":
438
- await octokit.rest.issues.update({
439
- owner,
440
- repo,
441
- issue_number: op.number,
442
- assignees: op.assignees
443
- });
444
- break;
445
- case "set-milestone": {
446
- const milestone = await resolveMilestone(octokit, owner, repo, op.milestone);
447
- await octokit.rest.issues.update({
448
- owner,
449
- repo,
450
- issue_number: op.number,
451
- milestone
452
- });
453
- break;
454
- }
455
- case "clear-milestone":
456
- await octokit.rest.issues.update({
457
- owner,
458
- repo,
459
- issue_number: op.number,
460
- milestone: null
461
- });
462
- break;
463
- case "lock":
464
- await octokit.rest.issues.lock({
465
- owner,
466
- repo,
467
- issue_number: op.number,
468
- lock_reason: normalizeLockReason(op.reason)
469
- });
470
- break;
471
- case "unlock":
472
- await octokit.rest.issues.unlock({
473
- owner,
474
- repo,
475
- issue_number: op.number
476
- });
477
- break;
478
- case "request-reviewers":
479
- ensurePullAction(op.action, op.number, isPull);
480
- await octokit.rest.pulls.requestReviewers({
481
- owner,
482
- repo,
483
- pull_number: op.number,
484
- reviewers: op.reviewers
485
- });
486
- break;
487
- case "remove-reviewers":
488
- ensurePullAction(op.action, op.number, isPull);
489
- await octokit.rest.pulls.removeRequestedReviewers({
490
- owner,
491
- repo,
492
- pull_number: op.number,
493
- reviewers: op.reviewers
494
- });
495
- break;
496
- case "mark-ready-for-review":
497
- ensurePullAction(op.action, op.number, isPull);
498
- await octokit.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/ready_for_review", {
499
- owner,
500
- repo,
501
- pull_number: op.number
502
- });
503
- break;
504
- case "convert-to-draft":
505
- ensurePullAction(op.action, op.number, isPull);
506
- await octokit.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/convert-to-draft", {
507
- owner,
508
- repo,
509
- pull_number: op.number
510
- });
511
- break;
512
- default: throw new Error(`Unsupported action: ${String(op.action)}`);
513
- }
514
- return target;
515
- }
516
- async function removeLabels(octokit, owner, repo, number, labels) {
517
- for (const label of labels) try {
518
- await octokit.rest.issues.removeLabel({
519
- owner,
520
- repo,
521
- issue_number: number,
522
- name: label
523
- });
524
- } catch (error) {
525
- if (error.status !== 404) throw error;
526
- }
527
- }
528
- async function resolveMilestone(octokit, owner, repo, value) {
529
- if (typeof value === "number") return value;
530
- if (/^\d+$/.test(value)) return Number(value);
531
- const matched = (await octokit.paginate(octokit.rest.issues.listMilestones, {
532
- owner,
533
- repo,
534
- state: "all",
535
- per_page: 100
536
- })).find((item) => item.title === value);
537
- if (!matched) throw new Error(`Milestone not found: ${value}`);
538
- return matched.number;
539
- }
540
- function splitRepo$1(repo) {
541
- const [owner, name] = repo.split("/");
542
- if (!owner || !name) throw new Error(`Invalid repo slug: ${repo}`);
543
- return {
544
- owner,
545
- repo: name
546
- };
547
- }
548
- function describeAction(op) {
549
- return `${op.action} #${op.number}`;
550
- }
551
- function createRunId() {
552
- return `run_${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(36).slice(2, 7)}`;
553
- }
554
- async function selectOperations(ops) {
555
- const result = await multiselect({
556
- message: "Select operations to include",
557
- options: ops.map((op, index) => ({
558
- label: `${index + 1}. ${describeAction(op)}`,
559
- value: index
560
- })),
561
- required: false
562
- });
563
- if (isCancel(result)) {
564
- cancel("Operation selection cancelled");
565
- throw new Error("Execution cancelled");
566
- }
567
- const selectedIndexes = new Set(result);
568
- return ops.map((op, index) => ({
569
- op,
570
- index
571
- })).filter((item) => selectedIndexes.has(item.index));
572
- }
573
- async function confirmApply(count) {
574
- const result = await confirm({
575
- message: `Apply ${count} operation(s) to GitHub?`,
576
- initialValue: false
577
- });
578
- if (isCancel(result)) {
579
- cancel("Execution cancelled");
580
- return false;
581
- }
582
- return result;
583
- }
584
- function printPlan(ops) {
585
- console.log(`Planned operations (${ops.length}):`);
586
- for (const [index, op] of ops.entries()) console.log(`- ${index + 1}. ${describeAction(op)}`);
587
- }
588
- function ensurePullAction(action, number, isPull) {
589
- if (!isPull) throw new Error(`Action ${action} requires #${number} to be a pull request`);
590
- }
591
- function normalizeLockReason(reason) {
592
- if (!reason) return void 0;
593
- if (reason === "too-heated") return "too heated";
594
- return reason;
595
- }
596
-
597
- //#endregion
598
- //#region src/execute/schema.ts
599
- const executeSchema = {
600
- $id: "https://ghfs.dev/schema/execute.json",
601
- type: "array",
602
- items: {
603
- type: "object",
604
- additionalProperties: true,
605
- required: ["number", "action"],
606
- properties: {
607
- number: { type: "number" },
608
- action: {
609
- type: "string",
610
- enum: [
611
- "close",
612
- "reopen",
613
- "set-title",
614
- "set-body",
615
- "add-comment",
616
- "add-labels",
617
- "remove-labels",
618
- "set-labels",
619
- "add-assignees",
620
- "remove-assignees",
621
- "set-assignees",
622
- "set-milestone",
623
- "clear-milestone",
624
- "lock",
625
- "unlock",
626
- "request-reviewers",
627
- "remove-reviewers",
628
- "mark-ready-for-review",
629
- "convert-to-draft"
630
- ]
631
- },
632
- ifUnchangedSince: {
633
- type: "string",
634
- format: "date-time"
635
- },
636
- title: { type: "string" },
637
- body: { type: "string" },
638
- labels: {
639
- type: "array",
640
- items: { type: "string" }
641
- },
642
- assignees: {
643
- type: "array",
644
- items: { type: "string" }
645
- },
646
- milestone: { anyOf: [{ type: "string" }, { type: "number" }] },
647
- reviewers: {
648
- type: "array",
649
- items: { type: "string" }
650
- },
651
- reason: {
652
- type: "string",
653
- enum: [
654
- "resolved",
655
- "off-topic",
656
- "too heated",
657
- "too-heated",
658
- "spam"
659
- ]
660
- }
661
- }
662
- }
663
- };
664
- async function writeExecuteSchema(storageDirAbsolute) {
665
- const schemaPath = getExecuteSchemaPath(storageDirAbsolute);
666
- await writeTextFile(schemaPath, `${JSON.stringify(executeSchema, null, 2)}\n`);
667
- return schemaPath;
668
- }
669
- function getExecuteSchemaPath(storageDirAbsolute) {
670
- return join(storageDirAbsolute, EXECUTE_SCHEMA_RELATIVE_PATH);
671
- }
672
-
673
- //#endregion
674
- //#region src/github/auth.ts
675
- const execFileAsync$1 = promisify(execFile);
676
- async function resolveAuthToken(options) {
677
- if (options.preferGhCli) {
678
- const token = await readTokenFromGhCli();
679
- if (token) return token;
680
- }
681
- const envToken = readTokenFromEnv(options.tokenEnv);
682
- if (envToken) return envToken;
683
- if (!options.interactive || !process.stdin.isTTY) throw new Error("Missing GitHub token. Set GH_TOKEN/GITHUB_TOKEN or run gh auth login.");
684
- return await promptForToken();
685
- }
686
- async function readTokenFromGhCli() {
687
- try {
688
- return (await execFileAsync$1("gh", ["auth", "token"])).stdout.trim() || void 0;
689
- } catch {
690
- return;
691
- }
692
- }
693
- function readTokenFromEnv(envNames) {
694
- for (const name of envNames) {
695
- const value = process.env[name]?.trim();
696
- if (value) return value;
697
- }
698
- }
699
- async function promptForToken() {
700
- const result = await password({
701
- message: "Enter a GitHub token (PAT) for ghfs:",
702
- validate: (value) => value?.trim().length ? void 0 : "Token is required"
703
- });
704
- if (isCancel(result)) {
705
- cancel("Token prompt cancelled");
706
- throw new Error("Token prompt cancelled");
707
- }
708
- return result.trim();
709
- }
710
-
711
- //#endregion
712
- //#region src/github/repo.ts
713
- const execFileAsync = promisify(execFile);
714
- async function resolveRepo(options) {
715
- if (options.cliRepo) {
716
- const repo = normalizeRepo(options.cliRepo);
717
- if (!repo) throw new Error(`Invalid --repo value: ${options.cliRepo}`);
718
- return {
719
- repo,
720
- source: "cli",
721
- candidates: []
722
- };
723
- }
724
- if (options.configRepo) {
725
- const repo = normalizeRepo(options.configRepo);
726
- if (!repo) throw new Error(`Invalid repo in ghfs.config.ts: ${options.configRepo}`);
727
- return {
728
- repo,
729
- source: "config",
730
- candidates: []
731
- };
732
- }
733
- const candidates = [];
734
- const gitCandidate = options.detectFromGit ? await detectRepoFromGit(options.cwd) : void 0;
735
- const pkgCandidate = options.detectFromPackageJson ? await detectRepoFromPackageJson(options.cwd) : void 0;
736
- if (gitCandidate) candidates.push(gitCandidate);
737
- if (pkgCandidate) candidates.push(pkgCandidate);
738
- if (gitCandidate && pkgCandidate && gitCandidate.repo !== pkgCandidate.repo) {
739
- if (options.interactive && process.stdin.isTTY) {
740
- const repo = await promptRepoChoice(gitCandidate, pkgCandidate);
741
- return {
742
- repo,
743
- source: repo === gitCandidate.repo ? "git" : "package-json",
744
- candidates
745
- };
746
- }
747
- throw new Error(`Repo mismatch detected. git=${gitCandidate.repo} package.json=${pkgCandidate.repo}. Use --repo to disambiguate.`);
748
- }
749
- if (gitCandidate) return {
750
- repo: gitCandidate.repo,
751
- source: "git",
752
- candidates
753
- };
754
- if (pkgCandidate) return {
755
- repo: pkgCandidate.repo,
756
- source: "package-json",
757
- candidates
758
- };
759
- if (options.syncStateRepo) return {
760
- repo: options.syncStateRepo,
761
- source: "sync-state",
762
- candidates
763
- };
764
- throw new Error("Could not resolve repository. Provide --repo or set repo in ghfs.config.ts.");
765
- }
766
- function normalizeRepo(input) {
767
- const trimmed = input.trim();
768
- if (!trimmed) return void 0;
769
- const shortMatch = trimmed.match(/^([\w.-]+)\/([\w.-]+)$/);
770
- if (shortMatch) return `${shortMatch[1]}/${stripGitSuffix(shortMatch[2])}`;
771
- const githubPrefixMatch = trimmed.match(/^github:([\w.-]+)\/([\w.-]+)$/);
772
- if (githubPrefixMatch) return `${githubPrefixMatch[1]}/${stripGitSuffix(githubPrefixMatch[2])}`;
773
- const sshScpMatch = trimmed.match(/^git@github\.com:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
774
- if (sshScpMatch) return `${sshScpMatch[1]}/${stripGitSuffix(sshScpMatch[2])}`;
775
- try {
776
- const url = new URL(trimmed);
777
- if (url.hostname !== "github.com") return void 0;
778
- const segments = url.pathname.replace(/^\//, "").split("/").filter(Boolean);
779
- if (segments.length < 2) return void 0;
780
- return `${segments[0]}/${stripGitSuffix(segments[1])}`;
781
- } catch {
782
- return;
783
- }
784
- }
785
- async function detectRepoFromGit(cwd) {
786
- let stdout;
787
- try {
788
- stdout = (await execFileAsync("git", ["remote"], { cwd })).stdout;
789
- } catch {
790
- return;
791
- }
792
- const remotes = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
793
- if (!remotes.length) return void 0;
794
- const orderedRemotes = prioritizeRemotes(remotes);
795
- for (const remote of orderedRemotes) try {
796
- const repo = normalizeRepo((await execFileAsync("git", [
797
- "remote",
798
- "get-url",
799
- remote
800
- ], { cwd })).stdout.trim());
801
- if (repo) return {
802
- source: "git",
803
- repo,
804
- detail: `remote:${remote}`
805
- };
806
- } catch {
807
- continue;
808
- }
809
- }
810
- async function detectRepoFromPackageJson(cwd) {
811
- const path = `${cwd}/package.json`;
812
- if (!await exists(path)) return void 0;
813
- let parsed;
814
- try {
815
- parsed = JSON.parse(await readTextFile(path));
816
- } catch {
817
- return;
818
- }
819
- if (!parsed || typeof parsed !== "object") return void 0;
820
- const repository = parsed.repository;
821
- if (typeof repository === "string") {
822
- const repo = normalizeRepo(repository);
823
- if (repo) return {
824
- source: "package-json",
825
- repo,
826
- detail: "package.json#repository"
827
- };
828
- }
829
- if (repository && typeof repository === "object") {
830
- const url = repository.url;
831
- if (typeof url === "string") {
832
- const repo = normalizeRepo(url);
833
- if (repo) return {
834
- source: "package-json",
835
- repo,
836
- detail: "package.json#repository.url"
837
- };
838
- }
839
- }
840
- }
841
- function prioritizeRemotes(remotes) {
842
- const unique = [...new Set(remotes)];
843
- const priority = ["origin", "upstream"];
844
- const prioritized = priority.filter((name) => unique.includes(name));
845
- const rest = unique.filter((name) => !priority.includes(name));
846
- return [...prioritized, ...rest];
847
- }
848
- function stripGitSuffix(name) {
849
- return name.replace(/\.git$/, "");
850
- }
851
- async function promptRepoChoice(gitCandidate, pkgCandidate) {
852
- const result = await select({
853
- message: "Detected conflicting GitHub repositories. Which one should ghfs use?",
854
- options: [{
855
- label: `${gitCandidate.repo} (${gitCandidate.detail})`,
856
- value: gitCandidate.repo
857
- }, {
858
- label: `${pkgCandidate.repo} (${pkgCandidate.detail})`,
859
- value: pkgCandidate.repo
860
- }],
861
- initialValue: gitCandidate.repo
862
- });
863
- if (isCancel(result)) {
864
- cancel("Repository selection cancelled");
865
- throw new Error("Repository selection cancelled");
866
- }
867
- return result;
868
- }
869
-
870
- //#endregion
871
- //#region src/sync/markdown.ts
872
- function renderIssueMarkdown(input) {
873
- const frontmatter = {
874
- schema: "ghfs/issue-doc@v1",
875
- repo: input.repo,
876
- number: input.number,
877
- kind: input.kind,
878
- state: input.state,
879
- title: input.title,
880
- author: input.author,
881
- labels: input.labels,
882
- assignees: input.assignees,
883
- milestone: input.milestone,
884
- created_at: input.createdAt,
885
- updated_at: input.updatedAt,
886
- closed_at: input.closedAt,
887
- last_synced_at: input.lastSyncedAt,
888
- is_draft: input.pr?.isDraft,
889
- merged: input.pr?.merged,
890
- merged_at: input.pr?.mergedAt,
891
- base_ref: input.pr?.baseRef,
892
- head_ref: input.pr?.headRef,
893
- reviewers_requested: input.pr?.requestedReviewers
894
- };
895
- const compactFrontmatter = Object.fromEntries(Object.entries(frontmatter).filter(([, value]) => value !== void 0));
896
- const sections = [
897
- `# ${input.title}`,
898
- "",
899
- "## Description",
900
- "",
901
- input.body?.trim() || "_No description._",
902
- "",
903
- "## Comments",
904
- ""
905
- ];
906
- if (input.comments.length === 0) sections.push("_No comments._");
907
- else for (const comment of input.comments) {
908
- sections.push(`### Comment ${comment.id} by @${comment.author} on ${comment.createdAt}`);
909
- sections.push(`<!-- comment-id:${comment.id} updated:${comment.updatedAt} -->`);
910
- sections.push("");
911
- sections.push(comment.body?.trim() || "_No content._");
912
- sections.push("");
913
- }
914
- return [
915
- "---",
916
- stringify(compactFrontmatter).trimEnd(),
917
- "---",
918
- "",
919
- ...sections,
920
- ""
921
- ].join("\n");
922
- }
923
-
924
- //#endregion
925
- //#region src/sync/paths.ts
926
- function getIssuesDir(storageDirAbsolute) {
927
- return join(storageDirAbsolute, ISSUE_DIR_NAME);
928
- }
929
- function getClosedIssuesDir(storageDirAbsolute) {
930
- return join(getIssuesDir(storageDirAbsolute), CLOSED_DIR_NAME);
931
- }
932
- function getIssueMarkdownPath(storageDirAbsolute, number, state) {
933
- if (state === "closed") return join(getClosedIssuesDir(storageDirAbsolute), `${number}.md`);
934
- return join(getIssuesDir(storageDirAbsolute), `${number}.md`);
935
- }
936
- function getClosedIssueMarkdownPath(storageDirAbsolute, number) {
937
- return join(getClosedIssuesDir(storageDirAbsolute), `${number}.md`);
938
- }
939
- function getPrPatchPath(storageDirAbsolute, number) {
940
- return join(getIssuesDir(storageDirAbsolute), `${number}.patch`);
941
- }
942
-
943
- //#endregion
944
- //#region src/sync/state.ts
945
- function getSyncStatePath(storageDirAbsolute) {
946
- return join(storageDirAbsolute, SYNC_STATE_FILE_NAME);
947
- }
948
- async function loadSyncState(storageDirAbsolute) {
949
- const path = getSyncStatePath(storageDirAbsolute);
950
- try {
951
- const raw = await readFile(path, "utf8");
952
- const parsed = JSON.parse(raw);
953
- if (parsed.version !== 1) return createEmptySyncState();
954
- return {
955
- version: 1,
956
- items: parsed.items ?? {},
957
- executions: parsed.executions ?? [],
958
- repo: parsed.repo,
959
- lastSyncedAt: parsed.lastSyncedAt,
960
- lastSince: parsed.lastSince
961
- };
962
- } catch {
963
- return createEmptySyncState();
964
- }
965
- }
966
- async function saveSyncState(storageDirAbsolute, state) {
967
- await ensureDir(storageDirAbsolute);
968
- await writeFile(getSyncStatePath(storageDirAbsolute), `${JSON.stringify(state, null, 2)}\n`, "utf8");
969
- }
970
- function createEmptySyncState() {
971
- return {
972
- version: 1,
973
- items: {},
974
- executions: []
975
- };
976
- }
977
- function appendExecution(state, result, limit = 20) {
978
- const nextExecutions = [result, ...state.executions].slice(0, limit);
979
- return {
980
- ...state,
981
- executions: nextExecutions
982
- };
983
- }
984
-
985
- //#endregion
986
- //#region src/sync/index.ts
987
- async function syncRepository(options) {
988
- const { owner, repo } = splitRepo(options.repo);
989
- const octokit = createGitHubClient(options.token);
990
- await ensureStorageStructure(options.config.storageDirAbsolute);
991
- const syncState = await loadSyncState(options.config.storageDirAbsolute);
992
- const since = resolveSince(options, syncState);
993
- const syncedAt = (/* @__PURE__ */ new Date()).toISOString();
994
- const listState = options.config.sync.includeClosed ? "all" : "open";
995
- const issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
996
- owner,
997
- repo,
998
- state: listState,
999
- sort: "updated",
1000
- direction: "asc",
1001
- per_page: 100,
1002
- since
1003
- });
1004
- let written = 0;
1005
- let moved = 0;
1006
- let patchesWritten = 0;
1007
- let patchesDeleted = 0;
1008
- for (const issue of issues) {
1009
- const number = issue.number;
1010
- const kind = issue.pull_request ? "pull" : "issue";
1011
- const state = issue.state === "closed" ? "closed" : "open";
1012
- const comments = await octokit.paginate(octokit.rest.issues.listComments, {
1013
- owner,
1014
- repo,
1015
- issue_number: number,
1016
- per_page: 100
1017
- });
1018
- const pull = kind === "pull" ? await getPullMetadata(octokit, owner, repo, number) : void 0;
1019
- const markdown = renderIssueMarkdown({
1020
- repo: options.repo,
1021
- number,
1022
- kind,
1023
- state,
1024
- title: issue.title,
1025
- body: issue.body ?? "",
1026
- author: issue.user?.login ?? "unknown",
1027
- labels: normalizeLabels(issue.labels),
1028
- assignees: (issue.assignees ?? []).map((assignee) => assignee.login),
1029
- milestone: issue.milestone?.title ?? null,
1030
- createdAt: issue.created_at,
1031
- updatedAt: issue.updated_at,
1032
- closedAt: issue.closed_at,
1033
- lastSyncedAt: syncedAt,
1034
- comments: comments.map((comment) => ({
1035
- id: comment.id,
1036
- author: comment.user?.login ?? "unknown",
1037
- body: comment.body ?? "",
1038
- createdAt: comment.created_at,
1039
- updatedAt: comment.updated_at
1040
- })),
1041
- pr: pull
1042
- });
1043
- const targetPath = getIssueMarkdownPath(options.config.storageDirAbsolute, number, state);
1044
- const closedPath = getClosedIssueMarkdownPath(options.config.storageDirAbsolute, number);
1045
- const openPath = getIssueMarkdownPath(options.config.storageDirAbsolute, number, "open");
1046
- if (state === "open" && await exists(closedPath)) {
1047
- await moveFile(closedPath, openPath);
1048
- moved += 1;
1049
- }
1050
- if (state === "closed" && await exists(openPath)) {
1051
- await moveFile(openPath, closedPath);
1052
- moved += 1;
1053
- }
1054
- await writeTextFile(targetPath, markdown);
1055
- written += 1;
1056
- const patchPath = getPrPatchPath(options.config.storageDirAbsolute, number);
1057
- if (kind === "pull" && options.config.sync.writePrPatch && state === "open") {
1058
- await writeTextFile(patchPath, await downloadPullPatch(octokit, owner, repo, number));
1059
- patchesWritten += 1;
1060
- }
1061
- if (kind === "pull" && state === "closed" && options.config.sync.deleteClosedPrPatch) {
1062
- if (await exists(patchPath)) {
1063
- await removeFile(patchPath);
1064
- patchesDeleted += 1;
1065
- }
1066
- }
1067
- syncState.items[String(number)] = {
1068
- number,
1069
- kind,
1070
- state,
1071
- updatedAt: issue.updated_at,
1072
- filePath: relativeToStorage(options.config.storageDirAbsolute, targetPath),
1073
- patchPath: kind === "pull" && state === "open" ? relativeToStorage(options.config.storageDirAbsolute, patchPath) : void 0
1074
- };
1075
- }
1076
- syncState.repo = options.repo;
1077
- syncState.lastSyncedAt = syncedAt;
1078
- syncState.lastSince = since;
1079
- await saveSyncState(options.config.storageDirAbsolute, syncState);
1080
- return {
1081
- repo: options.repo,
1082
- since,
1083
- syncedAt,
1084
- scanned: issues.length,
1085
- written,
1086
- moved,
1087
- patchesWritten,
1088
- patchesDeleted
1089
- };
1090
- }
1091
- async function appendExecutionResult(storageDirAbsolute, result) {
1092
- await saveSyncState(storageDirAbsolute, appendExecution(await loadSyncState(storageDirAbsolute), result));
1093
- }
1094
- function resolveSince(options, syncState) {
1095
- if (options.full) return void 0;
1096
- if (options.since) return options.since;
1097
- return syncState.lastSyncedAt;
1098
- }
1099
- async function ensureStorageStructure(storageDirAbsolute) {
1100
- await ensureDir(getIssuesDir(storageDirAbsolute));
1101
- await ensureDir(getClosedIssuesDir(storageDirAbsolute));
1102
- }
1103
- function splitRepo(repo) {
1104
- const [owner, name] = repo.split("/");
1105
- if (!owner || !name) throw new Error(`Invalid repo slug: ${repo}`);
1106
- return {
1107
- owner,
1108
- repo: name
1109
- };
1110
- }
1111
- function normalizeLabels(labels) {
1112
- return labels.map((label) => {
1113
- if (typeof label === "string") return label;
1114
- return label.name ?? void 0;
1115
- }).filter((label) => Boolean(label));
1116
- }
1117
- async function getPullMetadata(octokit, owner, repo, number) {
1118
- const pull = (await octokit.rest.pulls.get({
1119
- owner,
1120
- repo,
1121
- pull_number: number
1122
- })).data;
1123
- return {
1124
- isDraft: pull.draft,
1125
- merged: pull.merged,
1126
- mergedAt: pull.merged_at,
1127
- baseRef: pull.base.ref,
1128
- headRef: pull.head.ref,
1129
- requestedReviewers: pull.requested_reviewers.map((reviewer) => reviewer.login)
1130
- };
1131
- }
1132
- async function downloadPullPatch(octokit, owner, repo, number) {
1133
- const result = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
1134
- owner,
1135
- repo,
1136
- pull_number: number,
1137
- mediaType: { format: "patch" }
1138
- });
1139
- if (typeof result.data === "string") return result.data;
1140
- throw new Error(`Unexpected patch response for pull #${number}`);
1141
- }
1142
- function relativeToStorage(storageDirAbsolute, absolutePath) {
1143
- if (absolutePath.startsWith(storageDirAbsolute)) return absolutePath.slice(storageDirAbsolute.length + 1);
1144
- return basename(absolutePath);
1145
- }
1146
-
1147
- //#endregion
1148
- //#region src/sync/status.ts
1149
- async function getStatusSummary(config) {
1150
- const syncState = await loadSyncState(config.storageDirAbsolute);
1151
- const items = Object.values(syncState.items);
1152
- const openCount = items.filter((item) => item.state === "open").length;
1153
- const closedCount = items.filter((item) => item.state === "closed").length;
1154
- const lastExecution = syncState.executions[0];
1155
- return {
1156
- repo: syncState.repo,
1157
- lastSyncedAt: syncState.lastSyncedAt,
1158
- totalTracked: items.length,
1159
- openCount,
1160
- closedCount,
1161
- executionRuns: syncState.executions.length,
1162
- lastExecution: lastExecution ? {
1163
- runId: lastExecution.runId,
1164
- createdAt: lastExecution.createdAt,
1165
- mode: lastExecution.mode,
1166
- planned: lastExecution.planned,
1167
- applied: lastExecution.applied,
1168
- failed: lastExecution.failed
1169
- } : void 0
1170
- };
1171
- }
1172
- function printStatus(summary) {
1173
- console.log("ghfs status");
1174
- console.log(`- repo: ${summary.repo ?? "(not resolved yet)"}`);
1175
- console.log(`- last sync: ${summary.lastSyncedAt ?? "(never)"}`);
1176
- console.log(`- tracked items: ${summary.totalTracked} (open=${summary.openCount}, closed=${summary.closedCount})`);
1177
- console.log(`- execution runs: ${summary.executionRuns}`);
1178
- if (summary.lastExecution) {
1179
- console.log(`- last execution: ${summary.lastExecution.runId} at ${summary.lastExecution.createdAt}`);
1180
- console.log(` mode=${summary.lastExecution.mode} planned=${summary.lastExecution.planned} applied=${summary.lastExecution.applied} failed=${summary.lastExecution.failed}`);
1181
- }
1182
- }
1183
-
1184
- //#endregion
1185
- //#region src/cli.ts
1186
- const cli = cac("ghfs");
1187
- setupSyncCommand(cli.command("sync", "Sync issues and pull requests to local mirror"));
1188
- setupSyncCommand(cli.command("", "Sync issues and pull requests to local mirror"));
1189
- cli.command("execute", "Execute operations from .ghfs/execute.yml").option("--repo <repo>", "GitHub repository in owner/name format").option("--file <file>", "Path to execute yml file").option("--apply", "Apply mutations to GitHub (default is dry-run)").option("--non-interactive", "Disable interactive prompts").option("--continue-on-error", "Continue applying ops after a failure").action(withErrorHandling(async (options) => {
1190
- const config = await resolveConfig();
1191
- const syncState = await loadSyncState(config.storageDirAbsolute);
1192
- const repo = await resolveRepo({
1193
- cwd: config.cwd,
1194
- cliRepo: options.repo,
1195
- configRepo: config.repo,
1196
- detectFromGit: config.detectRepo.fromGit,
1197
- detectFromPackageJson: config.detectRepo.fromPackageJson,
1198
- syncStateRepo: syncState.repo,
1199
- interactive: process.stdin.isTTY && !options.nonInteractive
1200
- });
1201
- const token = await resolveAuthToken({
1202
- preferGhCli: config.auth.preferGhCli,
1203
- tokenEnv: config.auth.tokenEnv,
1204
- interactive: process.stdin.isTTY && !options.nonInteractive
1205
- });
1206
- const executeFilePath = resolve(config.cwd, options.file ?? config.executeFile);
1207
- const result = await executePendingChanges({
1208
- config,
1209
- repo: repo.repo,
1210
- token,
1211
- executeFilePath,
1212
- apply: Boolean(options.apply),
1213
- nonInteractive: Boolean(options.nonInteractive),
1214
- continueOnError: Boolean(options.continueOnError)
1215
- });
1216
- await appendExecutionResult(config.storageDirAbsolute, result);
1217
- console.log(`Execution ${result.mode} finished. planned=${result.planned} applied=${result.applied} failed=${result.failed}`);
1218
- for (const detail of result.details) console.log(`- [${detail.status}] op ${detail.op}: ${detail.message}`);
1219
- if (result.failed > 0) process.exitCode = 1;
1220
- }));
1221
- cli.command("status", "Show local sync status").action(withErrorHandling(async () => {
1222
- printStatus(await getStatusSummary(await resolveConfig()));
1223
- }));
1224
- cli.command("schema", "Write execute schema to .ghfs/schema/execute.schema.json").action(withErrorHandling(async () => {
1225
- const schemaPath = await writeExecuteSchema((await resolveConfig()).storageDirAbsolute);
1226
- console.log(schemaPath);
1227
- }));
1228
- cli.help();
1229
- cli.version("0.1.0");
1230
- cli.parse();
1231
- function setupSyncCommand(command) {
1232
- command.option("--repo <repo>", "GitHub repository in owner/name format").option("--since <iso>", "Only sync records updated since ISO datetime").option("--full", "Full sync ignoring previous cursor").action(withErrorHandling(async (options) => {
1233
- const config = await resolveConfig();
1234
- const syncState = await loadSyncState(config.storageDirAbsolute);
1235
- const repo = await resolveRepo({
1236
- cwd: config.cwd,
1237
- cliRepo: options.repo,
1238
- configRepo: config.repo,
1239
- detectFromGit: config.detectRepo.fromGit,
1240
- detectFromPackageJson: config.detectRepo.fromPackageJson,
1241
- syncStateRepo: syncState.repo,
1242
- interactive: process.stdin.isTTY
1243
- });
1244
- const token = await resolveAuthToken({
1245
- preferGhCli: config.auth.preferGhCli,
1246
- tokenEnv: config.auth.tokenEnv,
1247
- interactive: process.stdin.isTTY
1248
- });
1249
- const summary = await syncRepository({
1250
- config,
1251
- repo: repo.repo,
1252
- token,
1253
- since: options.since,
1254
- full: Boolean(options.full)
1255
- });
1256
- console.log(`Synced ${summary.repo} at ${summary.syncedAt}`);
1257
- console.log(`- since: ${summary.since ?? "(full)"}`);
1258
- console.log(`- scanned: ${summary.scanned}`);
1259
- console.log(`- markdown written: ${summary.written}`);
1260
- console.log(`- moved: ${summary.moved}`);
1261
- console.log(`- patch written: ${summary.patchesWritten}`);
1262
- console.log(`- patch deleted: ${summary.patchesDeleted}`);
1263
- }));
1264
- }
1265
- function withErrorHandling(fn) {
1266
- return (...args) => {
1267
- fn(...args).catch((error) => {
1268
- const message = error.message || String(error);
1269
- console.error(`ghfs error: ${message}`);
1270
- process.exit(1);
1271
- });
1272
- };
1273
- }
1274
-
1275
- //#endregion
1276
- export { };