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