@ghfs/cli 0.0.3 → 0.1.0
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 +56 -2323
- package/dist/dir.d.mts +4 -0
- package/dist/dir.mjs +5 -0
- package/dist/{factory-COZFMWsb.mjs → factory-DypZ8oi9.mjs} +27 -10
- package/dist/index.d.mts +2 -195
- package/dist/index.mjs +2 -4
- package/dist/provider-BFJGpjCs.d.mts +203 -0
- package/dist/server/index.d.mts +287 -0
- package/dist/server/index.mjs +2 -0
- package/dist/server-n1FLF8Xo.mjs +3331 -0
- package/dist/ui/200.html +1 -0
- package/dist/ui/404.html +1 -0
- package/dist/ui/_nuxt/BMIwt_GL.js +1 -0
- package/dist/ui/_nuxt/BXG6gk4R.js +1 -0
- package/dist/ui/_nuxt/DA9k4Rdg.js +69 -0
- package/dist/ui/_nuxt/DlAUqK2U.js +1 -0
- package/dist/ui/_nuxt/builds/latest.json +1 -0
- package/dist/ui/_nuxt/builds/meta/64045338-3c37-4c7d-a0df-5b21000f8b0a.json +1 -0
- package/dist/ui/_nuxt/entry.B5oa46-c.css +1 -0
- package/dist/ui/_nuxt/error-404.CktHaldI.css +1 -0
- package/dist/ui/_nuxt/error-500.DVdbZfrj.css +1 -0
- package/dist/ui/index.html +1 -0
- package/package.json +32 -14
package/dist/cli.mjs
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { i as formatDuration, o as formatTerminalLink$1, r as countNoun, s as formatValue } from "./factory-DypZ8oi9.mjs";
|
|
3
|
+
import { a as isExecuteCancelledError, c as GHFS_VERSION, d as pathExists, f as getExecuteFile, i as executePendingChanges, l as ensureExecuteArtifacts, m as resolveConfig, n as syncRepository, o as loadSyncState, p as getStorageDirAbsolute, r as appendExecutionResult, s as GHFS_NAME, t as createUiServer, u as ACTIONS_COLOR_HEX } from "./server-n1FLF8Xo.mjs";
|
|
3
4
|
import process from "node:process";
|
|
4
5
|
import { cac } from "cac";
|
|
5
|
-
import {
|
|
6
|
+
import { resolve } from "pathe";
|
|
6
7
|
import { execFile } from "node:child_process";
|
|
7
8
|
import { promisify } from "node:util";
|
|
8
|
-
import {
|
|
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";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
14
10
|
import c from "ansis";
|
|
15
11
|
import * as p from "@clack/prompts";
|
|
16
12
|
import { cancel, confirm, isCancel, multiselect, password, select } from "@clack/prompts";
|
|
17
|
-
|
|
18
13
|
//#region src/config/auth.ts
|
|
19
14
|
const execFileAsync$1 = promisify(execFile);
|
|
20
15
|
async function resolveAuthToken(options) {
|
|
@@ -44,142 +39,6 @@ async function readTokenFromEnv() {
|
|
|
44
39
|
if (value) return value;
|
|
45
40
|
}
|
|
46
41
|
}
|
|
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
42
|
//#endregion
|
|
184
43
|
//#region src/config/repo.ts
|
|
185
44
|
const execFileAsync = promisify(execFile);
|
|
@@ -318,2171 +177,6 @@ function prioritizeRemotes(remotes) {
|
|
|
318
177
|
function stripGitSuffix(name) {
|
|
319
178
|
return name.replace(/\.git$/, "");
|
|
320
179
|
}
|
|
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
180
|
//#endregion
|
|
2487
181
|
//#region src/cli/action-color.ts
|
|
2488
182
|
function colorizeAction(action, enabled = true) {
|
|
@@ -2536,7 +230,6 @@ function wrapTextValue(value) {
|
|
|
2536
230
|
if (normalized.length <= 48) return normalized;
|
|
2537
231
|
return `${normalized.slice(0, 45)}...`;
|
|
2538
232
|
}
|
|
2539
|
-
|
|
2540
233
|
//#endregion
|
|
2541
234
|
//#region src/cli/errors.ts
|
|
2542
235
|
function withErrorHandling(fn) {
|
|
@@ -2547,7 +240,6 @@ function withErrorHandling(fn) {
|
|
|
2547
240
|
});
|
|
2548
241
|
};
|
|
2549
242
|
}
|
|
2550
|
-
|
|
2551
243
|
//#endregion
|
|
2552
244
|
//#region src/cli/meta.ts
|
|
2553
245
|
const CLI_NAME = GHFS_NAME;
|
|
@@ -2565,7 +257,6 @@ function ASCII_HEADER(repo) {
|
|
|
2565
257
|
function toGitHubRepoUrl(repo) {
|
|
2566
258
|
return `https://github.com/${repo}`;
|
|
2567
259
|
}
|
|
2568
|
-
|
|
2569
260
|
//#endregion
|
|
2570
261
|
//#region src/cli/printer.ts
|
|
2571
262
|
function createCliPrinter(command, options = {}) {
|
|
@@ -2844,7 +535,6 @@ function describeStage(stage) {
|
|
|
2844
535
|
if (stage === "prune") return "pruning local artifacts";
|
|
2845
536
|
return "saving sync state";
|
|
2846
537
|
}
|
|
2847
|
-
|
|
2848
538
|
//#endregion
|
|
2849
539
|
//#region src/cli/prompts.ts
|
|
2850
540
|
async function promptForToken() {
|
|
@@ -2913,7 +603,6 @@ async function confirmExecuteApply(count) {
|
|
|
2913
603
|
}
|
|
2914
604
|
return result;
|
|
2915
605
|
}
|
|
2916
|
-
|
|
2917
606
|
//#endregion
|
|
2918
607
|
//#region src/cli/commands/execute.ts
|
|
2919
608
|
const PLAN_PREVIEW_LIMIT = 20;
|
|
@@ -3090,7 +779,6 @@ function printExecutionSummary(printer, result) {
|
|
|
3090
779
|
}
|
|
3091
780
|
printer.success(summary);
|
|
3092
781
|
}
|
|
3093
|
-
|
|
3094
782
|
//#endregion
|
|
3095
783
|
//#region src/sync/status.ts
|
|
3096
784
|
async function getStatusSummary(config) {
|
|
@@ -3144,7 +832,6 @@ async function getStatusSummary(config) {
|
|
|
3144
832
|
} : void 0
|
|
3145
833
|
};
|
|
3146
834
|
}
|
|
3147
|
-
|
|
3148
835
|
//#endregion
|
|
3149
836
|
//#region src/cli/commands/status.ts
|
|
3150
837
|
function registerStatusCommand(cli) {
|
|
@@ -3163,7 +850,6 @@ function registerStatusCommand(cli) {
|
|
|
3163
850
|
printer.done("");
|
|
3164
851
|
}));
|
|
3165
852
|
}
|
|
3166
|
-
|
|
3167
853
|
//#endregion
|
|
3168
854
|
//#region src/cli/summary.ts
|
|
3169
855
|
function printSyncSummaryTable(printer, summary, title) {
|
|
@@ -3177,7 +863,6 @@ function printSyncSummaryTable(printer, summary, title) {
|
|
|
3177
863
|
["duration", formatDuration(summary.durationMs)]
|
|
3178
864
|
]);
|
|
3179
865
|
}
|
|
3180
|
-
|
|
3181
866
|
//#endregion
|
|
3182
867
|
//#region src/cli/commands/sync.ts
|
|
3183
868
|
function registerSyncCommand(cli) {
|
|
@@ -3213,7 +898,56 @@ function setupSyncCommand(command) {
|
|
|
3213
898
|
printer.done("Sync finished");
|
|
3214
899
|
}));
|
|
3215
900
|
}
|
|
3216
|
-
|
|
901
|
+
//#endregion
|
|
902
|
+
//#region src/cli/commands/ui.ts
|
|
903
|
+
function registerUiCommand(cli) {
|
|
904
|
+
cli.command("ui", "Launch a local web UI for the mirror").option("--repo <repo>", "GitHub repository in owner/name format").option("--port <port>", "Port to listen on", { default: 7710 }).option("--host <host>", "Host to bind", { default: "127.0.0.1" }).option("--no-open", "Do not open the browser automatically").action(withErrorHandling(async (options) => {
|
|
905
|
+
const printer = createCliPrinter("ui");
|
|
906
|
+
const config = await resolveConfig();
|
|
907
|
+
await ensureExecuteArtifacts(resolve(config.cwd, getExecuteFile(config)));
|
|
908
|
+
const repo = await resolveRepo({
|
|
909
|
+
cwd: config.cwd,
|
|
910
|
+
cliRepo: options.repo,
|
|
911
|
+
configRepo: config.repo,
|
|
912
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
913
|
+
selectRepoChoice: promptRepoChoice
|
|
914
|
+
});
|
|
915
|
+
printer.header(repo.repo);
|
|
916
|
+
const initialToken = await resolveAuthToken({
|
|
917
|
+
token: config.auth.token,
|
|
918
|
+
interactive: false,
|
|
919
|
+
promptForToken
|
|
920
|
+
}).catch(() => "");
|
|
921
|
+
const port = typeof options.port === "number" ? options.port : Number(options.port ?? 7710);
|
|
922
|
+
const host = options.host ?? "127.0.0.1";
|
|
923
|
+
const server = await createUiServer({
|
|
924
|
+
config,
|
|
925
|
+
repo: repo.repo,
|
|
926
|
+
initialToken,
|
|
927
|
+
port,
|
|
928
|
+
host,
|
|
929
|
+
devMode: process.env.GHFS_UI_DEV === "1",
|
|
930
|
+
onRequestToken: async () => resolveAuthToken({
|
|
931
|
+
token: config.auth.token,
|
|
932
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
933
|
+
promptForToken
|
|
934
|
+
})
|
|
935
|
+
});
|
|
936
|
+
printer.info(`ghfs UI running at ${server.url}`);
|
|
937
|
+
if (!initialToken) printer.info("No GitHub token yet; sync/execute will prompt or fail until one is available.");
|
|
938
|
+
if (options.open !== false) {
|
|
939
|
+
const { default: open } = await import("open");
|
|
940
|
+
await open(server.url);
|
|
941
|
+
}
|
|
942
|
+
const shutdown = async () => {
|
|
943
|
+
await server.close().catch(() => {});
|
|
944
|
+
process.exit(0);
|
|
945
|
+
};
|
|
946
|
+
process.once("SIGINT", shutdown);
|
|
947
|
+
process.once("SIGTERM", shutdown);
|
|
948
|
+
await new Promise(() => {});
|
|
949
|
+
}));
|
|
950
|
+
}
|
|
3217
951
|
//#endregion
|
|
3218
952
|
//#region src/cli/index.ts
|
|
3219
953
|
function createCli() {
|
|
@@ -3221,6 +955,7 @@ function createCli() {
|
|
|
3221
955
|
registerSyncCommand(cli);
|
|
3222
956
|
registerExecuteCommand(cli);
|
|
3223
957
|
registerStatusCommand(cli);
|
|
958
|
+
registerUiCommand(cli);
|
|
3224
959
|
cli.help();
|
|
3225
960
|
cli.version(CLI_VERSION);
|
|
3226
961
|
return cli;
|
|
@@ -3228,10 +963,8 @@ function createCli() {
|
|
|
3228
963
|
function runCli(argv = process.argv) {
|
|
3229
964
|
createCli().parse(argv);
|
|
3230
965
|
}
|
|
3231
|
-
|
|
3232
966
|
//#endregion
|
|
3233
967
|
//#region src/cli.ts
|
|
3234
968
|
runCli();
|
|
3235
|
-
|
|
3236
969
|
//#endregion
|
|
3237
|
-
export {
|
|
970
|
+
export {};
|