@ghfs/cli 0.0.1 → 0.0.3

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 ADDED
@@ -0,0 +1,3237 @@
1
+ #!/usr/bin/env node
2
+ import { a as formatIssueNumber, i as formatDuration, n as normalizeReactions, o as formatTerminalLink$1, r as countNoun, s as formatValue, t as createRepositoryProvider } from "./factory-COZFMWsb.mjs";
3
+ import process from "node:process";
4
+ import { cac } from "cac";
5
+ import { basename, dirname, isAbsolute, join, resolve } from "pathe";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { existsSync } from "node:fs";
9
+ import { createJiti } from "jiti";
10
+ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
11
+ import * as v from "valibot";
12
+ import { parse, stringify } from "yaml";
13
+ import { randomBytes } from "node:crypto";
14
+ import c from "ansis";
15
+ import * as p from "@clack/prompts";
16
+ import { cancel, confirm, isCancel, multiselect, password, select } from "@clack/prompts";
17
+
18
+ //#region src/config/auth.ts
19
+ const execFileAsync$1 = promisify(execFile);
20
+ async function resolveAuthToken(options) {
21
+ const configuredToken = options.token?.trim();
22
+ if (configuredToken) return configuredToken;
23
+ const token = await readTokenFromGhCli();
24
+ if (token) return token;
25
+ const envToken = await readTokenFromEnv();
26
+ if (envToken) return envToken;
27
+ if (!options.interactive || !process.stdin.isTTY) throw new Error("Missing GitHub token. Set GH_TOKEN/GITHUB_TOKEN or run gh auth login.");
28
+ if (!options.promptForToken) throw new Error("Missing GitHub token. Set GH_TOKEN/GITHUB_TOKEN or run gh auth login.");
29
+ const promptedToken = await options.promptForToken();
30
+ if (promptedToken?.trim()) return promptedToken.trim();
31
+ throw new Error("Token prompt cancelled");
32
+ }
33
+ async function readTokenFromGhCli() {
34
+ try {
35
+ return (await execFileAsync$1("gh", ["auth", "token"])).stdout.trim() || void 0;
36
+ } catch {
37
+ return;
38
+ }
39
+ }
40
+ async function readTokenFromEnv() {
41
+ await import("dotenv").then((mod) => mod.config());
42
+ for (const name of ["GH_TOKEN", "GITHUB_TOKEN"]) {
43
+ const value = process.env[name]?.trim();
44
+ if (value) return value;
45
+ }
46
+ }
47
+
48
+ //#endregion
49
+ //#region src/constants.ts
50
+ const CONFIG_FILE_CANDIDATES = [
51
+ "ghfs.config.ts",
52
+ "ghfs.config.mts",
53
+ "ghfs.config.mjs",
54
+ "ghfs.config.js",
55
+ "ghfs.config.cjs"
56
+ ];
57
+ const DEFAULT_STORAGE_DIR = ".ghfs";
58
+ const ISSUE_DIR_NAME = "issues";
59
+ const PULL_DIR_NAME = "pulls";
60
+ const CLOSED_DIR_NAME = "closed";
61
+ const SYNC_STATE_FILE_NAME = ".sync.json";
62
+ const ISSUES_INDEX_FILE_NAME = "issues.md";
63
+ const PULLS_INDEX_FILE_NAME = "pulls.md";
64
+ const REPO_SNAPSHOT_FILE_NAME = "repo.json";
65
+ const EXECUTE_FILE_NAME = "execute.yml";
66
+ const EXECUTE_MD_FILE_NAME = "execute.md";
67
+ const EXECUTE_SCHEMA_RELATIVE_PATH = "schema/execute.schema.json";
68
+
69
+ //#endregion
70
+ //#region src/config/load.ts
71
+ async function loadUserConfig(cwd) {
72
+ const configPath = findConfigFile(cwd);
73
+ if (!configPath) return { config: {} };
74
+ return {
75
+ path: configPath,
76
+ config: extractUserConfig(await createJiti(resolve(cwd, "ghfs.config.ts"), { interopDefault: true }).import(configPath))
77
+ };
78
+ }
79
+ function extractUserConfig(loaded) {
80
+ if (!loaded || typeof loaded !== "object") return {};
81
+ if ("default" in loaded) {
82
+ const config = loaded.default;
83
+ if (config && typeof config === "object") return config;
84
+ return {};
85
+ }
86
+ return loaded;
87
+ }
88
+ async function resolveConfig(options = {}) {
89
+ const cwd = options.cwd ?? process.cwd();
90
+ const overrides = options.overrides ?? {};
91
+ const { config: userConfig } = await loadUserConfig(cwd);
92
+ const merged = mergeUserConfig(userConfig, overrides);
93
+ const directory = merged.directory ?? DEFAULT_STORAGE_DIR;
94
+ const configuredToken = merged.auth?.token?.trim() || "";
95
+ const repo = merged.repo?.trim() || "";
96
+ const issuesEnabled = merged.sync?.issues ?? true;
97
+ const pullsEnabled = merged.sync?.pulls ?? true;
98
+ const closedMode = merged.sync?.closed ?? false;
99
+ const patchesMode = merged.sync?.patches ?? "open";
100
+ return {
101
+ cwd,
102
+ repo,
103
+ directory,
104
+ auth: { token: configuredToken },
105
+ sync: {
106
+ issues: issuesEnabled,
107
+ pulls: pullsEnabled,
108
+ closed: closedMode,
109
+ patches: patchesMode
110
+ }
111
+ };
112
+ }
113
+ function getStorageDirAbsolute(config) {
114
+ return resolve(config.cwd, config.directory);
115
+ }
116
+ function getExecuteFile(config) {
117
+ return join(config.directory, EXECUTE_FILE_NAME);
118
+ }
119
+ function findConfigFile(cwd) {
120
+ for (const candidate of CONFIG_FILE_CANDIDATES) {
121
+ const fullPath = resolve(cwd, candidate);
122
+ if (existsSync(fullPath)) return fullPath;
123
+ }
124
+ }
125
+ function mergeUserConfig(base, overrides) {
126
+ return {
127
+ ...base,
128
+ ...overrides,
129
+ auth: {
130
+ ...base.auth,
131
+ ...overrides.auth
132
+ },
133
+ sync: {
134
+ ...base.sync,
135
+ ...overrides.sync
136
+ }
137
+ };
138
+ }
139
+
140
+ //#endregion
141
+ //#region src/utils/fs.ts
142
+ async function pathExists(path) {
143
+ try {
144
+ await access(path);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+ async function writeFileEnsured(path, content) {
151
+ await mkdir(dirname(path), { recursive: true });
152
+ await writeFile(path, content, "utf8");
153
+ }
154
+ async function removePath(path) {
155
+ await rm(path, { force: true });
156
+ }
157
+ async function movePath(from, to) {
158
+ await mkdir(dirname(to), { recursive: true });
159
+ await rename(from, to);
160
+ }
161
+ async function removePatchIfExists(storageDirAbsolute, number) {
162
+ const pullsDir = join(storageDirAbsolute, PULL_DIR_NAME);
163
+ let entries;
164
+ try {
165
+ entries = await readdir(pullsDir, { withFileTypes: true });
166
+ } catch {
167
+ return 0;
168
+ }
169
+ const padded = String(number).padStart(5, "0");
170
+ let removed = 0;
171
+ for (const entry of entries) {
172
+ if (!entry.isFile()) continue;
173
+ const fileName = entry.name;
174
+ const isLegacyPatch = fileName === `${number}.patch`;
175
+ const isCurrentPatch = fileName.startsWith(`${padded}-`) && fileName.endsWith(".patch");
176
+ if (!isLegacyPatch && !isCurrentPatch) continue;
177
+ await removePath(join(pullsDir, fileName));
178
+ removed += 1;
179
+ }
180
+ return removed;
181
+ }
182
+
183
+ //#endregion
184
+ //#region src/config/repo.ts
185
+ const execFileAsync = promisify(execFile);
186
+ async function resolveRepo(options) {
187
+ if (options.cliRepo) {
188
+ const repo = normalizeRepo(options.cliRepo);
189
+ if (!repo) throw new Error(`Invalid --repo value: ${options.cliRepo}`);
190
+ return {
191
+ repo,
192
+ source: "cli",
193
+ candidates: []
194
+ };
195
+ }
196
+ if (options.configRepo) {
197
+ const repo = normalizeRepo(options.configRepo);
198
+ if (!repo) throw new Error(`Invalid repo in ghfs.config.ts: ${options.configRepo}`);
199
+ return {
200
+ repo,
201
+ source: "config",
202
+ candidates: []
203
+ };
204
+ }
205
+ const candidates = [];
206
+ const gitCandidate = await detectRepoFromGit(options.cwd);
207
+ const pkgCandidate = await detectRepoFromPackageJson(options.cwd);
208
+ if (gitCandidate) candidates.push(gitCandidate);
209
+ if (pkgCandidate) candidates.push(pkgCandidate);
210
+ if (gitCandidate && pkgCandidate && gitCandidate.repo !== pkgCandidate.repo) {
211
+ if (options.interactive && process.stdin.isTTY && options.selectRepoChoice) {
212
+ const repo = await options.selectRepoChoice(gitCandidate, pkgCandidate);
213
+ if (!repo) throw new Error("Repository selection cancelled");
214
+ const normalizedRepo = normalizeRepo(repo);
215
+ if (!normalizedRepo || normalizedRepo !== gitCandidate.repo && normalizedRepo !== pkgCandidate.repo) throw new Error(`Invalid repository selection: ${repo}`);
216
+ return {
217
+ repo: normalizedRepo,
218
+ source: normalizedRepo === gitCandidate.repo ? "git" : "package-json",
219
+ candidates
220
+ };
221
+ }
222
+ throw new Error(`Repo mismatch detected. git=${gitCandidate.repo} package.json=${pkgCandidate.repo}. Use --repo to disambiguate.`);
223
+ }
224
+ if (gitCandidate) return {
225
+ repo: gitCandidate.repo,
226
+ source: "git",
227
+ candidates
228
+ };
229
+ if (pkgCandidate) return {
230
+ repo: pkgCandidate.repo,
231
+ source: "package-json",
232
+ candidates
233
+ };
234
+ throw new Error("Could not resolve repository. Provide --repo or set repo in ghfs.config.ts.");
235
+ }
236
+ function normalizeRepo(input) {
237
+ const trimmed = input.trim();
238
+ if (!trimmed) return void 0;
239
+ const shortMatch = trimmed.match(/^([\w.-]+)\/([\w.-]+)$/);
240
+ if (shortMatch) return `${shortMatch[1]}/${stripGitSuffix(shortMatch[2])}`;
241
+ const githubPrefixMatch = trimmed.match(/^github:([\w.-]+)\/([\w.-]+)$/);
242
+ if (githubPrefixMatch) return `${githubPrefixMatch[1]}/${stripGitSuffix(githubPrefixMatch[2])}`;
243
+ const sshScpMatch = trimmed.match(/^git@github\.com:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
244
+ if (sshScpMatch) return `${sshScpMatch[1]}/${stripGitSuffix(sshScpMatch[2])}`;
245
+ try {
246
+ const url = new URL(trimmed);
247
+ if (url.hostname !== "github.com") return void 0;
248
+ const segments = url.pathname.replace(/^\//, "").split("/").filter(Boolean);
249
+ if (segments.length < 2) return void 0;
250
+ return `${segments[0]}/${stripGitSuffix(segments[1])}`;
251
+ } catch {
252
+ return;
253
+ }
254
+ }
255
+ async function detectRepoFromGit(cwd) {
256
+ let stdout;
257
+ try {
258
+ stdout = (await execFileAsync("git", ["remote"], { cwd })).stdout;
259
+ } catch {
260
+ return;
261
+ }
262
+ const remotes = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
263
+ if (!remotes.length) return void 0;
264
+ const orderedRemotes = prioritizeRemotes(remotes);
265
+ for (const remote of orderedRemotes) try {
266
+ const repo = normalizeRepo((await execFileAsync("git", [
267
+ "remote",
268
+ "get-url",
269
+ remote
270
+ ], { cwd })).stdout.trim());
271
+ if (repo) return {
272
+ source: "git",
273
+ repo,
274
+ detail: `remote:${remote}`
275
+ };
276
+ } catch {
277
+ continue;
278
+ }
279
+ }
280
+ async function detectRepoFromPackageJson(cwd) {
281
+ const path = `${cwd}/package.json`;
282
+ if (!await pathExists(path)) return void 0;
283
+ let parsed;
284
+ try {
285
+ parsed = JSON.parse(await readFile(path, "utf8"));
286
+ } catch {
287
+ return;
288
+ }
289
+ if (!parsed || typeof parsed !== "object") return void 0;
290
+ const repository = parsed.repository;
291
+ if (typeof repository === "string") {
292
+ const repo = normalizeRepo(repository);
293
+ if (repo) return {
294
+ source: "package-json",
295
+ repo,
296
+ detail: "package.json#repository"
297
+ };
298
+ }
299
+ if (repository && typeof repository === "object") {
300
+ const url = repository.url;
301
+ if (typeof url === "string") {
302
+ const repo = normalizeRepo(url);
303
+ if (repo) return {
304
+ source: "package-json",
305
+ repo,
306
+ detail: "package.json#repository.url"
307
+ };
308
+ }
309
+ }
310
+ }
311
+ function prioritizeRemotes(remotes) {
312
+ const unique = [...new Set(remotes)];
313
+ const priority = ["origin", "upstream"];
314
+ const prioritized = priority.filter((name) => unique.includes(name));
315
+ const rest = unique.filter((name) => !priority.includes(name));
316
+ return [...prioritized, ...rest];
317
+ }
318
+ function stripGitSuffix(name) {
319
+ return name.replace(/\.git$/, "");
320
+ }
321
+
322
+ //#endregion
323
+ //#region src/execute/actions.ts
324
+ const ACTIONS_SUPPORTED = [
325
+ "close",
326
+ "close-with-comment",
327
+ "reopen",
328
+ "set-title",
329
+ "set-body",
330
+ "add-comment",
331
+ "add-labels",
332
+ "remove-labels",
333
+ "set-labels",
334
+ "add-assignees",
335
+ "remove-assignees",
336
+ "set-assignees",
337
+ "set-milestone",
338
+ "clear-milestone",
339
+ "lock",
340
+ "unlock",
341
+ "request-reviewers",
342
+ "remove-reviewers",
343
+ "mark-ready-for-review",
344
+ "convert-to-draft"
345
+ ];
346
+ const ACTIONS_ALIAS_MAP = {
347
+ "open": "reopen",
348
+ "closes": "close",
349
+ "close-comment": "close-with-comment",
350
+ "comment-close": "close-with-comment",
351
+ "close-and-comment": "close-with-comment",
352
+ "comment-and-close": "close-with-comment",
353
+ "label": "add-labels",
354
+ "labels": "add-labels",
355
+ "tag": "add-labels",
356
+ "tags": "add-labels",
357
+ "add-tag": "add-labels",
358
+ "assign": "add-assignees",
359
+ "assignee": "add-assignees",
360
+ "assignees": "add-assignees",
361
+ "body": "set-body",
362
+ "title": "set-title",
363
+ "retitle": "set-title",
364
+ "ready": "mark-ready-for-review",
365
+ "undraft": "mark-ready-for-review",
366
+ "draft": "convert-to-draft",
367
+ "comment": "add-comment"
368
+ };
369
+ const ACTIONS_COLOR_HEX = {
370
+ "close": "#ef4444",
371
+ "close-with-comment": "#fb7185",
372
+ "reopen": "#22c55e",
373
+ "set-title": "#3b82f6",
374
+ "set-body": "#06b6d4",
375
+ "add-comment": "#f97316",
376
+ "add-labels": "#84cc16",
377
+ "remove-labels": "#f43f5e",
378
+ "set-labels": "#eab308",
379
+ "add-assignees": "#10b981",
380
+ "remove-assignees": "#fb7185",
381
+ "set-assignees": "#0ea5e9",
382
+ "set-milestone": "#6366f1",
383
+ "clear-milestone": "#f59e0b",
384
+ "lock": "#a855f7",
385
+ "unlock": "#14b8a6",
386
+ "request-reviewers": "#38bdf8",
387
+ "remove-reviewers": "#e879f9",
388
+ "mark-ready-for-review": "#34d399",
389
+ "convert-to-draft": "#f472b6"
390
+ };
391
+ const ACTION_NAMES_SET = new Set(ACTIONS_SUPPORTED);
392
+ const ACTION_ALIASES = Object.keys(ACTIONS_ALIAS_MAP);
393
+ const ACTION_INPUTS = [...ACTIONS_SUPPORTED, ...ACTION_ALIASES];
394
+ function resolveActionName(action) {
395
+ const normalized = normalizeActionInput(action);
396
+ if (!normalized) return void 0;
397
+ if (ACTION_NAMES_SET.has(normalized)) return normalized;
398
+ return ACTIONS_ALIAS_MAP[normalized];
399
+ }
400
+ function normalizeActionInput(action) {
401
+ return action.trim().toLowerCase();
402
+ }
403
+
404
+ //#endregion
405
+ //#region src/execute/schema.ts
406
+ const executeSchema = {
407
+ $id: "https://ghfs.dev/schema/execute.json",
408
+ type: "array",
409
+ items: {
410
+ type: "object",
411
+ additionalProperties: true,
412
+ required: ["number", "action"],
413
+ properties: {
414
+ number: { type: "number" },
415
+ action: {
416
+ type: "string",
417
+ enum: [...ACTION_INPUTS]
418
+ },
419
+ ifUnchangedSince: {
420
+ type: "string",
421
+ format: "date-time"
422
+ },
423
+ title: { type: "string" },
424
+ body: { type: "string" },
425
+ labels: {
426
+ type: "array",
427
+ items: { type: "string" }
428
+ },
429
+ assignees: {
430
+ type: "array",
431
+ items: { type: "string" }
432
+ },
433
+ milestone: { anyOf: [{ type: "string" }, { type: "number" }] },
434
+ reviewers: {
435
+ type: "array",
436
+ items: { type: "string" }
437
+ },
438
+ reason: {
439
+ type: "string",
440
+ enum: [
441
+ "resolved",
442
+ "off-topic",
443
+ "too heated",
444
+ "too-heated",
445
+ "spam"
446
+ ]
447
+ }
448
+ }
449
+ }
450
+ };
451
+ const EXECUTE_FILE_PLACEHOLDER = [
452
+ `# yaml-language-server: $schema=./${EXECUTE_SCHEMA_RELATIVE_PATH}`,
453
+ "# Add operations as YAML list items, then run: ghfs execute --run",
454
+ "# Action names are case-insensitive and support aliases (for example: label, assign, comment, close-comment).",
455
+ "# - action: close",
456
+ "# number: 123",
457
+ "[]",
458
+ ""
459
+ ].join("\n");
460
+ const EXECUTE_MD_FILE_PLACEHOLDER = [
461
+ "# Add one action per line, then run: ghfs execute --run",
462
+ "# Command names are case-insensitive and support aliases.",
463
+ "# close #123 #124",
464
+ "# label #123 bug, triage",
465
+ "# assign #123 antfu",
466
+ "# comment #123 \"Need more context\"",
467
+ "# close-comment #123 \"Closing as completed\"",
468
+ "# set-title #123 \"new title\"",
469
+ "# add-tag #123 foo, bar",
470
+ ""
471
+ ].join("\n");
472
+ async function writeExecuteSchema(storageDirAbsolute) {
473
+ const schemaPath = getExecuteSchemaPath(storageDirAbsolute);
474
+ await mkdir(dirname(schemaPath), { recursive: true });
475
+ await writeFile(schemaPath, `${JSON.stringify(executeSchema, null, 2)}\n`, "utf8");
476
+ return schemaPath;
477
+ }
478
+ async function ensureExecuteArtifacts(executeFilePath) {
479
+ const storageDirAbsolute = dirname(executeFilePath);
480
+ const [schemaPath] = await Promise.all([
481
+ ensureExecuteSchema(storageDirAbsolute),
482
+ ensureExecuteFile(executeFilePath),
483
+ ensureExecuteMdFile(storageDirAbsolute)
484
+ ]);
485
+ return {
486
+ executeFilePath,
487
+ schemaPath
488
+ };
489
+ }
490
+ async function ensureExecuteSchema(storageDirAbsolute) {
491
+ const schemaPath = getExecuteSchemaPath(storageDirAbsolute);
492
+ if (await pathExists(schemaPath)) return schemaPath;
493
+ return writeExecuteSchema(storageDirAbsolute);
494
+ }
495
+ async function ensureExecuteFile(executeFilePath) {
496
+ if (await pathExists(executeFilePath)) return;
497
+ await mkdir(dirname(executeFilePath), { recursive: true });
498
+ await writeFile(executeFilePath, EXECUTE_FILE_PLACEHOLDER, "utf8");
499
+ }
500
+ async function ensureExecuteMdFile(storageDirAbsolute) {
501
+ const executeMdPath = join(storageDirAbsolute, EXECUTE_MD_FILE_NAME);
502
+ if (await pathExists(executeMdPath)) return;
503
+ await mkdir(dirname(executeMdPath), { recursive: true });
504
+ await writeFile(executeMdPath, EXECUTE_MD_FILE_PLACEHOLDER, "utf8");
505
+ }
506
+ function getExecuteSchemaPath(storageDirAbsolute) {
507
+ return join(storageDirAbsolute, EXECUTE_SCHEMA_RELATIVE_PATH);
508
+ }
509
+
510
+ //#endregion
511
+ //#region src/execute/validate.ts
512
+ const executeOpSchema = v.looseObject({
513
+ number: v.number(),
514
+ action: v.string(),
515
+ ifUnchangedSince: v.optional(v.string()),
516
+ title: v.optional(v.string()),
517
+ body: v.optional(v.string()),
518
+ labels: v.optional(v.array(v.string())),
519
+ assignees: v.optional(v.array(v.string())),
520
+ milestone: v.optional(v.union([v.string(), v.number()])),
521
+ reviewers: v.optional(v.array(v.string())),
522
+ reason: v.optional(v.picklist([
523
+ "resolved",
524
+ "off-topic",
525
+ "too heated",
526
+ "too-heated",
527
+ "spam"
528
+ ]))
529
+ });
530
+ const executeFileSchema = v.array(executeOpSchema);
531
+ async function readAndValidateExecuteFileWithSource(path) {
532
+ const raw = await readFile(path, "utf8");
533
+ let parsed;
534
+ try {
535
+ parsed = parse(raw);
536
+ } catch (error) {
537
+ throw new Error(`Failed to parse execute YAML: ${error.message}`);
538
+ }
539
+ const parsedResult = v.safeParse(executeFileSchema, parsed);
540
+ if (!parsedResult.success) {
541
+ const message = parsedResult.issues.map((issue) => {
542
+ const path = issue.path?.map((segment) => String(segment.key)).join(".");
543
+ return `${path ? `${path}: ` : ""}${issue.message}`;
544
+ }).join("; ");
545
+ throw new Error(`Invalid execute file: ${message}`);
546
+ }
547
+ const { pending, sourceActions, actionErrors } = normalizeActionInputs(parsedResult.output);
548
+ if (actionErrors.length) throw new Error(`Invalid execute file: ${actionErrors.join("; ")}`);
549
+ const customErrors = validateExecuteRules(pending);
550
+ if (customErrors.length) throw new Error(`Invalid execute file: ${customErrors.join("; ")}`);
551
+ return {
552
+ ops: pending,
553
+ sourceActions
554
+ };
555
+ }
556
+ async function writeExecuteFile(path, pending) {
557
+ const content = stringify(pending);
558
+ await mkdir(dirname(path), { recursive: true });
559
+ await writeFile(path, content.endsWith("\n") ? content : `${content}\n`, "utf8");
560
+ }
561
+ function validateExecuteRules(pending) {
562
+ const errors = [];
563
+ for (const [index, op] of pending.entries()) {
564
+ const key = `[${index}]`;
565
+ errors.push(...validateOperationRules(key, op));
566
+ }
567
+ return errors;
568
+ }
569
+ function validateOperationRules(key, op) {
570
+ const errors = [];
571
+ if (!Number.isInteger(op.number) || op.number <= 0) errors.push(`${key}: number must be a positive integer`);
572
+ switch (op.action) {
573
+ case "set-title":
574
+ if (!isNonEmptyString(op.title)) errors.push(`${key}: set-title requires title`);
575
+ break;
576
+ case "set-body":
577
+ case "add-comment":
578
+ case "close-with-comment":
579
+ if (!isNonEmptyString(op.body)) errors.push(`${key}: ${op.action} requires body`);
580
+ break;
581
+ case "add-labels":
582
+ case "remove-labels":
583
+ case "set-labels":
584
+ if (!isStringArray(op.labels)) errors.push(`${key}: ${op.action} requires labels[]`);
585
+ break;
586
+ case "add-assignees":
587
+ case "remove-assignees":
588
+ case "set-assignees":
589
+ if (!isStringArray(op.assignees)) errors.push(`${key}: ${op.action} requires assignees[]`);
590
+ break;
591
+ case "set-milestone":
592
+ if (!(typeof op.milestone === "string" || typeof op.milestone === "number")) errors.push(`${key}: set-milestone requires milestone`);
593
+ break;
594
+ case "request-reviewers":
595
+ case "remove-reviewers":
596
+ if (!isStringArray(op.reviewers)) errors.push(`${key}: ${op.action} requires reviewers[]`);
597
+ break;
598
+ default: break;
599
+ }
600
+ if (op.ifUnchangedSince && Number.isNaN(Date.parse(op.ifUnchangedSince))) errors.push(`${key}: ifUnchangedSince must be a valid datetime`);
601
+ return errors;
602
+ }
603
+ function isNonEmptyString(value) {
604
+ return typeof value === "string" && value.trim().length > 0;
605
+ }
606
+ function isStringArray(value) {
607
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string") && value.length > 0;
608
+ }
609
+ function normalizeActionInputs(pending) {
610
+ const normalized = [];
611
+ const sourceActions = [];
612
+ const actionErrors = [];
613
+ for (const [index, op] of pending.entries()) {
614
+ const sourceAction = op.action;
615
+ sourceActions.push(sourceAction);
616
+ const action = resolveActionName(sourceAction);
617
+ if (!action) {
618
+ actionErrors.push(`[${index}]: unknown action: ${sourceAction}`);
619
+ continue;
620
+ }
621
+ normalized.push({
622
+ ...op,
623
+ action
624
+ });
625
+ }
626
+ return {
627
+ pending: normalized,
628
+ sourceActions,
629
+ actionErrors
630
+ };
631
+ }
632
+
633
+ //#endregion
634
+ //#region src/execute/sources/execute-md.ts
635
+ const MULTI_SIMPLE_ACTIONS = new Set([
636
+ "close",
637
+ "reopen",
638
+ "clear-milestone",
639
+ "unlock",
640
+ "mark-ready-for-review",
641
+ "convert-to-draft"
642
+ ]);
643
+ function parseExecuteMdLine(line) {
644
+ const trimmed = line.trim();
645
+ if (!trimmed || isCommentLine(trimmed)) return void 0;
646
+ const tokens = tokenizeCommand(trimmed);
647
+ if (!tokens) return {
648
+ kind: "warning",
649
+ message: "invalid quoted string syntax"
650
+ };
651
+ if (tokens.length === 0) return void 0;
652
+ const [commandInput, ...args] = tokens;
653
+ const command = resolveActionName(commandInput);
654
+ if (!command) return {
655
+ kind: "warning",
656
+ message: `unrecognized action pattern: ${commandInput}`
657
+ };
658
+ if (command === "set-title") return parseSetTitle(args);
659
+ if (command === "add-labels") return parseAddLabels(args, commandInput);
660
+ if (command === "add-assignees") return parseAddAssignees(args, commandInput);
661
+ if (command === "add-comment") return parseAddComment(args, commandInput);
662
+ if (command === "close-with-comment") return parseCloseWithComment(args, commandInput);
663
+ if (MULTI_SIMPLE_ACTIONS.has(command)) return parseMultiSimpleAction(command, args, commandInput);
664
+ return {
665
+ kind: "warning",
666
+ message: `unrecognized action pattern: ${commandInput}`
667
+ };
668
+ }
669
+ async function readExecuteMdFile(path) {
670
+ if (!await pathExists(path)) return parseExecuteMd("");
671
+ return parseExecuteMd(await readFile(path, "utf8"));
672
+ }
673
+ function parseExecuteMd(raw) {
674
+ const lines = raw.split(/\r?\n/);
675
+ const ops = [];
676
+ const parsedLines = [];
677
+ const warnings = [];
678
+ let inHtmlCommentBlock = false;
679
+ for (const [lineIndex, rawLine] of lines.entries()) {
680
+ const trimmed = rawLine.trim();
681
+ if (inHtmlCommentBlock) {
682
+ parsedLines.push({
683
+ kind: "raw",
684
+ raw: rawLine
685
+ });
686
+ if (trimmed.includes("-->")) inHtmlCommentBlock = false;
687
+ continue;
688
+ }
689
+ if (trimmed.startsWith("<!--")) {
690
+ parsedLines.push({
691
+ kind: "raw",
692
+ raw: rawLine
693
+ });
694
+ if (!trimmed.includes("-->")) inHtmlCommentBlock = true;
695
+ continue;
696
+ }
697
+ const parsed = parseExecuteMdLine(rawLine);
698
+ if (!parsed) {
699
+ parsedLines.push({
700
+ kind: "raw",
701
+ raw: rawLine
702
+ });
703
+ continue;
704
+ }
705
+ if (parsed.kind === "warning") {
706
+ warnings.push(`execute-md line ${lineIndex + 1}: ${parsed.message}`);
707
+ parsedLines.push({
708
+ kind: "raw",
709
+ raw: rawLine
710
+ });
711
+ continue;
712
+ }
713
+ if (parsed.kind === "single") {
714
+ const opIndex = ops.length;
715
+ ops.push(parsed.op);
716
+ parsedLines.push({
717
+ kind: "single",
718
+ raw: rawLine,
719
+ opIndex
720
+ });
721
+ continue;
722
+ }
723
+ const opIndexes = [];
724
+ for (const number of parsed.numbers) {
725
+ opIndexes.push(ops.length);
726
+ ops.push({
727
+ action: parsed.action,
728
+ number
729
+ });
730
+ }
731
+ parsedLines.push({
732
+ kind: "multi",
733
+ action: parsed.action,
734
+ command: parsed.command,
735
+ opIndexes
736
+ });
737
+ }
738
+ return {
739
+ ops,
740
+ warnings,
741
+ lines: parsedLines
742
+ };
743
+ }
744
+ function stringifyExecuteMd(parsed, remainingOpIndexes) {
745
+ const lines = [];
746
+ for (const line of parsed.lines) {
747
+ if (line.kind === "raw") {
748
+ lines.push(line.raw);
749
+ continue;
750
+ }
751
+ if (line.kind === "single") {
752
+ if (remainingOpIndexes.has(line.opIndex)) lines.push(line.raw);
753
+ continue;
754
+ }
755
+ const numbers = line.opIndexes.filter((index) => remainingOpIndexes.has(index)).map((index) => parsed.ops[index]?.number).filter((value) => typeof value === "number");
756
+ if (numbers.length > 0) lines.push(`${line.command} ${numbers.map((number) => `#${number}`).join(" ")}`);
757
+ }
758
+ return `${lines.join("\n")}\n`;
759
+ }
760
+ function parseSetTitle(args) {
761
+ if (args.length !== 2) return {
762
+ kind: "warning",
763
+ message: "set-title expects: set-title #<number> \"<title>\""
764
+ };
765
+ const number = parseIssueRef(args[0]);
766
+ if (!number) return {
767
+ kind: "warning",
768
+ message: "set-title expects a single issue reference (#123)"
769
+ };
770
+ return {
771
+ kind: "single",
772
+ op: {
773
+ action: "set-title",
774
+ number,
775
+ title: args[1]
776
+ }
777
+ };
778
+ }
779
+ function parseAddLabels(args, command) {
780
+ if (args.length < 2) return {
781
+ kind: "warning",
782
+ message: `${command} expects: ${command} #<number> <label1, label2>`
783
+ };
784
+ const number = parseIssueRef(args[0]);
785
+ if (!number) return {
786
+ kind: "warning",
787
+ message: `${command} expects a single issue reference (#123)`
788
+ };
789
+ const labels = args.slice(1).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
790
+ if (labels.length === 0) return {
791
+ kind: "warning",
792
+ message: `${command} requires at least one label`
793
+ };
794
+ return {
795
+ kind: "single",
796
+ op: {
797
+ action: "add-labels",
798
+ number,
799
+ labels
800
+ }
801
+ };
802
+ }
803
+ function parseAddAssignees(args, command) {
804
+ if (args.length < 2) return {
805
+ kind: "warning",
806
+ message: `${command} expects: ${command} #<number> <assignee1, assignee2>`
807
+ };
808
+ const number = parseIssueRef(args[0]);
809
+ if (!number) return {
810
+ kind: "warning",
811
+ message: `${command} expects a single issue reference (#123)`
812
+ };
813
+ const assignees = args.slice(1).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
814
+ if (assignees.length === 0) return {
815
+ kind: "warning",
816
+ message: `${command} requires at least one assignee`
817
+ };
818
+ return {
819
+ kind: "single",
820
+ op: {
821
+ action: "add-assignees",
822
+ number,
823
+ assignees
824
+ }
825
+ };
826
+ }
827
+ function parseAddComment(args, command) {
828
+ if (args.length < 2) return {
829
+ kind: "warning",
830
+ message: `${command} expects: ${command} #<number> "<comment>"`
831
+ };
832
+ const number = parseIssueRef(args[0]);
833
+ if (!number) return {
834
+ kind: "warning",
835
+ message: `${command} expects a single issue reference (#123)`
836
+ };
837
+ const body = args.slice(1).join(" ").trim();
838
+ if (!body) return {
839
+ kind: "warning",
840
+ message: `${command} requires a non-empty comment`
841
+ };
842
+ return {
843
+ kind: "single",
844
+ op: {
845
+ action: "add-comment",
846
+ number,
847
+ body
848
+ }
849
+ };
850
+ }
851
+ function parseCloseWithComment(args, command) {
852
+ if (args.length < 2) return {
853
+ kind: "warning",
854
+ message: `${command} expects: ${command} #<number> "<comment>"`
855
+ };
856
+ const number = parseIssueRef(args[0]);
857
+ if (!number) return {
858
+ kind: "warning",
859
+ message: `${command} expects a single issue reference (#123)`
860
+ };
861
+ const body = args.slice(1).join(" ").trim();
862
+ if (!body) return {
863
+ kind: "warning",
864
+ message: `${command} requires a non-empty comment`
865
+ };
866
+ return {
867
+ kind: "single",
868
+ op: {
869
+ action: "close-with-comment",
870
+ number,
871
+ body
872
+ }
873
+ };
874
+ }
875
+ function parseMultiSimpleAction(action, args, command) {
876
+ const numbers = args.map(parseIssueRef);
877
+ if (numbers.length === 0 || numbers.some((number) => !number)) return {
878
+ kind: "warning",
879
+ message: `${command} expects one or more issue references (#123 #456)`
880
+ };
881
+ return {
882
+ kind: "multi",
883
+ action,
884
+ command,
885
+ numbers
886
+ };
887
+ }
888
+ function parseIssueRef(value) {
889
+ const match = value.match(/^#(\d+)$/);
890
+ if (!match) return void 0;
891
+ const number = Number.parseInt(match[1], 10);
892
+ if (!Number.isInteger(number) || number <= 0) return void 0;
893
+ return number;
894
+ }
895
+ function tokenizeCommand(value) {
896
+ const tokens = [];
897
+ let index = 0;
898
+ while (index < value.length) {
899
+ while (index < value.length && /\s/.test(value[index])) index += 1;
900
+ if (index >= value.length) break;
901
+ if (value[index] === "\"") {
902
+ index += 1;
903
+ let token = "";
904
+ let closed = false;
905
+ while (index < value.length) {
906
+ const char = value[index];
907
+ if (char === "\\") {
908
+ const next = value[index + 1];
909
+ if (next === "\"" || next === "\\") {
910
+ token += next;
911
+ index += 2;
912
+ continue;
913
+ }
914
+ }
915
+ if (char === "\"") {
916
+ closed = true;
917
+ index += 1;
918
+ break;
919
+ }
920
+ token += char;
921
+ index += 1;
922
+ }
923
+ if (!closed) return void 0;
924
+ tokens.push(token);
925
+ continue;
926
+ }
927
+ const start = index;
928
+ while (index < value.length && !/\s/.test(value[index])) index += 1;
929
+ tokens.push(value.slice(start, index));
930
+ }
931
+ return tokens;
932
+ }
933
+ function isCommentLine(trimmed) {
934
+ return trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("<!--");
935
+ }
936
+
937
+ //#endregion
938
+ //#region package.json
939
+ var version = "0.0.3";
940
+
941
+ //#endregion
942
+ //#region src/meta.ts
943
+ const GHFS_NAME = "ghfs";
944
+ const GHFS_VERSION = version;
945
+
946
+ //#endregion
947
+ //#region src/sync/state.ts
948
+ function getSyncStatePath(storageDirAbsolute) {
949
+ return join(storageDirAbsolute, SYNC_STATE_FILE_NAME);
950
+ }
951
+ async function loadSyncState(storageDirAbsolute) {
952
+ const path = getSyncStatePath(storageDirAbsolute);
953
+ try {
954
+ const raw = await readFile(path, "utf8");
955
+ const parsed = JSON.parse(raw);
956
+ if (parsed.version !== 2) return createEmptySyncState();
957
+ return {
958
+ version: 2,
959
+ items: normalizeItems(parsed.items),
960
+ executions: normalizeExecutions(parsed.executions),
961
+ ghfsVersion: typeof parsed.ghfsVersion === "string" ? parsed.ghfsVersion : void 0,
962
+ repo: parsed.repo,
963
+ lastSyncedAt: parsed.lastSyncedAt,
964
+ lastSince: parsed.lastSince,
965
+ lastRepoUpdatedAt: parsed.lastRepoUpdatedAt,
966
+ lastSyncRun: parsed.lastSyncRun
967
+ };
968
+ } catch {
969
+ return createEmptySyncState();
970
+ }
971
+ }
972
+ function normalizeItems(items) {
973
+ if (!items) return {};
974
+ if (typeof items !== "object" || Array.isArray(items)) return {};
975
+ const normalizedItems = {};
976
+ for (const [key, item] of Object.entries(items)) {
977
+ const normalizedItem = normalizeItem(item);
978
+ if (!normalizedItem) continue;
979
+ normalizedItems[key] = normalizedItem;
980
+ }
981
+ return normalizedItems;
982
+ }
983
+ function normalizeExecutions(executions) {
984
+ if (!Array.isArray(executions)) return [];
985
+ return executions.map((execution) => {
986
+ if (!execution || typeof execution !== "object") return execution;
987
+ const typedExecution = execution;
988
+ if (typedExecution.mode === "dry-run") return {
989
+ ...typedExecution,
990
+ mode: "report"
991
+ };
992
+ return typedExecution;
993
+ });
994
+ }
995
+ async function saveSyncState(storageDirAbsolute, state) {
996
+ await mkdir(storageDirAbsolute, { recursive: true });
997
+ const normalizedState = {
998
+ ...state,
999
+ ghfsVersion: state.ghfsVersion ?? GHFS_VERSION
1000
+ };
1001
+ await writeFile(getSyncStatePath(storageDirAbsolute), `${JSON.stringify(normalizedState, null, 2)}\n`, "utf8");
1002
+ }
1003
+ function createEmptySyncState() {
1004
+ return {
1005
+ version: 2,
1006
+ items: {},
1007
+ executions: []
1008
+ };
1009
+ }
1010
+ function appendExecution(state, result, limit = 20) {
1011
+ const nextExecutions = [result, ...state.executions].slice(0, limit);
1012
+ return {
1013
+ ...state,
1014
+ executions: nextExecutions
1015
+ };
1016
+ }
1017
+ function normalizeItem(item) {
1018
+ if (!item || typeof item !== "object") return void 0;
1019
+ if (!item.lastUpdatedAt || !item.lastSyncedAt || !item.filePath) return void 0;
1020
+ if (!item.data || !item.data.item) return void 0;
1021
+ const comments = Array.isArray(item.data.comments) ? item.data.comments : [];
1022
+ return {
1023
+ ...item,
1024
+ data: {
1025
+ ...item.data,
1026
+ item: {
1027
+ ...item.data.item,
1028
+ reactions: normalizeReactions(item.data.item.reactions)
1029
+ },
1030
+ comments: comments.filter((comment) => comment && typeof comment === "object").map((comment) => ({
1031
+ ...comment,
1032
+ reactions: normalizeReactions(comment.reactions)
1033
+ }))
1034
+ }
1035
+ };
1036
+ }
1037
+
1038
+ //#endregion
1039
+ //#region src/execute/sources/per-item.ts
1040
+ async function loadPerItemSource(storageDir) {
1041
+ const syncState = await loadSyncState(storageDir);
1042
+ const ops = [];
1043
+ const warnings = [];
1044
+ const repo = syncState.repo;
1045
+ for (const tracked of Object.values(syncState.items)) {
1046
+ const markdownPath = join(storageDir, tracked.filePath);
1047
+ if (!await pathExists(markdownPath)) {
1048
+ warnings.push(`per-item: missing markdown for ${formatIssueNumber(tracked.number, { repo })} (${tracked.filePath})`);
1049
+ continue;
1050
+ }
1051
+ const frontmatter = parseFrontmatter(await readFile(markdownPath, "utf8"));
1052
+ if (!frontmatter) {
1053
+ warnings.push(`per-item: invalid or missing frontmatter for ${formatIssueNumber(tracked.number, { repo })}`);
1054
+ continue;
1055
+ }
1056
+ const trackedItem = tracked.data.item;
1057
+ const itemOps = computePerItemOps({
1058
+ number: tracked.number,
1059
+ current: {
1060
+ title: trackedItem.title,
1061
+ state: trackedItem.state,
1062
+ labels: trackedItem.labels,
1063
+ assignees: trackedItem.assignees,
1064
+ milestone: trackedItem.milestone
1065
+ },
1066
+ desired: frontmatter,
1067
+ updatedAt: trackedItem.updatedAt
1068
+ });
1069
+ ops.push(...itemOps);
1070
+ }
1071
+ return {
1072
+ ops,
1073
+ warnings
1074
+ };
1075
+ }
1076
+ function computePerItemOps(input) {
1077
+ const ops = [];
1078
+ const ifUnchangedSince = input.updatedAt;
1079
+ if (input.current.title !== input.desired.title) ops.push({
1080
+ action: "set-title",
1081
+ number: input.number,
1082
+ title: input.desired.title,
1083
+ ifUnchangedSince
1084
+ });
1085
+ if (input.current.state !== input.desired.state) ops.push({
1086
+ action: input.desired.state === "closed" ? "close" : "reopen",
1087
+ number: input.number,
1088
+ ifUnchangedSince
1089
+ });
1090
+ if (!sameStringSet(input.current.labels, input.desired.labels)) {
1091
+ const additions = diffStrings(input.desired.labels, input.current.labels);
1092
+ const deletions = diffStrings(input.current.labels, input.desired.labels);
1093
+ if (additions.length > 0 && deletions.length > 0) ops.push({
1094
+ action: "set-labels",
1095
+ number: input.number,
1096
+ labels: input.desired.labels,
1097
+ ifUnchangedSince
1098
+ });
1099
+ else if (additions.length > 0) ops.push({
1100
+ action: "add-labels",
1101
+ number: input.number,
1102
+ labels: additions,
1103
+ ifUnchangedSince
1104
+ });
1105
+ else if (deletions.length > 0) ops.push({
1106
+ action: "remove-labels",
1107
+ number: input.number,
1108
+ labels: deletions,
1109
+ ifUnchangedSince
1110
+ });
1111
+ }
1112
+ if (!sameStringSet(input.current.assignees, input.desired.assignees)) {
1113
+ if (input.desired.assignees.length > 0) ops.push({
1114
+ action: "set-assignees",
1115
+ number: input.number,
1116
+ assignees: input.desired.assignees,
1117
+ ifUnchangedSince
1118
+ });
1119
+ else if (input.current.assignees.length > 0) ops.push({
1120
+ action: "remove-assignees",
1121
+ number: input.number,
1122
+ assignees: input.current.assignees,
1123
+ ifUnchangedSince
1124
+ });
1125
+ }
1126
+ if (normalizeMilestone(input.current.milestone) !== normalizeMilestone(input.desired.milestone)) if (input.desired.milestone) ops.push({
1127
+ action: "set-milestone",
1128
+ number: input.number,
1129
+ milestone: input.desired.milestone,
1130
+ ifUnchangedSince
1131
+ });
1132
+ else ops.push({
1133
+ action: "clear-milestone",
1134
+ number: input.number,
1135
+ ifUnchangedSince
1136
+ });
1137
+ return ops;
1138
+ }
1139
+ function parseFrontmatter(raw) {
1140
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
1141
+ if (!match) return void 0;
1142
+ let parsed;
1143
+ try {
1144
+ parsed = parse(match[1]);
1145
+ } catch {
1146
+ return;
1147
+ }
1148
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
1149
+ const data = parsed;
1150
+ const title = typeof data.title === "string" && data.title.trim().length > 0 ? data.title.trim() : void 0;
1151
+ const state = data.state === "open" || data.state === "closed" ? data.state : void 0;
1152
+ if (!title || !state) return void 0;
1153
+ return {
1154
+ title,
1155
+ state,
1156
+ labels: normalizeStringArray(data.labels ?? data.tags),
1157
+ assignees: normalizeStringArray(data.assignees),
1158
+ milestone: normalizeMilestone(data.milestone)
1159
+ };
1160
+ }
1161
+ function normalizeStringArray(value) {
1162
+ if (!Array.isArray(value)) return [];
1163
+ const unique = /* @__PURE__ */ new Set();
1164
+ for (const entry of value) {
1165
+ if (typeof entry !== "string") continue;
1166
+ const normalized = entry.trim();
1167
+ if (!normalized) continue;
1168
+ unique.add(normalized);
1169
+ }
1170
+ return [...unique];
1171
+ }
1172
+ function sameStringSet(left, right) {
1173
+ if (left.length !== right.length) return false;
1174
+ const sortedLeft = [...left].sort();
1175
+ const sortedRight = [...right].sort();
1176
+ return sortedLeft.every((value, index) => value === sortedRight[index]);
1177
+ }
1178
+ function normalizeMilestone(value) {
1179
+ if (typeof value !== "string") return null;
1180
+ const normalized = value.trim();
1181
+ return normalized.length > 0 ? normalized : null;
1182
+ }
1183
+ function diffStrings(source, target) {
1184
+ const targetSet = new Set(target);
1185
+ return source.filter((value) => !targetSet.has(value));
1186
+ }
1187
+
1188
+ //#endregion
1189
+ //#region src/execute/sources/index.ts
1190
+ async function loadExecuteSources(executeFilePath) {
1191
+ const storageDir = dirname(executeFilePath);
1192
+ const executeMdPath = join(storageDir, EXECUTE_MD_FILE_NAME);
1193
+ const yml = await readAndValidateExecuteFileWithSource(executeFilePath);
1194
+ const ymlOps = yml.ops;
1195
+ const executeMd = await readExecuteMdFile(executeMdPath);
1196
+ const perItem = await loadPerItemSource(storageDir);
1197
+ const mergedOps = [
1198
+ ...ymlOps,
1199
+ ...executeMd.ops,
1200
+ ...perItem.ops
1201
+ ];
1202
+ const customErrors = validateExecuteRules(mergedOps);
1203
+ if (customErrors.length) throw new Error(`Invalid execute file: ${customErrors.join("; ")}`);
1204
+ return {
1205
+ ops: mergedOps,
1206
+ warnings: [...executeMd.warnings, ...perItem.warnings],
1207
+ async writeRemaining(remainingIndexes) {
1208
+ await writeExecuteFile(executeFilePath, ymlOps.map((op, index) => ({
1209
+ op,
1210
+ index
1211
+ })).filter((item) => remainingIndexes.has(item.index)).map(({ op, index }) => ({
1212
+ ...op,
1213
+ action: yml.sourceActions[index] ?? op.action
1214
+ })));
1215
+ if (!await pathExists(executeMdPath)) return;
1216
+ const mdOffset = ymlOps.length;
1217
+ const mdRemaining = /* @__PURE__ */ new Set();
1218
+ for (const index of remainingIndexes) if (index >= mdOffset) mdRemaining.add(index - mdOffset);
1219
+ await writeFile(executeMdPath, stringifyExecuteMd(executeMd, mdRemaining), "utf-8");
1220
+ }
1221
+ };
1222
+ }
1223
+
1224
+ //#endregion
1225
+ //#region src/execute/index.ts
1226
+ var ExecuteCancelledError = class extends Error {
1227
+ constructor() {
1228
+ super("Execution cancelled");
1229
+ this.name = "ExecuteCancelledError";
1230
+ }
1231
+ };
1232
+ function isExecuteCancelledError(error) {
1233
+ return error instanceof ExecuteCancelledError;
1234
+ }
1235
+ async function executePendingChanges(options) {
1236
+ try {
1237
+ await ensureExecuteArtifacts(options.executeFilePath);
1238
+ const sources = await loadExecuteSources(options.executeFilePath);
1239
+ const allOps = sources.ops;
1240
+ for (const warning of sources.warnings) options.onWarning?.(warning);
1241
+ if (allOps.length === 0) return {
1242
+ runId: createRunId(),
1243
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1244
+ mode: "report",
1245
+ repo: options.repo,
1246
+ planned: 0,
1247
+ applied: 0,
1248
+ failed: 0,
1249
+ details: []
1250
+ };
1251
+ const interactive = process.stdin.isTTY && !options.nonInteractive;
1252
+ if (interactive && !options.prompts) throw new Error("Interactive execute prompts are unavailable. Use --non-interactive or provide prompts.");
1253
+ const selected = Array.isArray(options.selectedIndexes) ? selectOperationsByIndexes(allOps, options.selectedIndexes) : interactive ? await selectOperations(allOps, options.prompts) : allOps.map((op, index) => ({
1254
+ op,
1255
+ index
1256
+ }));
1257
+ const runId = createRunId();
1258
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1259
+ const mode = options.apply ? "apply" : "report";
1260
+ options.reporter?.onStart?.({
1261
+ repo: options.repo,
1262
+ mode,
1263
+ planned: selected.length
1264
+ });
1265
+ if (selected.length === 0) {
1266
+ const result = {
1267
+ runId,
1268
+ createdAt,
1269
+ mode,
1270
+ repo: options.repo,
1271
+ planned: 0,
1272
+ applied: 0,
1273
+ failed: 0,
1274
+ details: []
1275
+ };
1276
+ options.reporter?.onComplete?.({ result });
1277
+ return result;
1278
+ }
1279
+ options.onPlan?.(selected.map((item) => item.op));
1280
+ if (!options.apply) {
1281
+ const result = {
1282
+ runId,
1283
+ createdAt,
1284
+ mode: "report",
1285
+ repo: options.repo,
1286
+ planned: selected.length,
1287
+ applied: 0,
1288
+ failed: 0,
1289
+ details: selected.map(({ op, index }) => ({
1290
+ op: index + 1,
1291
+ action: op.action,
1292
+ number: op.number,
1293
+ status: "planned",
1294
+ message: describeExecutionAction(op.action, op.number)
1295
+ }))
1296
+ };
1297
+ options.reporter?.onComplete?.({ result });
1298
+ return result;
1299
+ }
1300
+ if (interactive) {
1301
+ if (!await confirmApply(selected.length, options.prompts)) throw new ExecuteCancelledError();
1302
+ }
1303
+ const provider = options.provider ?? createRepositoryProvider({
1304
+ token: options.token,
1305
+ repo: options.repo
1306
+ });
1307
+ const details = [];
1308
+ const appliedIndexes = /* @__PURE__ */ new Set();
1309
+ let applied = 0;
1310
+ let failed = 0;
1311
+ for (const { op, index } of selected) try {
1312
+ const target = await applyOperation(provider, op);
1313
+ appliedIndexes.add(index);
1314
+ await persistRemainingOps(sources.writeRemaining, allOps, appliedIndexes);
1315
+ const detail = {
1316
+ op: index + 1,
1317
+ action: op.action,
1318
+ number: op.number,
1319
+ target,
1320
+ status: "applied",
1321
+ message: describeExecutionAction(op.action, op.number)
1322
+ };
1323
+ details.push(detail);
1324
+ applied += 1;
1325
+ options.reporter?.onProgress?.({
1326
+ repo: options.repo,
1327
+ mode: "apply",
1328
+ planned: selected.length,
1329
+ completed: details.length,
1330
+ applied,
1331
+ failed,
1332
+ detail
1333
+ });
1334
+ } catch (error) {
1335
+ failed += 1;
1336
+ const detail = {
1337
+ op: index + 1,
1338
+ action: op.action,
1339
+ number: op.number,
1340
+ status: "failed",
1341
+ message: error.message
1342
+ };
1343
+ details.push(detail);
1344
+ options.reporter?.onProgress?.({
1345
+ repo: options.repo,
1346
+ mode: "apply",
1347
+ planned: selected.length,
1348
+ completed: details.length,
1349
+ applied,
1350
+ failed,
1351
+ detail
1352
+ });
1353
+ if (!options.continueOnError) break;
1354
+ }
1355
+ const result = {
1356
+ runId,
1357
+ createdAt,
1358
+ mode: "apply",
1359
+ repo: options.repo,
1360
+ planned: selected.length,
1361
+ applied,
1362
+ failed,
1363
+ details
1364
+ };
1365
+ options.reporter?.onComplete?.({ result });
1366
+ return result;
1367
+ } catch (error) {
1368
+ options.reporter?.onError?.({ error });
1369
+ throw error;
1370
+ }
1371
+ }
1372
+ async function persistRemainingOps(writeRemaining, allOps, appliedIndexes) {
1373
+ const remainingIndexes = /* @__PURE__ */ new Set();
1374
+ for (const [index] of allOps.entries()) if (!appliedIndexes.has(index)) remainingIndexes.add(index);
1375
+ await writeRemaining(remainingIndexes);
1376
+ }
1377
+ async function applyOperation(provider, op) {
1378
+ const item = await provider.fetchItemSnapshot(op.number);
1379
+ const isPull = item.kind === "pull";
1380
+ if (op.ifUnchangedSince) {
1381
+ const remoteUpdatedAt = item.updatedAt;
1382
+ if (remoteUpdatedAt && new Date(remoteUpdatedAt).getTime() > new Date(op.ifUnchangedSince).getTime()) throw new Error(`Operation conflict: remote updated_at=${remoteUpdatedAt}`);
1383
+ }
1384
+ switch (op.action) {
1385
+ case "close":
1386
+ await provider.actionClose(op.number);
1387
+ break;
1388
+ case "reopen":
1389
+ await provider.actionReopen(op.number);
1390
+ break;
1391
+ case "set-title":
1392
+ await provider.actionSetTitle(op.number, op.title);
1393
+ break;
1394
+ case "set-body":
1395
+ await provider.actionSetBody(op.number, op.body);
1396
+ break;
1397
+ case "add-comment":
1398
+ await provider.actionAddComment(op.number, op.body);
1399
+ break;
1400
+ case "close-with-comment":
1401
+ await provider.actionAddComment(op.number, op.body);
1402
+ await provider.actionClose(op.number);
1403
+ break;
1404
+ case "add-labels":
1405
+ await provider.actionAddLabels(op.number, op.labels);
1406
+ break;
1407
+ case "remove-labels":
1408
+ await provider.actionRemoveLabels(op.number, op.labels);
1409
+ break;
1410
+ case "set-labels":
1411
+ await provider.actionSetLabels(op.number, op.labels);
1412
+ break;
1413
+ case "add-assignees":
1414
+ await provider.actionAddAssignees(op.number, op.assignees);
1415
+ break;
1416
+ case "remove-assignees":
1417
+ await provider.actionRemoveAssignees(op.number, op.assignees);
1418
+ break;
1419
+ case "set-assignees":
1420
+ await provider.actionSetAssignees(op.number, op.assignees);
1421
+ break;
1422
+ case "set-milestone":
1423
+ await provider.actionSetMilestone(op.number, op.milestone);
1424
+ break;
1425
+ case "clear-milestone":
1426
+ await provider.actionClearMilestone(op.number);
1427
+ break;
1428
+ case "lock":
1429
+ await provider.actionLock(op.number, op.reason);
1430
+ break;
1431
+ case "unlock":
1432
+ await provider.actionUnlock(op.number);
1433
+ break;
1434
+ case "request-reviewers":
1435
+ ensurePullAction(op.action, op.number, isPull);
1436
+ await provider.actionRequestReviewers(op.number, op.reviewers);
1437
+ break;
1438
+ case "remove-reviewers":
1439
+ ensurePullAction(op.action, op.number, isPull);
1440
+ await provider.actionRemoveReviewers(op.number, op.reviewers);
1441
+ break;
1442
+ case "mark-ready-for-review":
1443
+ ensurePullAction(op.action, op.number, isPull);
1444
+ await provider.actionMarkReadyForReview(op.number);
1445
+ break;
1446
+ case "convert-to-draft":
1447
+ ensurePullAction(op.action, op.number, isPull);
1448
+ await provider.actionConvertToDraft(op.number);
1449
+ break;
1450
+ default: throw new Error(`Unsupported action: ${String(op.action)}`);
1451
+ }
1452
+ return item.kind;
1453
+ }
1454
+ function createRunId() {
1455
+ return `run_${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(36).slice(2, 7)}`;
1456
+ }
1457
+ async function selectOperations(ops, prompts) {
1458
+ const selectedIndexes = await prompts.selectOperations(ops);
1459
+ if (!selectedIndexes) throw new ExecuteCancelledError();
1460
+ const selectedIndexesSet = new Set(selectedIndexes);
1461
+ return ops.map((op, index) => ({
1462
+ op,
1463
+ index
1464
+ })).filter((item) => selectedIndexesSet.has(item.index));
1465
+ }
1466
+ async function confirmApply(count, prompts) {
1467
+ const result = await prompts.confirmApply(count);
1468
+ if (result == null) return false;
1469
+ return result;
1470
+ }
1471
+ function selectOperationsByIndexes(ops, selectedIndexes) {
1472
+ const selectedSet = /* @__PURE__ */ new Set();
1473
+ for (const index of selectedIndexes) if (Number.isInteger(index) && index >= 0 && index < ops.length) selectedSet.add(index);
1474
+ return ops.map((op, index) => ({
1475
+ op,
1476
+ index
1477
+ })).filter((item) => selectedSet.has(item.index));
1478
+ }
1479
+ function ensurePullAction(action, number, isPull) {
1480
+ if (!isPull) throw new Error(`Action ${action} requires #${number} to be a pull request`);
1481
+ }
1482
+ function describeExecutionAction(action, number) {
1483
+ return `${action} #${number}`;
1484
+ }
1485
+
1486
+ //#endregion
1487
+ //#region src/sync/execution-log.ts
1488
+ async function appendExecutionResult(storageDirAbsolute, result) {
1489
+ await saveSyncState(storageDirAbsolute, appendExecution(await loadSyncState(storageDirAbsolute), result));
1490
+ }
1491
+
1492
+ //#endregion
1493
+ //#region src/utils/sync.ts
1494
+ function resolveSince(options, syncState) {
1495
+ if (options.full) return void 0;
1496
+ if (options.since) return options.since;
1497
+ return syncState.lastSyncedAt;
1498
+ }
1499
+ function normalizeIssueNumbers(numbers) {
1500
+ if (!numbers) return void 0;
1501
+ return [...new Set(numbers.filter((number) => Number.isInteger(number) && number > 0))];
1502
+ }
1503
+
1504
+ //#endregion
1505
+ //#region src/sync/markdown.ts
1506
+ const FIELDS_ALWAYS_KEEP = new Set(["labels", "assignees"]);
1507
+ const FIELDS_ALWAYS_EXCLUDE = new Set(["repo", "kind"]);
1508
+ const REACTION_FIELDS = [
1509
+ {
1510
+ key: "plusOne",
1511
+ emoji: "👍"
1512
+ },
1513
+ {
1514
+ key: "minusOne",
1515
+ emoji: "👎"
1516
+ },
1517
+ {
1518
+ key: "laugh",
1519
+ emoji: "😄"
1520
+ },
1521
+ {
1522
+ key: "hooray",
1523
+ emoji: "🎉"
1524
+ },
1525
+ {
1526
+ key: "confused",
1527
+ emoji: "😕"
1528
+ },
1529
+ {
1530
+ key: "heart",
1531
+ emoji: "❤️"
1532
+ },
1533
+ {
1534
+ key: "rocket",
1535
+ emoji: "🚀"
1536
+ },
1537
+ {
1538
+ key: "eyes",
1539
+ emoji: "👀"
1540
+ }
1541
+ ];
1542
+ function renderIssueMarkdown(input) {
1543
+ const url = input.url || `https://github.com/${input.repo}/${input.kind === "pull" ? "pull" : "issues"}/${input.number}`;
1544
+ const frontmatter = {
1545
+ repo: input.repo,
1546
+ number: input.number,
1547
+ kind: input.kind,
1548
+ url,
1549
+ state: input.state,
1550
+ title: input.title,
1551
+ author: input.author,
1552
+ labels: input.labels,
1553
+ assignees: input.assignees,
1554
+ milestone: input.milestone,
1555
+ created_at: input.createdAt,
1556
+ updated_at: input.updatedAt,
1557
+ closed_at: input.closedAt,
1558
+ last_synced_at: input.lastSyncedAt,
1559
+ reactions: formatReactionsFrontmatter(input.reactions),
1560
+ is_draft: input.pr?.isDraft,
1561
+ merged: input.pr?.merged,
1562
+ merged_at: input.pr?.mergedAt,
1563
+ base_ref: input.pr?.baseRef,
1564
+ head_ref: input.pr?.headRef,
1565
+ reviewers_requested: input.pr?.requestedReviewers
1566
+ };
1567
+ const compactFrontmatter = Object.fromEntries(Object.entries(frontmatter).filter(([key, value]) => {
1568
+ if (FIELDS_ALWAYS_EXCLUDE.has(key)) return false;
1569
+ if (FIELDS_ALWAYS_KEEP.has(key)) return true;
1570
+ if (value === void 0 || value === null || value === false) return false;
1571
+ if (Array.isArray(value)) return value.length > 0;
1572
+ return true;
1573
+ }));
1574
+ const sections = [
1575
+ `# ${input.title}`,
1576
+ "",
1577
+ "## Description",
1578
+ "",
1579
+ input.body?.trim() || "_No description._"
1580
+ ];
1581
+ const bodyReactionsLine = formatReactionsLine(input.reactions);
1582
+ if (bodyReactionsLine) {
1583
+ sections.push("");
1584
+ sections.push(bodyReactionsLine);
1585
+ }
1586
+ sections.push("");
1587
+ sections.push("---");
1588
+ sections.push("");
1589
+ sections.push("## Comments");
1590
+ sections.push("");
1591
+ if (input.comments.length === 0) sections.push("_No comments._");
1592
+ else for (const [index, comment] of input.comments.entries()) {
1593
+ if (index > 0) {
1594
+ sections.push("---");
1595
+ sections.push("");
1596
+ }
1597
+ sections.push(`### @${comment.author} on ${comment.createdAt}`);
1598
+ sections.push(`<!-- comment-id:${comment.id} updated:${comment.updatedAt} -->`);
1599
+ sections.push("");
1600
+ sections.push(comment.body?.trim() || "_No content._");
1601
+ const reactionsLine = formatReactionsLine(comment.reactions);
1602
+ if (reactionsLine) {
1603
+ sections.push("");
1604
+ sections.push(reactionsLine);
1605
+ }
1606
+ sections.push("");
1607
+ }
1608
+ return [
1609
+ "---",
1610
+ stringify(compactFrontmatter).trimEnd(),
1611
+ "---",
1612
+ "",
1613
+ ...sections,
1614
+ ""
1615
+ ].join("\n");
1616
+ }
1617
+ function formatReactionsLine(reactions) {
1618
+ const entries = getReactionEntries(reactions);
1619
+ if (entries.length === 0) return void 0;
1620
+ return `> ${entries.map((entry) => `\`${entry.emoji} ${entry.count}\``).join(" | ")}`;
1621
+ }
1622
+ function formatReactionsFrontmatter(reactions) {
1623
+ const entries = getReactionEntries(reactions);
1624
+ if (entries.length === 0) return void 0;
1625
+ return Object.fromEntries(entries.map((entry) => [entry.emoji, entry.count]));
1626
+ }
1627
+ function getReactionEntries(reactions) {
1628
+ const normalized = normalizeReactions(reactions);
1629
+ return REACTION_FIELDS.map(({ key, emoji }) => {
1630
+ const count = normalized[key];
1631
+ if (!count) return void 0;
1632
+ return {
1633
+ emoji,
1634
+ count
1635
+ };
1636
+ }).filter((entry) => Boolean(entry));
1637
+ }
1638
+
1639
+ //#endregion
1640
+ //#region src/utils/string.ts
1641
+ function slugifyTitle(title, maxLength = 48) {
1642
+ const normalized = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1643
+ if (!normalized) return "item";
1644
+ return normalized.slice(0, maxLength).replace(/-+$/g, "") || "item";
1645
+ }
1646
+
1647
+ //#endregion
1648
+ //#region src/sync/paths.ts
1649
+ const FILE_NUMBER_PAD_LENGTH = 5;
1650
+ const MAX_SLUG_LENGTH = 48;
1651
+ function getIssueMarkdownPath(storageDirAbsolute, number, state, title) {
1652
+ const fileName = getItemFileName(number, title);
1653
+ if (state === "closed") return join(storageDirAbsolute, ISSUE_DIR_NAME, CLOSED_DIR_NAME, fileName);
1654
+ return join(storageDirAbsolute, ISSUE_DIR_NAME, fileName);
1655
+ }
1656
+ function getPullMarkdownPath(storageDirAbsolute, number, state, title) {
1657
+ const fileName = getItemFileName(number, title);
1658
+ if (state === "closed") return join(storageDirAbsolute, PULL_DIR_NAME, CLOSED_DIR_NAME, fileName);
1659
+ return join(storageDirAbsolute, PULL_DIR_NAME, fileName);
1660
+ }
1661
+ function getItemMarkdownPath(storageDirAbsolute, kind, number, state, title) {
1662
+ if (kind === "pull") return getPullMarkdownPath(storageDirAbsolute, number, state, title);
1663
+ return getIssueMarkdownPath(storageDirAbsolute, number, state, title);
1664
+ }
1665
+ function getItemFileName(number, title) {
1666
+ return `${String(number).padStart(FILE_NUMBER_PAD_LENGTH, "0")}-${slugifyTitle(title, MAX_SLUG_LENGTH)}.md`;
1667
+ }
1668
+ function getPrPatchPath(storageDirAbsolute, number, title) {
1669
+ return join(storageDirAbsolute, PULL_DIR_NAME, getItemFileName(number, title).replace(/\.md$/, ".patch"));
1670
+ }
1671
+
1672
+ //#endregion
1673
+ //#region src/sync/sync-repository-utils.ts
1674
+ function createCounters(scanned = 0, selected = 0) {
1675
+ return {
1676
+ scanned,
1677
+ selected,
1678
+ processed: 0,
1679
+ skipped: 0,
1680
+ written: 0,
1681
+ moved: 0,
1682
+ patchesWritten: 0,
1683
+ patchesDeleted: 0
1684
+ };
1685
+ }
1686
+ function addItemStats(counters, stats) {
1687
+ counters.skipped += stats.skipped;
1688
+ counters.written += stats.written;
1689
+ counters.moved += stats.moved;
1690
+ counters.patchesWritten += stats.patchesWritten;
1691
+ counters.patchesDeleted += stats.patchesDeleted;
1692
+ }
1693
+ function shouldSyncKind(sync, kind) {
1694
+ return kind === "issue" ? sync.issues : sync.pulls;
1695
+ }
1696
+ function shouldSyncIssue(sync, issue) {
1697
+ return shouldSyncKind(sync, issue.kind);
1698
+ }
1699
+ function resolvePatchPlan(patchesMode, kind, state) {
1700
+ if (kind !== "pull") return {
1701
+ shouldWritePatch: false,
1702
+ shouldDeletePatch: false
1703
+ };
1704
+ const shouldWritePatch = patchesMode === "all" || patchesMode === "open" && state === "open";
1705
+ return {
1706
+ shouldWritePatch,
1707
+ shouldDeletePatch: !shouldWritePatch
1708
+ };
1709
+ }
1710
+ function relativeToStorage(storageDirAbsolute, absolutePath) {
1711
+ if (absolutePath.startsWith(storageDirAbsolute)) return absolutePath.slice(storageDirAbsolute.length + 1);
1712
+ return basename(absolutePath);
1713
+ }
1714
+
1715
+ //#endregion
1716
+ //#region src/sync/sync-repository-storage.ts
1717
+ async function resolveIssuePaths(storageDirAbsolute, kind, number, title, state, trackedFilePath) {
1718
+ const closedPath = getItemMarkdownPath(storageDirAbsolute, kind, number, "closed", title);
1719
+ const openPath = getItemMarkdownPath(storageDirAbsolute, kind, number, "open", title);
1720
+ const hasClosedFile = await pathExists(closedPath);
1721
+ const hasOpenFile = await pathExists(openPath);
1722
+ const trackedPath = resolveTrackedPath(storageDirAbsolute, trackedFilePath);
1723
+ const hasTrackedFile = trackedPath ? await pathExists(trackedPath) : false;
1724
+ const targetPath = getItemMarkdownPath(storageDirAbsolute, kind, number, state, title);
1725
+ const hasTargetFile = state === "open" ? hasOpenFile : hasClosedFile;
1726
+ const matchedPaths = await findMatchedMarkdownPaths(storageDirAbsolute, kind, number, [
1727
+ openPath,
1728
+ closedPath,
1729
+ trackedPath
1730
+ ]);
1731
+ return {
1732
+ openPath,
1733
+ closedPath,
1734
+ targetPath,
1735
+ patchPath: getPrPatchPath(storageDirAbsolute, number, title),
1736
+ trackedPath,
1737
+ hasOpenFile,
1738
+ hasClosedFile,
1739
+ hasTrackedFile,
1740
+ matchedPaths,
1741
+ hasLocalFile: hasOpenFile || hasClosedFile || hasTrackedFile || matchedPaths.length > 0,
1742
+ hasTargetFile
1743
+ };
1744
+ }
1745
+ async function moveMarkdownByState(paths, state) {
1746
+ const sourcePath = resolveMoveSourcePath(paths, state);
1747
+ if (!sourcePath) return 0;
1748
+ if (sourcePath === paths.targetPath) return 0;
1749
+ if (await pathExists(paths.targetPath)) return 0;
1750
+ await movePath(sourcePath, paths.targetPath);
1751
+ return 1;
1752
+ }
1753
+ function resolveMoveSourcePath(paths, state) {
1754
+ if (paths.hasTrackedFile && paths.trackedPath && paths.trackedPath !== paths.targetPath) return paths.trackedPath;
1755
+ if (state === "open" && paths.hasClosedFile && paths.closedPath !== paths.targetPath) return paths.closedPath;
1756
+ if (state === "closed" && paths.hasOpenFile && paths.openPath !== paths.targetPath) return paths.openPath;
1757
+ if (paths.hasOpenFile && paths.openPath !== paths.targetPath) return paths.openPath;
1758
+ if (paths.hasClosedFile && paths.closedPath !== paths.targetPath) return paths.closedPath;
1759
+ return paths.matchedPaths.find((path) => path !== paths.targetPath);
1760
+ }
1761
+ function getExistingMarkdownPaths(paths) {
1762
+ const markdownPaths = /* @__PURE__ */ new Set();
1763
+ if (paths.hasOpenFile) markdownPaths.add(paths.openPath);
1764
+ if (paths.hasClosedFile) markdownPaths.add(paths.closedPath);
1765
+ if (paths.hasTrackedFile && paths.trackedPath) markdownPaths.add(paths.trackedPath);
1766
+ for (const matchedPath of paths.matchedPaths) markdownPaths.add(matchedPath);
1767
+ return [...markdownPaths];
1768
+ }
1769
+ function resolveTrackedPath(storageDirAbsolute, trackedFilePath) {
1770
+ if (!trackedFilePath) return void 0;
1771
+ if (isAbsolute(trackedFilePath)) return trackedFilePath;
1772
+ return join(storageDirAbsolute, trackedFilePath);
1773
+ }
1774
+ function resolveTrackedPathOrJoin(storageDirAbsolute, trackedFilePath) {
1775
+ return resolveTrackedPath(storageDirAbsolute, trackedFilePath) ?? join(storageDirAbsolute, trackedFilePath);
1776
+ }
1777
+ async function findMatchedMarkdownPaths(storageDirAbsolute, kind, number, knownPaths) {
1778
+ const matchedPaths = /* @__PURE__ */ new Set();
1779
+ const knownPathSet = new Set(knownPaths.filter(Boolean));
1780
+ const padded = String(number).padStart(5, "0");
1781
+ const kindDir = kind === "issue" ? ISSUE_DIR_NAME : PULL_DIR_NAME;
1782
+ for (const stateDir of ["", CLOSED_DIR_NAME]) {
1783
+ const dir = stateDir ? join(storageDirAbsolute, kindDir, stateDir) : join(storageDirAbsolute, kindDir);
1784
+ let files;
1785
+ try {
1786
+ files = await readdir(dir);
1787
+ } catch {
1788
+ continue;
1789
+ }
1790
+ for (const fileName of files) {
1791
+ if (!fileName.startsWith(`${padded}-`) || !fileName.endsWith(".md")) continue;
1792
+ const fullPath = join(dir, fileName);
1793
+ if (!knownPathSet.has(fullPath)) matchedPaths.add(fullPath);
1794
+ }
1795
+ }
1796
+ return [...matchedPaths];
1797
+ }
1798
+ function updateTrackedItem(context, number, kind, state, issueUpdatedAt, markdownPath, patchPath, data) {
1799
+ context.syncState.items[String(number)] = {
1800
+ number,
1801
+ kind,
1802
+ state,
1803
+ lastUpdatedAt: issueUpdatedAt,
1804
+ lastSyncedAt: context.syncedAt,
1805
+ filePath: relativeToStorage(context.storageDirAbsolute, markdownPath),
1806
+ patchPath: patchPath ? relativeToStorage(context.storageDirAbsolute, patchPath) : void 0,
1807
+ data
1808
+ };
1809
+ }
1810
+ async function pruneTrackedClosedItems(storageDirAbsolute, syncState, sync) {
1811
+ if (sync.issues) await rm(join(storageDirAbsolute, ISSUE_DIR_NAME, CLOSED_DIR_NAME), {
1812
+ recursive: true,
1813
+ force: true
1814
+ });
1815
+ if (sync.pulls) await rm(join(storageDirAbsolute, PULL_DIR_NAME, CLOSED_DIR_NAME), {
1816
+ recursive: true,
1817
+ force: true
1818
+ });
1819
+ let patchesDeleted = 0;
1820
+ for (const item of Object.values(syncState.items)) {
1821
+ if (item.state !== "closed") continue;
1822
+ if (!shouldSyncKind(sync, item.kind)) continue;
1823
+ await removePath(resolveTrackedPathOrJoin(storageDirAbsolute, item.filePath));
1824
+ if (item.kind === "pull") patchesDeleted += await removePatchIfExists(storageDirAbsolute, item.number);
1825
+ delete syncState.items[String(item.number)];
1826
+ }
1827
+ return patchesDeleted;
1828
+ }
1829
+ async function pruneMissingOpenTrackedItems(storageDirAbsolute, syncState, openNumbers, sync) {
1830
+ let patchesDeleted = 0;
1831
+ for (const item of Object.values(syncState.items)) {
1832
+ if (item.state !== "open") continue;
1833
+ if (!shouldSyncKind(sync, item.kind)) continue;
1834
+ if (openNumbers.has(item.number)) continue;
1835
+ await removePath(resolveTrackedPathOrJoin(storageDirAbsolute, item.filePath));
1836
+ if (item.kind === "pull") patchesDeleted += await removePatchIfExists(storageDirAbsolute, item.number);
1837
+ delete syncState.items[String(item.number)];
1838
+ }
1839
+ return patchesDeleted;
1840
+ }
1841
+
1842
+ //#endregion
1843
+ //#region src/sync/sync-repository-item.ts
1844
+ async function prepareIssueCandidateSync(context, issue) {
1845
+ const number = issue.number;
1846
+ const kind = issue.kind;
1847
+ const state = issue.state;
1848
+ const tracked = context.syncState.items[String(number)];
1849
+ const paths = await resolveIssuePaths(context.storageDirAbsolute, kind, number, issue.title, state, tracked?.filePath);
1850
+ const patchPlan = resolvePatchPlan(context.config.sync.patches, kind, state);
1851
+ if (state === "closed" && context.config.sync.closed === false) {
1852
+ delete context.syncState.items[String(number)];
1853
+ return {
1854
+ number,
1855
+ kind,
1856
+ state,
1857
+ action: "remove",
1858
+ paths,
1859
+ patchPlan
1860
+ };
1861
+ }
1862
+ if (state === "closed" && context.config.sync.closed === true && !paths.hasLocalFile) {
1863
+ delete context.syncState.items[String(number)];
1864
+ return {
1865
+ number,
1866
+ kind,
1867
+ state,
1868
+ action: "remove",
1869
+ paths,
1870
+ patchPlan
1871
+ };
1872
+ }
1873
+ const hasCanonicalData = Boolean(tracked?.data && (kind !== "pull" || tracked.data.pull));
1874
+ const shouldRefetch = !tracked || tracked.lastUpdatedAt !== issue.updatedAt || !hasCanonicalData;
1875
+ const data = shouldRefetch ? await fetchCanonicalData(context, issue) : tracked.data;
1876
+ updateTrackedItem(context, number, kind, state, issue.updatedAt, paths.targetPath, patchPlan.shouldWritePatch ? paths.patchPath : void 0, data);
1877
+ return {
1878
+ number,
1879
+ kind,
1880
+ state,
1881
+ action: resolveSyncAction(shouldRefetch, paths, state),
1882
+ paths,
1883
+ patchPlan
1884
+ };
1885
+ }
1886
+ async function materializePreparedIssue(context, candidate) {
1887
+ const { number, kind, state, action, patchPlan, paths } = candidate;
1888
+ if (action === "remove") {
1889
+ for (const markdownPath of getExistingMarkdownPaths(paths)) await removePath(markdownPath);
1890
+ let patchesDeleted = 0;
1891
+ if (kind === "pull") patchesDeleted += await removePatchIfExists(context.storageDirAbsolute, number);
1892
+ return {
1893
+ kind,
1894
+ action,
1895
+ skipped: 0,
1896
+ written: 0,
1897
+ moved: 0,
1898
+ patchesWritten: 0,
1899
+ patchesDeleted
1900
+ };
1901
+ }
1902
+ if (action === "skip") {
1903
+ let patchesDeleted = 0;
1904
+ if (patchPlan.shouldDeletePatch) patchesDeleted += await removePatchIfExists(context.storageDirAbsolute, number);
1905
+ return {
1906
+ kind,
1907
+ action,
1908
+ skipped: 1,
1909
+ written: 0,
1910
+ moved: 0,
1911
+ patchesWritten: 0,
1912
+ patchesDeleted
1913
+ };
1914
+ }
1915
+ const tracked = context.syncState.items[String(number)];
1916
+ if (!tracked) throw new Error(`Missing tracked canonical data for ${formatIssueNumber(number, {
1917
+ repo: context.repoSlug,
1918
+ kind
1919
+ })}`);
1920
+ const markdown = buildTrackedMarkdown(context, tracked);
1921
+ const moved = await moveMarkdownByState(paths, state);
1922
+ await writeFileEnsured(paths.targetPath, markdown);
1923
+ const patchStats = await syncPatchByPlan(context, number, paths.patchPath, patchPlan);
1924
+ return {
1925
+ kind,
1926
+ action,
1927
+ skipped: 0,
1928
+ written: 1,
1929
+ moved,
1930
+ patchesWritten: patchStats.patchesWritten,
1931
+ patchesDeleted: patchStats.patchesDeleted
1932
+ };
1933
+ }
1934
+ async function rematerializeTrackedMarkdown(context) {
1935
+ let processed = 0;
1936
+ let written = 0;
1937
+ let moved = 0;
1938
+ for (const tracked of Object.values(context.syncState.items)) {
1939
+ const paths = await resolveIssuePaths(context.storageDirAbsolute, tracked.kind, tracked.number, tracked.data.item.title, tracked.state, tracked.filePath);
1940
+ moved += await moveMarkdownByState(paths, tracked.state);
1941
+ await writeFileEnsured(paths.targetPath, buildTrackedMarkdown(context, tracked));
1942
+ tracked.filePath = relativeToStorage(context.storageDirAbsolute, paths.targetPath);
1943
+ tracked.lastSyncedAt = context.syncedAt;
1944
+ processed += 1;
1945
+ written += 1;
1946
+ }
1947
+ return {
1948
+ processed,
1949
+ written,
1950
+ moved
1951
+ };
1952
+ }
1953
+ async function reconcileMarkdownFilesByScan(context) {
1954
+ let written = 0;
1955
+ let moved = 0;
1956
+ const expectedPaths = /* @__PURE__ */ new Set();
1957
+ for (const tracked of Object.values(context.syncState.items)) {
1958
+ const paths = await resolveIssuePaths(context.storageDirAbsolute, tracked.kind, tracked.number, tracked.data.item.title, tracked.state, tracked.filePath);
1959
+ expectedPaths.add(paths.targetPath);
1960
+ const movedByState = !paths.hasTargetFile ? await moveMarkdownByState(paths, tracked.state) : 0;
1961
+ let changed = movedByState > 0;
1962
+ if (!await pathExists(paths.targetPath)) {
1963
+ await writeFileEnsured(paths.targetPath, buildTrackedMarkdown(context, tracked));
1964
+ written += 1;
1965
+ changed = true;
1966
+ }
1967
+ tracked.filePath = relativeToStorage(context.storageDirAbsolute, paths.targetPath);
1968
+ if (changed) tracked.lastSyncedAt = context.syncedAt;
1969
+ moved += movedByState;
1970
+ }
1971
+ moved += await moveExtraMarkdownFilesToClosed(context.storageDirAbsolute, expectedPaths);
1972
+ return {
1973
+ written,
1974
+ moved
1975
+ };
1976
+ }
1977
+ function resolveSyncAction(shouldRefetch, paths, state) {
1978
+ if (shouldRefetch) return "refetch";
1979
+ if (paths.hasTargetFile) return "skip";
1980
+ if (resolveMoveSourcePath(paths, state)) return "move";
1981
+ return "create";
1982
+ }
1983
+ async function fetchCanonicalData(context, issue) {
1984
+ return {
1985
+ item: issue,
1986
+ comments: await context.provider.fetchComments(issue.number),
1987
+ pull: issue.kind === "pull" ? await context.provider.fetchPullMetadata(issue.number) : void 0
1988
+ };
1989
+ }
1990
+ async function syncPatchByPlan(context, number, patchPath, patchPlan) {
1991
+ let patchesWritten = 0;
1992
+ let patchesDeleted = 0;
1993
+ if (patchPlan.shouldWritePatch) {
1994
+ const patch = await context.provider.fetchPullPatch(number);
1995
+ await removePatchIfExists(context.storageDirAbsolute, number);
1996
+ await writeFileEnsured(patchPath, patch);
1997
+ patchesWritten += 1;
1998
+ }
1999
+ if (patchPlan.shouldDeletePatch) patchesDeleted += await removePatchIfExists(context.storageDirAbsolute, number);
2000
+ return {
2001
+ patchesWritten,
2002
+ patchesDeleted
2003
+ };
2004
+ }
2005
+ function buildTrackedMarkdown(context, tracked) {
2006
+ return renderIssueMarkdown({
2007
+ repo: context.repoSlug,
2008
+ number: tracked.data.item.number,
2009
+ kind: tracked.data.item.kind,
2010
+ url: tracked.data.item.url,
2011
+ state: tracked.data.item.state,
2012
+ title: tracked.data.item.title,
2013
+ body: tracked.data.item.body ?? "",
2014
+ author: tracked.data.item.author ?? "unknown",
2015
+ labels: tracked.data.item.labels,
2016
+ assignees: tracked.data.item.assignees,
2017
+ milestone: tracked.data.item.milestone,
2018
+ createdAt: tracked.data.item.createdAt,
2019
+ updatedAt: tracked.data.item.updatedAt,
2020
+ closedAt: tracked.data.item.closedAt,
2021
+ lastSyncedAt: context.syncedAt,
2022
+ reactions: normalizeReactions(tracked.data.item.reactions),
2023
+ comments: tracked.data.comments.map((comment) => ({
2024
+ id: comment.id,
2025
+ author: comment.author ?? "unknown",
2026
+ body: comment.body ?? "",
2027
+ createdAt: comment.createdAt,
2028
+ updatedAt: comment.updatedAt,
2029
+ reactions: normalizeReactions(comment.reactions)
2030
+ })),
2031
+ pr: tracked.data.pull
2032
+ });
2033
+ }
2034
+ async function moveExtraMarkdownFilesToClosed(storageDirAbsolute, expectedPaths) {
2035
+ let moved = 0;
2036
+ moved += await moveOpenMarkdownFilesToClosed(join(storageDirAbsolute, ISSUE_DIR_NAME), expectedPaths);
2037
+ moved += await moveOpenMarkdownFilesToClosed(join(storageDirAbsolute, PULL_DIR_NAME), expectedPaths);
2038
+ return moved;
2039
+ }
2040
+ async function moveOpenMarkdownFilesToClosed(kindDirAbsolute, expectedPaths) {
2041
+ let moved = 0;
2042
+ const openFiles = await listOpenMarkdownFiles(kindDirAbsolute);
2043
+ const closedDirAbsolute = join(kindDirAbsolute, CLOSED_DIR_NAME);
2044
+ for (const markdownPath of openFiles) {
2045
+ if (expectedPaths.has(markdownPath)) continue;
2046
+ await movePath(markdownPath, await resolveUniqueClosedTarget(closedDirAbsolute, basename(markdownPath)));
2047
+ moved += 1;
2048
+ }
2049
+ return moved;
2050
+ }
2051
+ async function listOpenMarkdownFiles(kindDirAbsolute) {
2052
+ try {
2053
+ return (await readdir(kindDirAbsolute, {
2054
+ withFileTypes: true,
2055
+ encoding: "utf8"
2056
+ })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => join(kindDirAbsolute, entry.name));
2057
+ } catch {
2058
+ return [];
2059
+ }
2060
+ }
2061
+ async function resolveUniqueClosedTarget(closedDirAbsolute, fileName) {
2062
+ const baseName = fileName.replace(/\.md$/i, "");
2063
+ let candidate = join(closedDirAbsolute, fileName);
2064
+ let index = 1;
2065
+ while (await pathExists(candidate)) {
2066
+ candidate = join(closedDirAbsolute, `${baseName}-extra-${index}.md`);
2067
+ index += 1;
2068
+ }
2069
+ return candidate;
2070
+ }
2071
+
2072
+ //#endregion
2073
+ //#region src/sync/sync-repository-provider.ts
2074
+ async function fetchIssueCandidatesByPagination(context, since) {
2075
+ const issues = [];
2076
+ let scanned = 0;
2077
+ const allOpenNumbers = context.config.sync.closed === false && !since ? /* @__PURE__ */ new Set() : void 0;
2078
+ const state = context.config.sync.closed === false ? "open" : "all";
2079
+ for await (const page of context.provider.paginateItems({
2080
+ state,
2081
+ since
2082
+ })) for (const issue of page) {
2083
+ if (!shouldSyncIssue(context.config.sync, issue)) continue;
2084
+ scanned += 1;
2085
+ issues.push(issue);
2086
+ if (state === "open" && allOpenNumbers) allOpenNumbers.add(issue.number);
2087
+ }
2088
+ return {
2089
+ issues,
2090
+ scanned,
2091
+ allOpenNumbers
2092
+ };
2093
+ }
2094
+ async function fetchIssueCandidatesByNumbers(context, numbers) {
2095
+ const issues = (await context.provider.fetchItemsByNumbers(numbers)).filter((issue) => shouldSyncIssue(context.config.sync, issue));
2096
+ return {
2097
+ issues,
2098
+ scanned: issues.length
2099
+ };
2100
+ }
2101
+
2102
+ //#endregion
2103
+ //#region src/utils/markdown.ts
2104
+ function getTimestamp(value) {
2105
+ const timestamp = Date.parse(value);
2106
+ if (Number.isFinite(timestamp)) return timestamp;
2107
+ return Number.NEGATIVE_INFINITY;
2108
+ }
2109
+ function renderRowsTable(rows) {
2110
+ const lines = ["| Number | Title | Labels | Updated | File |", "| --- | --- | --- | --- | --- |"];
2111
+ if (rows.length === 0) {
2112
+ lines.push("| - | - | - | - | - |");
2113
+ return lines;
2114
+ }
2115
+ for (const row of rows) {
2116
+ const labels = row.labels.length ? row.labels.map((label) => `\`${escapeInlineCode(label)}\``).join(", ") : "-";
2117
+ lines.push(`| #${row.number} | ${escapeTableCell(row.title)} | ${labels} | ${escapeTableCell(row.updatedAt)} | [${row.filePath}](${row.filePath}) |`);
2118
+ }
2119
+ return lines;
2120
+ }
2121
+ function escapeTableCell(value) {
2122
+ return value.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim() || "-";
2123
+ }
2124
+ function escapeInlineCode(value) {
2125
+ return value.replace(/`/g, "\\`");
2126
+ }
2127
+
2128
+ //#endregion
2129
+ //#region src/sync/sync-repository-snapshot.ts
2130
+ async function writeRepoSnapshot(context) {
2131
+ const repoSnapshot = await buildRepoSnapshot(context);
2132
+ await mkdir(context.storageDirAbsolute, { recursive: true });
2133
+ await writeFile(join(context.storageDirAbsolute, REPO_SNAPSHOT_FILE_NAME), `${JSON.stringify(repoSnapshot, null, 2)}\n`, "utf8");
2134
+ }
2135
+ async function writeRepositoryIndexes(context) {
2136
+ const [issuesMarkdown, pullsMarkdown] = await Promise.all([renderIndexMarkdown(context, "issue"), renderIndexMarkdown(context, "pull")]);
2137
+ await mkdir(context.storageDirAbsolute, { recursive: true });
2138
+ await Promise.all([writeFile(join(context.storageDirAbsolute, ISSUES_INDEX_FILE_NAME), issuesMarkdown, "utf8"), writeFile(join(context.storageDirAbsolute, PULLS_INDEX_FILE_NAME), pullsMarkdown, "utf8")]);
2139
+ }
2140
+ async function renderIndexMarkdown(context, kind) {
2141
+ const rows = Object.values(context.syncState.items).filter((item) => item.kind === kind).map((item) => readIndexRow(item));
2142
+ const openRows = sortRows(rows.filter((row) => row.state === "open"));
2143
+ const closedRows = sortRows(rows.filter((row) => row.state === "closed"));
2144
+ return [
2145
+ `# ${kind === "issue" ? "Issues" : "Pull Requests"}`,
2146
+ "",
2147
+ `- repo: ${context.repoSlug}`,
2148
+ `- synced_at: ${context.syncedAt}`,
2149
+ `- total: ${rows.length}`,
2150
+ `- open: ${openRows.length}`,
2151
+ `- closed: ${closedRows.length}`,
2152
+ "",
2153
+ `## Open (${openRows.length})`,
2154
+ "",
2155
+ ...renderRowsTable(openRows),
2156
+ "",
2157
+ `## Closed (${closedRows.length})`,
2158
+ "",
2159
+ ...renderRowsTable(closedRows),
2160
+ ""
2161
+ ].join("\n");
2162
+ }
2163
+ function readIndexRow(item) {
2164
+ return {
2165
+ number: item.number,
2166
+ state: item.state,
2167
+ title: item.data.item.title,
2168
+ labels: item.data.item.labels,
2169
+ updatedAt: item.data.item.updatedAt,
2170
+ filePath: item.filePath
2171
+ };
2172
+ }
2173
+ function sortRows(rows) {
2174
+ return [...rows].sort((left, right) => {
2175
+ const updatedDiff = getTimestamp(right.updatedAt) - getTimestamp(left.updatedAt);
2176
+ if (updatedDiff !== 0) return updatedDiff;
2177
+ return right.number - left.number;
2178
+ });
2179
+ }
2180
+ async function buildRepoSnapshot(context) {
2181
+ const [repoResult, labelsResult, milestonesResult] = await Promise.all([
2182
+ context.provider.fetchRepository(),
2183
+ context.provider.fetchRepositoryLabels(),
2184
+ context.provider.fetchRepositoryMilestones()
2185
+ ]);
2186
+ const repository = repoResult;
2187
+ const labels = labelsResult.map((label) => ({
2188
+ name: label.name,
2189
+ color: label.color,
2190
+ description: label.description ?? null,
2191
+ default: Boolean(label.default)
2192
+ })).sort((left, right) => left.name.localeCompare(right.name));
2193
+ const milestones = milestonesResult.map((milestone) => ({
2194
+ number: milestone.number,
2195
+ title: milestone.title,
2196
+ state: milestone.state,
2197
+ description: milestone.description ?? null,
2198
+ due_on: milestone.due_on,
2199
+ open_issues: milestone.open_issues,
2200
+ closed_issues: milestone.closed_issues,
2201
+ created_at: milestone.created_at,
2202
+ updated_at: milestone.updated_at,
2203
+ closed_at: milestone.closed_at
2204
+ })).sort((left, right) => left.number - right.number);
2205
+ return {
2206
+ repo: context.repoSlug,
2207
+ synced_at: context.syncedAt,
2208
+ repository: {
2209
+ owner: repository.owner.login,
2210
+ name: repository.name,
2211
+ full_name: repository.full_name,
2212
+ description: repository.description ?? null,
2213
+ private: repository.private,
2214
+ archived: repository.archived,
2215
+ default_branch: repository.default_branch,
2216
+ html_url: repository.html_url,
2217
+ fork: repository.fork,
2218
+ open_issues_count: repository.open_issues_count,
2219
+ has_issues: repository.has_issues,
2220
+ has_projects: repository.has_projects,
2221
+ has_wiki: repository.has_wiki,
2222
+ created_at: repository.created_at,
2223
+ updated_at: repository.updated_at,
2224
+ pushed_at: repository.pushed_at
2225
+ },
2226
+ labels,
2227
+ milestones
2228
+ };
2229
+ }
2230
+
2231
+ //#endregion
2232
+ //#region src/sync/sync-repository.ts
2233
+ async function syncRepository(options) {
2234
+ const reporter = options.reporter;
2235
+ const startedAt = /* @__PURE__ */ new Date();
2236
+ const startedAtIso = startedAt.toISOString();
2237
+ const runId = createSyncRunId();
2238
+ const provider = options.provider ?? createRepositoryProvider({
2239
+ token: options.token,
2240
+ repo: options.repo
2241
+ });
2242
+ const storageDirAbsolute = resolve(options.config.cwd, options.config.directory);
2243
+ const targetNumbers = normalizeIssueNumbers(options.numbers);
2244
+ const counters = createCounters();
2245
+ const stageDurations = createStageDurations();
2246
+ let errorReported = false;
2247
+ reporter?.onStart?.({
2248
+ repo: options.repo,
2249
+ startedAt: startedAtIso,
2250
+ numbersCount: targetNumbers?.length,
2251
+ snapshot: cloneSnapshot(counters)
2252
+ });
2253
+ let context;
2254
+ let since;
2255
+ let repoUpdatedAt;
2256
+ let candidates = {
2257
+ issues: [],
2258
+ scanned: 0
2259
+ };
2260
+ const preparedCandidates = [];
2261
+ let updatedIssues = 0;
2262
+ let updatedPulls = 0;
2263
+ let ghfsVersionMismatch = false;
2264
+ let previousGhfsVersion;
2265
+ const runStage = async (stage, message, fn) => {
2266
+ reporter?.onStageStart?.({
2267
+ stage,
2268
+ message,
2269
+ snapshot: cloneSnapshot(counters)
2270
+ });
2271
+ const stageStartedAt = Date.now();
2272
+ try {
2273
+ const result = await fn();
2274
+ const durationMs = Date.now() - stageStartedAt;
2275
+ stageDurations[stage] = durationMs;
2276
+ reporter?.onStageEnd?.({
2277
+ stage,
2278
+ message,
2279
+ durationMs,
2280
+ snapshot: cloneSnapshot(counters)
2281
+ });
2282
+ return result;
2283
+ } catch (error) {
2284
+ errorReported = true;
2285
+ stageDurations[stage] = Date.now() - stageStartedAt;
2286
+ reporter?.onError?.({
2287
+ stage,
2288
+ error,
2289
+ snapshot: cloneSnapshot(counters)
2290
+ });
2291
+ throw error;
2292
+ }
2293
+ };
2294
+ try {
2295
+ let shouldEarlyReturn = false;
2296
+ await runStage("metadata", "Fetch repository metadata", async () => {
2297
+ const syncState = await loadSyncState(storageDirAbsolute);
2298
+ since = targetNumbers ? void 0 : resolveSince(options, syncState);
2299
+ const syncedAt = (/* @__PURE__ */ new Date()).toISOString();
2300
+ context = {
2301
+ provider,
2302
+ repoSlug: options.repo,
2303
+ storageDirAbsolute,
2304
+ config: options.config,
2305
+ syncState,
2306
+ syncedAt,
2307
+ totalIssues: 0,
2308
+ totalPulls: 0
2309
+ };
2310
+ previousGhfsVersion = syncState.ghfsVersion;
2311
+ ghfsVersionMismatch = syncState.ghfsVersion !== GHFS_VERSION;
2312
+ if (targetNumbers) return;
2313
+ repoUpdatedAt = (await provider.fetchRepository()).updated_at;
2314
+ if (!options.full && syncState.lastRepoUpdatedAt && syncState.lastRepoUpdatedAt === repoUpdatedAt) shouldEarlyReturn = true;
2315
+ reporter?.onStageUpdate?.({
2316
+ stage: "metadata",
2317
+ snapshot: cloneSnapshot(counters),
2318
+ message: `since=${since ?? "(full)"} repoUpdatedAt=${repoUpdatedAt}`
2319
+ });
2320
+ });
2321
+ assertContext(context);
2322
+ const syncContext = context;
2323
+ if (!shouldEarlyReturn) {
2324
+ await runStage("pagination", "Pagination", async () => {
2325
+ const paginatedSince = options.full ? void 0 : since;
2326
+ candidates = targetNumbers ? await fetchIssueCandidatesByNumbers(syncContext, targetNumbers) : await fetchIssueCandidatesByPagination(syncContext, paginatedSince);
2327
+ counters.scanned = candidates.scanned;
2328
+ counters.selected = candidates.issues.length;
2329
+ reporter?.onStageUpdate?.({
2330
+ stage: "pagination",
2331
+ snapshot: cloneSnapshot(counters),
2332
+ message: `scanned=${counters.scanned} selected=${counters.selected}`
2333
+ });
2334
+ });
2335
+ await runStage("fetch", "Fetch updated issues/PRs", async () => {
2336
+ for (const issue of candidates.issues) {
2337
+ const prepared = await prepareIssueCandidateSync(syncContext, issue);
2338
+ preparedCandidates.push(prepared);
2339
+ counters.processed += 1;
2340
+ if (prepared.action === "refetch" || prepared.action === "create") if (prepared.kind === "issue") updatedIssues += 1;
2341
+ else updatedPulls += 1;
2342
+ reporter?.onStageUpdate?.({
2343
+ stage: "fetch",
2344
+ snapshot: cloneSnapshot(counters),
2345
+ message: `${formatIssueNumber(issue.number, {
2346
+ repo: options.repo,
2347
+ kind: issue.kind
2348
+ })} ${prepared.kind} ${prepared.action}`
2349
+ });
2350
+ }
2351
+ });
2352
+ syncContext.syncState.repo = options.repo;
2353
+ if (!targetNumbers) {
2354
+ syncContext.syncState.lastSyncedAt = syncContext.syncedAt;
2355
+ syncContext.syncState.lastSince = since;
2356
+ syncContext.syncState.lastRepoUpdatedAt = repoUpdatedAt;
2357
+ }
2358
+ await saveSyncState(syncContext.storageDirAbsolute, syncContext.syncState);
2359
+ await runStage("materialize", "Materialize local files", async () => {
2360
+ for (const prepared of preparedCandidates) addItemStats(counters, await materializePreparedIssue(syncContext, prepared));
2361
+ });
2362
+ await runStage("prune", "Prune stale local artifacts", async () => {
2363
+ if (options.config.sync.closed === false) counters.patchesDeleted += await pruneTrackedClosedItems(syncContext.storageDirAbsolute, syncContext.syncState, options.config.sync);
2364
+ if (!targetNumbers && options.config.sync.closed === false && candidates.allOpenNumbers) counters.patchesDeleted += await pruneMissingOpenTrackedItems(syncContext.storageDirAbsolute, syncContext.syncState, candidates.allOpenNumbers, options.config.sync);
2365
+ reporter?.onStageUpdate?.({
2366
+ stage: "prune",
2367
+ snapshot: cloneSnapshot(counters),
2368
+ message: `patchesDeleted=${counters.patchesDeleted}`
2369
+ });
2370
+ });
2371
+ }
2372
+ await runStage("save", "Save sync state", async () => {
2373
+ if (ghfsVersionMismatch) {
2374
+ const rematerialized = await rematerializeTrackedMarkdown(syncContext);
2375
+ reporter?.onStageUpdate?.({
2376
+ stage: "save",
2377
+ snapshot: cloneSnapshot(counters),
2378
+ message: `regenerated=${rematerialized.written} version=${previousGhfsVersion ?? "(none)"}->${GHFS_VERSION}`
2379
+ });
2380
+ }
2381
+ const scanStats = await reconcileMarkdownFilesByScan(syncContext);
2382
+ counters.written += scanStats.written;
2383
+ counters.moved += scanStats.moved;
2384
+ reporter?.onStageUpdate?.({
2385
+ stage: "save",
2386
+ snapshot: cloneSnapshot(counters),
2387
+ message: `scan-fixed written=${scanStats.written} moved=${scanStats.moved}`
2388
+ });
2389
+ if (!shouldEarlyReturn) await writeRepoSnapshot(syncContext);
2390
+ if (!shouldEarlyReturn || ghfsVersionMismatch) await writeRepositoryIndexes(syncContext);
2391
+ syncContext.syncState.ghfsVersion = GHFS_VERSION;
2392
+ await saveSyncState(syncContext.storageDirAbsolute, syncContext.syncState);
2393
+ });
2394
+ const totals = computeTotals(syncContext.syncState.items);
2395
+ syncContext.totalIssues = totals.totalIssues;
2396
+ syncContext.totalPulls = totals.totalPulls;
2397
+ const finishedAt = /* @__PURE__ */ new Date();
2398
+ const durationMs = Math.max(0, finishedAt.getTime() - startedAt.getTime());
2399
+ const requestCount = provider.getRequestCount();
2400
+ const summary = {
2401
+ repo: options.repo,
2402
+ since,
2403
+ syncedAt: syncContext.syncedAt,
2404
+ totalIssues: totals.totalIssues,
2405
+ totalPulls: totals.totalPulls,
2406
+ updatedIssues,
2407
+ updatedPulls,
2408
+ trackedItems: totals.trackedItems,
2409
+ requestCount,
2410
+ scanned: counters.scanned,
2411
+ selected: counters.selected,
2412
+ processed: counters.processed,
2413
+ skipped: counters.skipped,
2414
+ written: counters.written,
2415
+ moved: counters.moved,
2416
+ patchesWritten: counters.patchesWritten,
2417
+ patchesDeleted: counters.patchesDeleted,
2418
+ durationMs
2419
+ };
2420
+ syncContext.syncState.lastSyncRun = {
2421
+ runId,
2422
+ repo: options.repo,
2423
+ startedAt: startedAtIso,
2424
+ finishedAt: finishedAt.toISOString(),
2425
+ durationMs,
2426
+ requestCount,
2427
+ since,
2428
+ numbersCount: targetNumbers?.length,
2429
+ counters: cloneSnapshot(counters),
2430
+ stages: { ...stageDurations }
2431
+ };
2432
+ await saveSyncState(syncContext.storageDirAbsolute, syncContext.syncState);
2433
+ reporter?.onComplete?.({
2434
+ summary,
2435
+ stages: { ...stageDurations }
2436
+ });
2437
+ return summary;
2438
+ } catch (error) {
2439
+ if (!errorReported) reporter?.onError?.({
2440
+ error,
2441
+ snapshot: cloneSnapshot(counters)
2442
+ });
2443
+ throw error;
2444
+ }
2445
+ }
2446
+ function assertContext(context) {
2447
+ if (!context) throw new Error("Sync context was not initialized");
2448
+ }
2449
+ function createSyncRunId() {
2450
+ return `sync_${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "")}_${randomBytes(3).toString("hex")}`;
2451
+ }
2452
+ function createStageDurations() {
2453
+ return {
2454
+ metadata: 0,
2455
+ pagination: 0,
2456
+ fetch: 0,
2457
+ materialize: 0,
2458
+ prune: 0,
2459
+ save: 0
2460
+ };
2461
+ }
2462
+ function cloneSnapshot(counters) {
2463
+ return {
2464
+ scanned: counters.scanned,
2465
+ selected: counters.selected,
2466
+ processed: counters.processed,
2467
+ skipped: counters.skipped,
2468
+ written: counters.written,
2469
+ moved: counters.moved,
2470
+ patchesWritten: counters.patchesWritten,
2471
+ patchesDeleted: counters.patchesDeleted
2472
+ };
2473
+ }
2474
+ function computeTotals(items) {
2475
+ let totalIssues = 0;
2476
+ let totalPulls = 0;
2477
+ for (const item of Object.values(items)) if (item.kind === "issue") totalIssues += 1;
2478
+ else totalPulls += 1;
2479
+ return {
2480
+ totalIssues,
2481
+ totalPulls,
2482
+ trackedItems: totalIssues + totalPulls
2483
+ };
2484
+ }
2485
+
2486
+ //#endregion
2487
+ //#region src/cli/action-color.ts
2488
+ function colorizeAction(action, enabled = true) {
2489
+ if (!enabled) return action;
2490
+ const colorHex = ACTIONS_COLOR_HEX[action];
2491
+ if (!colorHex) return c.white(action);
2492
+ return c.hex(colorHex)(action);
2493
+ }
2494
+ function describeCliOperation(op, options = {}) {
2495
+ const tty = options.tty ?? false;
2496
+ const issueRef = formatIssueRef(op.number, options.repo, tty);
2497
+ const action = colorizeAction(op.action, tty);
2498
+ const values = formatOperationValues(op);
2499
+ if (!values) return `${issueRef} ${action}`;
2500
+ return `${issueRef} ${action} ${values}`;
2501
+ }
2502
+ function formatIssueRef(number, repo, tty) {
2503
+ const text = `#${number}`;
2504
+ if (!tty || !repo || !repo.includes("/")) return text;
2505
+ return formatTerminalLink(text, `https://github.com/${repo}/issues/${number}`);
2506
+ }
2507
+ function formatTerminalLink(text, url) {
2508
+ return `\u001B]8;;${url}\u001B\\${text}\u001B]8;;\u001B\\`;
2509
+ }
2510
+ function formatOperationValues(op) {
2511
+ switch (op.action) {
2512
+ case "set-title": return wrapTextValue(op.title);
2513
+ case "set-body":
2514
+ case "add-comment":
2515
+ case "close-with-comment": return wrapTextValue(op.body);
2516
+ case "add-labels":
2517
+ case "remove-labels":
2518
+ case "set-labels": return joinValues(op.labels);
2519
+ case "add-assignees":
2520
+ case "remove-assignees":
2521
+ case "set-assignees": return joinValues(op.assignees);
2522
+ case "set-milestone": return op.milestone != null ? String(op.milestone) : void 0;
2523
+ case "request-reviewers":
2524
+ case "remove-reviewers": return joinValues(op.reviewers);
2525
+ case "lock": return op.reason;
2526
+ default: return;
2527
+ }
2528
+ }
2529
+ function joinValues(values) {
2530
+ if (!values || values.length === 0) return void 0;
2531
+ return values.join(", ");
2532
+ }
2533
+ function wrapTextValue(value) {
2534
+ if (!value) return void 0;
2535
+ const normalized = value.trim();
2536
+ if (normalized.length <= 48) return normalized;
2537
+ return `${normalized.slice(0, 45)}...`;
2538
+ }
2539
+
2540
+ //#endregion
2541
+ //#region src/cli/errors.ts
2542
+ function withErrorHandling(fn) {
2543
+ return (...args) => {
2544
+ fn(...args).catch((error) => {
2545
+ console.error(error);
2546
+ process.exit(1);
2547
+ });
2548
+ };
2549
+ }
2550
+
2551
+ //#endregion
2552
+ //#region src/cli/meta.ts
2553
+ const CLI_NAME = GHFS_NAME;
2554
+ const CLI_VERSION = GHFS_VERSION;
2555
+ function ASCII_HEADER(repo) {
2556
+ return c.gray([
2557
+ " _ ___ ",
2558
+ " ___| |_| _|___ ",
2559
+ ` | . | | _|_ -| ${c.green.bold(CLI_NAME)} ${c.blue(`v${CLI_VERSION}`)}`,
2560
+ ` |_ |_|_|_| |___| → ${repo}`,
2561
+ " |___| ",
2562
+ ""
2563
+ ].join("\n"));
2564
+ }
2565
+ function toGitHubRepoUrl(repo) {
2566
+ return `https://github.com/${repo}`;
2567
+ }
2568
+
2569
+ //#endregion
2570
+ //#region src/cli/printer.ts
2571
+ function createCliPrinter(command, options = {}) {
2572
+ const mode = resolveMode(options);
2573
+ const progressEvery = options.progressEvery ?? 25;
2574
+ const write = (message) => {
2575
+ if (message == null) return;
2576
+ console.log(message);
2577
+ };
2578
+ const writeError = (message) => {
2579
+ console.error(message);
2580
+ };
2581
+ const printer = {
2582
+ mode,
2583
+ header(repo) {
2584
+ if (mode !== "rich") return;
2585
+ write(ASCII_HEADER(formatTerminalLink$1(repo, toGitHubRepoUrl(repo))));
2586
+ },
2587
+ start(message) {
2588
+ if (mode === "rich") p.intro(message);
2589
+ else write(message);
2590
+ },
2591
+ step(message) {
2592
+ if (mode === "rich") p.log.step(message);
2593
+ else write(message);
2594
+ },
2595
+ info(message) {
2596
+ if (mode === "rich") p.log.info(message);
2597
+ else write(message);
2598
+ },
2599
+ success(message) {
2600
+ if (mode === "rich") p.log.success(message);
2601
+ else write(message);
2602
+ },
2603
+ warn(message) {
2604
+ if (mode === "rich") p.log.warn(message);
2605
+ else write(message);
2606
+ },
2607
+ error(message) {
2608
+ if (mode === "rich") p.log.error(message);
2609
+ else writeError(message);
2610
+ },
2611
+ done(message) {
2612
+ if (mode === "rich") p.outro(message);
2613
+ else write(message);
2614
+ },
2615
+ note(message, title) {
2616
+ if (mode === "rich") p.note(message, title, { format: (line) => line });
2617
+ else {
2618
+ if (title) write(`--- ${title} ---`);
2619
+ write(message);
2620
+ if (title) write("---");
2621
+ }
2622
+ },
2623
+ table(title, entries, options) {
2624
+ const lines = formatKeyValueLines(entries, options);
2625
+ if (lines.length !== 0) printer.note(lines.join("\n"), title);
2626
+ },
2627
+ print(lines) {
2628
+ for (const line of lines) console.log(line);
2629
+ },
2630
+ createSyncReporter() {
2631
+ return mode === "rich" ? createRichSyncReporter(printer) : createPlainSyncReporter(printer, progressEvery);
2632
+ },
2633
+ createExecuteReporter() {
2634
+ return mode === "rich" ? createRichExecuteReporter(printer) : createPlainExecuteReporter(printer);
2635
+ }
2636
+ };
2637
+ return printer;
2638
+ }
2639
+ function createRichSyncReporter(printer) {
2640
+ let stageSpinner = p.spinner();
2641
+ let syncProgress = p.progress({ max: 1 });
2642
+ let hasSyncProgress = false;
2643
+ let lastProcessed = 0;
2644
+ return {
2645
+ onStart() {},
2646
+ onStageStart(event) {
2647
+ if (isHiddenSyncStage(event.stage)) return;
2648
+ if (event.stage === "fetch") {
2649
+ hasSyncProgress = true;
2650
+ syncProgress = p.progress({ max: Math.max(event.snapshot.selected, 1) });
2651
+ lastProcessed = 0;
2652
+ syncProgress.start(formatSyncProgressLine(event.snapshot));
2653
+ return;
2654
+ }
2655
+ stageSpinner = p.spinner();
2656
+ stageSpinner.start(event.message);
2657
+ },
2658
+ onStageUpdate(event) {
2659
+ if (event.stage === "fetch" && hasSyncProgress) {
2660
+ const nextProcessed = event.snapshot.processed;
2661
+ const advanceBy = Math.max(0, nextProcessed - lastProcessed);
2662
+ const message = formatSyncProgressLine(event.snapshot, event.message);
2663
+ if (advanceBy > 0) syncProgress.advance(advanceBy, message);
2664
+ else syncProgress.message(message);
2665
+ lastProcessed = nextProcessed;
2666
+ return;
2667
+ }
2668
+ if (isHiddenSyncStage(event.stage)) return;
2669
+ if (event.message) stageSpinner.message(event.message);
2670
+ },
2671
+ onStageEnd(event) {
2672
+ if (isHiddenSyncStage(event.stage)) return;
2673
+ const completionLine = formatStageCompletionLine(event.stage, event.snapshot, event.durationMs);
2674
+ if (event.stage === "fetch" && hasSyncProgress) {
2675
+ const advanceBy = Math.max(0, event.snapshot.processed - lastProcessed);
2676
+ if (advanceBy > 0) syncProgress.advance(advanceBy);
2677
+ if (completionLine) syncProgress.stop(completionLine);
2678
+ else syncProgress.clear();
2679
+ hasSyncProgress = false;
2680
+ return;
2681
+ }
2682
+ if (completionLine) stageSpinner.stop(completionLine);
2683
+ else stageSpinner.clear();
2684
+ },
2685
+ onComplete(event) {
2686
+ printer.success(`Sync finished. ${event.summary.updatedIssues} issues and ${event.summary.updatedPulls} PRs updated${c.dim(` (${formatDuration(event.summary.durationMs)})`)}.`);
2687
+ },
2688
+ onError(event) {
2689
+ const message = `Sync failed${event.stage && !isHiddenSyncStage(event.stage) ? ` while ${describeStage(event.stage)}` : ""}: ${toErrorMessage(event.error)}`;
2690
+ if (hasSyncProgress) {
2691
+ syncProgress.error(message);
2692
+ hasSyncProgress = false;
2693
+ return;
2694
+ }
2695
+ stageSpinner.error(message);
2696
+ }
2697
+ };
2698
+ }
2699
+ function createPlainSyncReporter(printer, progressEvery) {
2700
+ let lastProgress = 0;
2701
+ return {
2702
+ onStart() {},
2703
+ onStageStart(event) {
2704
+ if (isHiddenSyncStage(event.stage)) return;
2705
+ printer.step(event.message);
2706
+ },
2707
+ onStageUpdate(event) {
2708
+ if (event.stage === "fetch") {
2709
+ const processed = event.snapshot.processed;
2710
+ if (!(processed === 0 || processed === event.snapshot.selected || processed - lastProgress >= progressEvery)) return;
2711
+ lastProgress = processed;
2712
+ printer.step(formatSyncProgressLine(event.snapshot, event.message));
2713
+ return;
2714
+ }
2715
+ if (isHiddenSyncStage(event.stage)) return;
2716
+ if (event.message) printer.info(event.message);
2717
+ },
2718
+ onStageEnd(event) {
2719
+ if (isHiddenSyncStage(event.stage)) return;
2720
+ const completionLine = formatStageCompletionLine(event.stage, event.snapshot, event.durationMs);
2721
+ if (!completionLine) return;
2722
+ printer.step(completionLine);
2723
+ },
2724
+ onComplete(event) {
2725
+ printer.success(`Sync finished. ${event.summary.updatedIssues} issues and ${event.summary.updatedPulls} PRs updated${c.dim(` (${formatDuration(event.summary.durationMs)})`)}.`);
2726
+ },
2727
+ onError(event) {
2728
+ const stage = event.stage && !isHiddenSyncStage(event.stage) ? ` while ${describeStage(event.stage)}` : "";
2729
+ printer.error(c.red(`Sync failed${stage}: ${toErrorMessage(event.error)}`));
2730
+ }
2731
+ };
2732
+ }
2733
+ function createRichExecuteReporter(printer) {
2734
+ let applyProgress = p.progress({ max: 1 });
2735
+ let hasApplyProgress = false;
2736
+ let lastCompleted = 0;
2737
+ return {
2738
+ onStart(event) {
2739
+ const runMode = event.mode === "apply" ? "apply run" : "report mode";
2740
+ printer.step(`Starting ${runMode} with ${countNoun(event.planned, "planned operation")}.`);
2741
+ if (event.mode === "apply") {
2742
+ hasApplyProgress = true;
2743
+ applyProgress = p.progress({ max: Math.max(event.planned, 1) });
2744
+ lastCompleted = 0;
2745
+ applyProgress.start(`Completed 0 of ${countNoun(event.planned, "operation")}`);
2746
+ }
2747
+ },
2748
+ onProgress(event) {
2749
+ if (!hasApplyProgress) return;
2750
+ const advanceBy = Math.max(0, event.completed - lastCompleted);
2751
+ const message = `Completed ${event.completed} of ${countNoun(event.planned, "operation")} (${event.applied} applied, ${event.failed} failed). Latest: operation #${event.detail.op} ${event.detail.status}.`;
2752
+ if (advanceBy > 0) applyProgress.advance(advanceBy, message);
2753
+ else applyProgress.message(message);
2754
+ lastCompleted = event.completed;
2755
+ },
2756
+ onComplete(event) {
2757
+ if (hasApplyProgress) {
2758
+ const advanceBy = Math.max(0, event.result.details.length - lastCompleted);
2759
+ if (advanceBy > 0) applyProgress.advance(advanceBy);
2760
+ applyProgress.stop(`Apply run finished (${event.result.applied} applied, ${event.result.failed} failed).`);
2761
+ hasApplyProgress = false;
2762
+ }
2763
+ const runMode = event.result.mode === "apply" ? "Apply run" : "Report mode";
2764
+ printer.success(`${runMode} finished. Planned ${event.result.planned}, applied ${event.result.applied}, failed ${event.result.failed}.`);
2765
+ },
2766
+ onError(event) {
2767
+ const message = `Execution failed: ${toErrorMessage(event.error)}`;
2768
+ if (hasApplyProgress) {
2769
+ applyProgress.error(message);
2770
+ hasApplyProgress = false;
2771
+ return;
2772
+ }
2773
+ printer.error(c.red(message));
2774
+ }
2775
+ };
2776
+ }
2777
+ function createPlainExecuteReporter(printer) {
2778
+ return {
2779
+ onStart(event) {
2780
+ const runMode = event.mode === "apply" ? "apply run" : "report mode";
2781
+ printer.step(`Starting ${runMode} with ${countNoun(event.planned, "planned operation")}.`);
2782
+ },
2783
+ onProgress(event) {
2784
+ printer.step(`Completed ${event.completed} of ${countNoun(event.planned, "operation")} (${event.applied} applied, ${event.failed} failed). Latest: operation #${event.detail.op} ${event.detail.status}.`);
2785
+ },
2786
+ onComplete(event) {
2787
+ const runMode = event.result.mode === "apply" ? "Apply run" : "Report mode";
2788
+ printer.success(`${runMode} finished. Planned ${event.result.planned}, applied ${event.result.applied}, failed ${event.result.failed}.`);
2789
+ },
2790
+ onError(event) {
2791
+ printer.error(c.red(`Execution failed: ${toErrorMessage(event.error)}`));
2792
+ }
2793
+ };
2794
+ }
2795
+ function resolveMode(options) {
2796
+ const isTTY = options.isTTY ?? Boolean(process.stdout.isTTY);
2797
+ const isCI = options.isCI ?? Boolean(process.env.CI);
2798
+ return isTTY && !isCI ? "rich" : "plain";
2799
+ }
2800
+ function formatSyncProgressLine(snapshot, message) {
2801
+ const line = `Fetched ${snapshot.processed} of ${countNoun(snapshot.selected, "item")} (${snapshot.skipped} skipped, ${snapshot.written} written, ${snapshot.moved} moved, ${countNoun(snapshot.patchesWritten, "patch")} added, ${countNoun(snapshot.patchesDeleted, "patch")} removed)`;
2802
+ if (!message) return line;
2803
+ return `${line}. Current: ${message}`;
2804
+ }
2805
+ function formatStageCompletionLine(stage, snapshot, durationMs) {
2806
+ if (!hasStageEffect(stage, snapshot)) return void 0;
2807
+ const duration = c.dim(` (${formatDuration(durationMs)})`);
2808
+ if (stage === "pagination") return `Pagination scanned ${countNoun(snapshot.scanned, "candidate item")}${duration}.`;
2809
+ if (stage === "fetch") return `Fetched updated issues/PRs (${snapshot.processed}/${snapshot.selected})${duration}.`;
2810
+ }
2811
+ function toErrorMessage(error) {
2812
+ return error.message || String(error);
2813
+ }
2814
+ function formatKeyValueLines(entries, options = {}) {
2815
+ const { indent = 1, dimKey = true, excludeZero = false } = options;
2816
+ const padding = " ".repeat(Math.max(0, indent));
2817
+ const validEntries = entries.filter(([, value]) => {
2818
+ if (value == null) return false;
2819
+ if (excludeZero && value === 0) return false;
2820
+ return true;
2821
+ });
2822
+ const keyWidth = Math.max(0, ...validEntries.map(([key]) => key.length));
2823
+ return validEntries.map(([key, value]) => {
2824
+ const displayKey = dimKey ? c.dim(key.padStart(keyWidth, " ")) : key.padStart(keyWidth, " ");
2825
+ if (value == null) return void 0;
2826
+ if (excludeZero && value === 0) return void 0;
2827
+ return `${padding}${displayKey} ${formatValue(value)}`;
2828
+ }).filter((x) => x != null);
2829
+ }
2830
+ function hasStageEffect(stage, snapshot) {
2831
+ if (isHiddenSyncStage(stage)) return false;
2832
+ if (stage === "pagination") return snapshot.scanned > 0;
2833
+ if (stage === "fetch") return snapshot.processed > 0;
2834
+ return true;
2835
+ }
2836
+ function isHiddenSyncStage(stage) {
2837
+ return stage !== "pagination" && stage !== "fetch";
2838
+ }
2839
+ function describeStage(stage) {
2840
+ if (stage === "pagination") return "running pagination";
2841
+ if (stage === "fetch") return "fetching updated issues and pull requests";
2842
+ if (stage === "metadata") return "fetching repository metadata";
2843
+ if (stage === "materialize") return "materializing local files";
2844
+ if (stage === "prune") return "pruning local artifacts";
2845
+ return "saving sync state";
2846
+ }
2847
+
2848
+ //#endregion
2849
+ //#region src/cli/prompts.ts
2850
+ async function promptForToken() {
2851
+ const result = await password({
2852
+ message: "Enter a GitHub token (PAT) for ghfs:",
2853
+ validate: (value) => value?.trim().length ? void 0 : "Token is required"
2854
+ });
2855
+ if (isCancel(result)) {
2856
+ cancel("Token prompt cancelled");
2857
+ return;
2858
+ }
2859
+ return result.trim();
2860
+ }
2861
+ async function promptRepoChoice(gitCandidate, pkgCandidate) {
2862
+ const result = await select({
2863
+ message: "Detected conflicting GitHub repositories. Which one should ghfs use?",
2864
+ options: [{
2865
+ label: `${gitCandidate.repo} (${gitCandidate.detail})`,
2866
+ value: gitCandidate.repo
2867
+ }, {
2868
+ label: `${pkgCandidate.repo} (${pkgCandidate.detail})`,
2869
+ value: pkgCandidate.repo
2870
+ }],
2871
+ initialValue: gitCandidate.repo
2872
+ });
2873
+ if (isCancel(result)) {
2874
+ cancel("Repository selection cancelled");
2875
+ return;
2876
+ }
2877
+ return result;
2878
+ }
2879
+ function createExecutePrompts(options = {}) {
2880
+ return {
2881
+ selectOperations: (ops) => promptExecuteOperations(ops, options),
2882
+ confirmApply: confirmExecuteApply
2883
+ };
2884
+ }
2885
+ async function promptExecuteOperations(ops, options) {
2886
+ const selectedByDefault = ops.map((_, index) => index);
2887
+ const result = await multiselect({
2888
+ message: "Select operations to include",
2889
+ options: ops.map((op, index) => ({
2890
+ label: describeCliOperation(op, {
2891
+ tty: true,
2892
+ repo: options.repo
2893
+ }),
2894
+ value: index
2895
+ })),
2896
+ initialValues: selectedByDefault,
2897
+ required: false
2898
+ });
2899
+ if (isCancel(result)) {
2900
+ cancel("Operation selection cancelled");
2901
+ return;
2902
+ }
2903
+ return [...result];
2904
+ }
2905
+ async function confirmExecuteApply(count) {
2906
+ const result = await confirm({
2907
+ message: `Run ${count} ${count === 1 ? "operation" : "operations"} on GitHub?`,
2908
+ initialValue: false
2909
+ });
2910
+ if (isCancel(result)) {
2911
+ cancel("Execution cancelled");
2912
+ return;
2913
+ }
2914
+ return result;
2915
+ }
2916
+
2917
+ //#endregion
2918
+ //#region src/cli/commands/execute.ts
2919
+ const PLAN_PREVIEW_LIMIT = 20;
2920
+ const defaultDependencies = {
2921
+ createCliPrinter,
2922
+ resolveConfig,
2923
+ isTTY: () => Boolean(process.stdin.isTTY),
2924
+ resolveRepo,
2925
+ resolveAuthToken,
2926
+ executePendingChanges,
2927
+ appendExecutionResult,
2928
+ syncRepository,
2929
+ createExecutePrompts,
2930
+ promptForToken,
2931
+ promptRepoChoice
2932
+ };
2933
+ function registerExecuteCommand(cli) {
2934
+ cli.command("execute", "Execute operations from .ghfs/execute.yml").option("--repo <repo>", "GitHub repository in owner/name format").option("--file <file>", "Path to execute yaml file").option("--run", "Run mutations on GitHub").option("--non-interactive", "Disable interactive prompts").option("--continue-on-error", "Continue applying ops after a failure").action(withErrorHandling(async (options) => runExecuteCommand(options)));
2935
+ }
2936
+ async function runExecuteCommand(options, dependencies = defaultDependencies) {
2937
+ const printer = dependencies.createCliPrinter("execute");
2938
+ const config = await dependencies.resolveConfig();
2939
+ const storageDirAbsolute = getStorageDirAbsolute(config);
2940
+ const interactive = dependencies.isTTY() && !options.nonInteractive;
2941
+ const runMutations = Boolean(options.run);
2942
+ let resolvedRepo;
2943
+ let repoForRun = options.repo?.trim() || config.repo;
2944
+ let tokenForRun = "";
2945
+ if (runMutations) {
2946
+ resolvedRepo = await dependencies.resolveRepo({
2947
+ cwd: config.cwd,
2948
+ cliRepo: options.repo,
2949
+ configRepo: config.repo,
2950
+ interactive,
2951
+ selectRepoChoice: dependencies.promptRepoChoice
2952
+ });
2953
+ repoForRun = resolvedRepo.repo;
2954
+ printer.header(resolvedRepo.repo);
2955
+ tokenForRun = await dependencies.resolveAuthToken({
2956
+ token: config.auth.token,
2957
+ interactive,
2958
+ promptForToken: dependencies.promptForToken
2959
+ });
2960
+ } else if (repoForRun) printer.header(repoForRun);
2961
+ const prompts = dependencies.createExecutePrompts({ repo: repoForRun || void 0 });
2962
+ const executeFilePath = resolve(config.cwd, options.file ?? getExecuteFile(config));
2963
+ let result;
2964
+ try {
2965
+ result = await dependencies.executePendingChanges({
2966
+ config,
2967
+ repo: repoForRun || "(repo not resolved)",
2968
+ token: tokenForRun,
2969
+ executeFilePath,
2970
+ apply: runMutations,
2971
+ nonInteractive: Boolean(options.nonInteractive),
2972
+ continueOnError: Boolean(options.continueOnError),
2973
+ onPlan: runMutations ? void 0 : (ops) => printExecutionPlan(printer, ops, repoForRun || void 0),
2974
+ onWarning: (warning) => printer.warn(warning),
2975
+ prompts
2976
+ });
2977
+ } catch (error) {
2978
+ if (isExecuteCancelledError(error)) {
2979
+ printer.info("Execution cancelled.");
2980
+ return;
2981
+ }
2982
+ throw error;
2983
+ }
2984
+ if (!runMutations) {
2985
+ if (interactive && result.planned > 0) {
2986
+ if (await prompts.confirmApply(result.planned)) {
2987
+ resolvedRepo = await dependencies.resolveRepo({
2988
+ cwd: config.cwd,
2989
+ cliRepo: options.repo,
2990
+ configRepo: config.repo,
2991
+ interactive,
2992
+ selectRepoChoice: dependencies.promptRepoChoice
2993
+ });
2994
+ repoForRun = resolvedRepo.repo;
2995
+ tokenForRun = await dependencies.resolveAuthToken({
2996
+ token: config.auth.token,
2997
+ interactive,
2998
+ promptForToken: dependencies.promptForToken
2999
+ });
3000
+ const selectedIndexes = result.details.filter((detail) => detail.status === "planned").map((detail) => detail.op - 1).filter((index) => index >= 0);
3001
+ try {
3002
+ result = await dependencies.executePendingChanges({
3003
+ config,
3004
+ repo: repoForRun,
3005
+ token: tokenForRun,
3006
+ executeFilePath,
3007
+ apply: true,
3008
+ selectedIndexes,
3009
+ nonInteractive: true,
3010
+ continueOnError: Boolean(options.continueOnError),
3011
+ onWarning: (warning) => printer.warn(warning),
3012
+ prompts
3013
+ });
3014
+ } catch (error) {
3015
+ if (isExecuteCancelledError(error)) {
3016
+ printer.info("Execution cancelled.");
3017
+ return;
3018
+ }
3019
+ throw error;
3020
+ }
3021
+ await dependencies.appendExecutionResult(storageDirAbsolute, result);
3022
+ printFailedOperations(printer, result, resolvedRepo.repo);
3023
+ const affectedNumbers = [...new Set(result.details.filter((detail) => detail.status === "applied").map((detail) => detail.number))];
3024
+ if (affectedNumbers.length > 0) printPostRunSyncSummary(printer, await dependencies.syncRepository({
3025
+ config,
3026
+ repo: resolvedRepo.repo,
3027
+ token: tokenForRun,
3028
+ numbers: affectedNumbers,
3029
+ reporter: printer.createSyncReporter()
3030
+ }));
3031
+ printExecutionSummary(printer, result);
3032
+ if (result.failed > 0) process.exitCode = 1;
3033
+ return;
3034
+ }
3035
+ }
3036
+ printReportSummary(printer, result);
3037
+ return;
3038
+ }
3039
+ await dependencies.appendExecutionResult(storageDirAbsolute, result);
3040
+ printFailedOperations(printer, result, resolvedRepo?.repo || repoForRun || void 0);
3041
+ const affectedNumbers = [...new Set(result.details.filter((detail) => detail.status === "applied").map((detail) => detail.number))];
3042
+ if (affectedNumbers.length > 0) printPostRunSyncSummary(printer, await dependencies.syncRepository({
3043
+ config,
3044
+ repo: resolvedRepo?.repo || repoForRun || "",
3045
+ token: tokenForRun,
3046
+ numbers: affectedNumbers,
3047
+ reporter: printer.createSyncReporter()
3048
+ }));
3049
+ printExecutionSummary(printer, result);
3050
+ if (result.failed > 0) process.exitCode = 1;
3051
+ }
3052
+ function printExecutionPlan(printer, ops, repo) {
3053
+ const colorize = printer.mode === "rich";
3054
+ if (ops.length === 0) {
3055
+ printer.info("No operations planned.");
3056
+ return;
3057
+ }
3058
+ printer.info(`Planned ${countNoun(ops.length, "operation")}.`);
3059
+ const previewEntries = ops.slice(0, PLAN_PREVIEW_LIMIT).map((op, index) => [`#${index + 1}`, describeCliOperation(op, {
3060
+ tty: colorize,
3061
+ repo
3062
+ })]);
3063
+ printer.table("Planned operations", previewEntries, { dimKey: false });
3064
+ const remaining = ops.length - PLAN_PREVIEW_LIMIT;
3065
+ if (remaining > 0) printer.info(`...and ${countNoun(remaining, "more operation")}.`);
3066
+ }
3067
+ function printReportSummary(printer, result) {
3068
+ printer.success(`${countNoun(result.planned, "operation")} found.`);
3069
+ if (result.planned > 0) printer.info("Run `ghfs execute --run` to execute these operations.");
3070
+ }
3071
+ function printFailedOperations(printer, result, repo) {
3072
+ const colorize = printer.mode === "rich";
3073
+ const failed = result.details.filter((detail) => detail.status === "failed");
3074
+ if (failed.length === 0) return;
3075
+ printer.warn(`${countNoun(failed.length, "operation")} failed:`);
3076
+ printer.print(failed.map((detail) => `${detail.op}. ${describeCliOperation(detail, {
3077
+ tty: colorize,
3078
+ repo
3079
+ })}: ${detail.message}`));
3080
+ }
3081
+ function printPostRunSyncSummary(printer, summary) {
3082
+ const refreshed = summary.updatedIssues + summary.updatedPulls;
3083
+ printer.info(`Post-run sync refreshed ${countNoun(refreshed, "item")} in ${formatDuration(summary.durationMs)} (${summary.requestCount} requests).`);
3084
+ }
3085
+ function printExecutionSummary(printer, result) {
3086
+ const summary = `Execution summary: planned ${result.planned}, applied ${result.applied}, failed ${result.failed}.`;
3087
+ if (result.failed > 0) {
3088
+ printer.warn(summary);
3089
+ return;
3090
+ }
3091
+ printer.success(summary);
3092
+ }
3093
+
3094
+ //#endregion
3095
+ //#region src/sync/status.ts
3096
+ async function getStatusSummary(config) {
3097
+ const syncState = await loadSyncState(resolve(config.cwd, config.directory));
3098
+ const items = Object.values(syncState.items);
3099
+ const openCount = items.filter((item) => item.state === "open").length;
3100
+ const closedCount = items.filter((item) => item.state === "closed").length;
3101
+ const lastExecution = syncState.executions[0];
3102
+ const lastSyncRun = syncState.lastSyncRun;
3103
+ return {
3104
+ repo: syncState.repo,
3105
+ lastSyncedAt: syncState.lastSyncedAt,
3106
+ totalTracked: items.length,
3107
+ openCount,
3108
+ closedCount,
3109
+ executionRuns: syncState.executions.length,
3110
+ lastSyncRun: lastSyncRun ? {
3111
+ runId: lastSyncRun.runId,
3112
+ startedAt: lastSyncRun.startedAt,
3113
+ finishedAt: lastSyncRun.finishedAt,
3114
+ durationMs: lastSyncRun.durationMs,
3115
+ requestCount: lastSyncRun.requestCount,
3116
+ since: lastSyncRun.since,
3117
+ numbersCount: lastSyncRun.numbersCount,
3118
+ counters: {
3119
+ scanned: lastSyncRun.counters.scanned,
3120
+ selected: lastSyncRun.counters.selected,
3121
+ processed: lastSyncRun.counters.processed,
3122
+ skipped: lastSyncRun.counters.skipped,
3123
+ written: lastSyncRun.counters.written,
3124
+ moved: lastSyncRun.counters.moved,
3125
+ patchesWritten: lastSyncRun.counters.patchesWritten,
3126
+ patchesDeleted: lastSyncRun.counters.patchesDeleted
3127
+ },
3128
+ stages: {
3129
+ metadata: lastSyncRun.stages.metadata,
3130
+ pagination: lastSyncRun.stages.pagination,
3131
+ fetch: lastSyncRun.stages.fetch,
3132
+ materialize: lastSyncRun.stages.materialize,
3133
+ prune: lastSyncRun.stages.prune,
3134
+ save: lastSyncRun.stages.save
3135
+ }
3136
+ } : void 0,
3137
+ lastExecution: lastExecution ? {
3138
+ runId: lastExecution.runId,
3139
+ createdAt: lastExecution.createdAt,
3140
+ mode: lastExecution.mode,
3141
+ planned: lastExecution.planned,
3142
+ applied: lastExecution.applied,
3143
+ failed: lastExecution.failed
3144
+ } : void 0
3145
+ };
3146
+ }
3147
+
3148
+ //#endregion
3149
+ //#region src/cli/commands/status.ts
3150
+ function registerStatusCommand(cli) {
3151
+ cli.command("status", "Show local sync status").action(withErrorHandling(async () => {
3152
+ const printer = createCliPrinter("status");
3153
+ printer.start("Reading local sync state");
3154
+ const summary = await getStatusSummary(await resolveConfig());
3155
+ printer.table("Status", [
3156
+ ["repo", summary.repo ?? "(not resolved yet)"],
3157
+ ["last sync", summary.lastSyncedAt],
3158
+ ["tracked items", summary.totalTracked],
3159
+ ["open items", summary.openCount],
3160
+ ["closed items", summary.closedCount],
3161
+ ["execution runs", summary.executionRuns]
3162
+ ], { excludeZero: true });
3163
+ printer.done("");
3164
+ }));
3165
+ }
3166
+
3167
+ //#endregion
3168
+ //#region src/cli/summary.ts
3169
+ function printSyncSummaryTable(printer, summary, title) {
3170
+ printer.table(title, [
3171
+ ["total issues count", summary.totalIssues],
3172
+ ["total prs count", summary.totalPulls],
3173
+ ["issues updated", summary.updatedIssues],
3174
+ ["prs updated", summary.updatedPulls],
3175
+ ["local tracked issues", summary.trackedItems],
3176
+ ["github requests", summary.requestCount],
3177
+ ["duration", formatDuration(summary.durationMs)]
3178
+ ]);
3179
+ }
3180
+
3181
+ //#endregion
3182
+ //#region src/cli/commands/sync.ts
3183
+ function registerSyncCommand(cli) {
3184
+ setupSyncCommand(cli.command("sync", "Sync issues and pull requests to local mirror"));
3185
+ setupSyncCommand(cli.command("", "Sync issues and pull requests to local mirror"));
3186
+ }
3187
+ function setupSyncCommand(command) {
3188
+ 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) => {
3189
+ const printer = createCliPrinter("sync");
3190
+ const config = await resolveConfig();
3191
+ await ensureExecuteArtifacts(resolve(config.cwd, getExecuteFile(config)));
3192
+ const repo = await resolveRepo({
3193
+ cwd: config.cwd,
3194
+ cliRepo: options.repo,
3195
+ configRepo: config.repo,
3196
+ interactive: process.stdin.isTTY,
3197
+ selectRepoChoice: promptRepoChoice
3198
+ });
3199
+ printer.header(repo.repo);
3200
+ const token = await resolveAuthToken({
3201
+ token: config.auth.token,
3202
+ interactive: process.stdin.isTTY,
3203
+ promptForToken
3204
+ });
3205
+ printSyncSummaryTable(printer, await syncRepository({
3206
+ config,
3207
+ repo: repo.repo,
3208
+ token,
3209
+ since: options.since,
3210
+ full: Boolean(options.full),
3211
+ reporter: printer.createSyncReporter()
3212
+ }), "Summary");
3213
+ printer.done("Sync finished");
3214
+ }));
3215
+ }
3216
+
3217
+ //#endregion
3218
+ //#region src/cli/index.ts
3219
+ function createCli() {
3220
+ const cli = cac(CLI_NAME);
3221
+ registerSyncCommand(cli);
3222
+ registerExecuteCommand(cli);
3223
+ registerStatusCommand(cli);
3224
+ cli.help();
3225
+ cli.version(CLI_VERSION);
3226
+ return cli;
3227
+ }
3228
+ function runCli(argv = process.argv) {
3229
+ createCli().parse(argv);
3230
+ }
3231
+
3232
+ //#endregion
3233
+ //#region src/cli.ts
3234
+ runCli();
3235
+
3236
+ //#endregion
3237
+ export { };