swadhinbiswas

[email protected]
2026-01-22 15:46:57 +0600 committed NaN years ago
416b898
commit 416b898
19 files changed
+4784 -4069
▼cli/bin/och.ts
+293-241
4
4
* Complete Git hosting platform with stack-first PR workflows
5
5
*/
6
6
7
-import { Command } from "commander";
8
7
import chalk from "chalk";
8
+import { Command } from "commander";
9
9
import { simpleGit } from "simple-git";
10
-import gradient from "gradient-string";
11
10
12
11
// Import all command modules
13
-import { stackCommands } from "../src/commands/stack/index.js";
12
+import { apiCommands } from "../src/commands/api/index.js";
14
13
import { authCommands } from "../src/commands/auth.js";
15
-import { pushRepo, cloneRepo, createRepo, listRepos } from "../src/commands/repo.js";
16
-import { prCommands } from "../src/commands/pr/index.js";
17
-import { issueCommands } from "../src/commands/issue/index.js";
14
+import { automateCommand } from "../src/commands/automate/index.js";
15
+import { branchCommands } from "../src/commands/branch/index.js";
18
16
import { ciCommands } from "../src/commands/ci/index.js";
19
-import { queueCommands } from "../src/commands/queue/index.js";
20
-import { reviewCommands } from "../src/commands/review/index.js";
21
-import { metricsCommands } from "../src/commands/metrics/index.js";
22
17
import { configCommands } from "../src/commands/config/index.js";
23
-import { branchCommands } from "../src/commands/branch/index.js";
18
+import { inboxCommand } from "../src/commands/inbox/index.js";
19
+import { insightsCommand } from "../src/commands/insights/index.js";
20
+import { issueCommands } from "../src/commands/issue/index.js";
21
+import { metricsCommands } from "../src/commands/metrics/index.js";
22
+import { notifyCommand } from "../src/commands/notify/index.js";
23
+import { prCommands } from "../src/commands/pr/index.js";
24
+import { queueCommands } from "../src/commands/queue/index.js";
24
25
import { releaseCommands } from "../src/commands/release/index.js";
26
+import {
27
+ cloneRepo,
28
+ createRepo,
29
+ listRepos,
30
+ pushRepo,
31
+} from "../src/commands/repo.js";
32
+import { reviewCommands } from "../src/commands/review/index.js";
25
33
import { searchCommands } from "../src/commands/search/index.js";
26
34
import { secretCommands } from "../src/commands/secret/index.js";
27
35
import { sshKeyCommands } from "../src/commands/ssh-key/index.js";
28
-import { apiCommands } from "../src/commands/api/index.js";
29
-import { inboxCommand } from "../src/commands/inbox/index.js";
30
-import { notifyCommand } from "../src/commands/notify/index.js";
31
-import { automateCommand } from "../src/commands/automate/index.js";
32
-import { insightsCommand } from "../src/commands/insights/index.js";
36
+import { stackCommands } from "../src/commands/stack/index.js";
33
37
34
38
const git = simpleGit();
35
39
const program = new Command();
36
40
37
41
// Custom help formatter with Dracula theme
38
42
program.configureHelp({
39
- sortSubcommands: true,
40
- subcommandTerm: (cmd) => chalk.hex('#bd93f9')(cmd.name()), // Dracula purple
43
+ sortSubcommands: true,
44
+ subcommandTerm: (cmd) => chalk.hex("#bd93f9")(cmd.name()), // Dracula purple
41
45
});
42
46
43
47
// Add custom help banner with Dracula colors
44
-program.addHelpText('beforeAll', () => {
45
- const banner = `
48
+program.addHelpText("beforeAll", () => {
49
+ const banner = `
46
50
╔═══════════════════════════════════╗
47
51
║ OpenCodeHub CLI (OCH) ║
48
52
╚═══════════════════════════════════╝`;
49
53
50
- return chalk.hex('#ff79c6')(banner) + '\n' + // Dracula pink
51
- chalk.hex('#6272a4')(' Stack-first ') + // Dracula comment gray
52
- chalk.hex('#bd93f9').bold('PR workflows') + // Dracula purple
53
- chalk.hex('#6272a4')(' from your terminal\n'); // Dracula comment gray
54
+ return (
55
+ chalk.hex("#ff79c6")(banner) +
56
+ "\n" + // Dracula pink
57
+ chalk.hex("#6272a4")(" Stack-first ") + // Dracula comment gray
58
+ chalk.hex("#bd93f9").bold("PR workflows") + // Dracula purple
59
+ chalk.hex("#6272a4")(" from your terminal\n")
60
+ ); // Dracula comment gray
54
61
});
55
62
56
63
// Customize command help colors with Dracula theme
57
64
const originalHelp = program.helpInformation.bind(program);
58
65
program.helpInformation = function () {
59
- const help = originalHelp();
60
-
61
- return help
62
- // Usage line - Dracula cyan
63
- .replace(/^Usage: (.+)$/gm, (_, usage) =>
64
- chalk.hex('#8be9fd').bold('Usage: ') + chalk.hex('#f8f8f2')(usage)
65
- )
66
- // Section headers - Dracula green
67
- .replace(/^Options:$/gm, '\n' + chalk.hex('#50fa7b').bold('Options:'))
68
- .replace(/^Commands:$/gm, '\n' + chalk.hex('#50fa7b').bold('Commands:'))
69
- // Flags - Dracula purple
70
- .replace(/(-[a-zA-Z-]+)/g, chalk.hex('#bd93f9')('$1'))
71
- // Arguments - Dracula yellow
72
- .replace(/<([^>]+)>/g, chalk.hex('#f1fa8c')('<$1>'))
73
- // Optional params - Dracula comment
74
- .replace(/\[([^\]]+)\]/g, chalk.hex('#6272a4')('[$1]'))
75
- // Command names at start of lines - Dracula pink
76
- .replace(/^(\s+)([a-z-]+)(\s)/gm, (match, space, cmd, after) =>
77
- space + chalk.hex('#ff79c6')(cmd) + after
78
- );
66
+ const help = originalHelp();
67
+
68
+ return (
69
+ help
70
+ // Usage line - Dracula cyan
71
+ .replace(
72
+ /^Usage: (.+)$/gm,
73
+ (_, usage) =>
74
+ chalk.hex("#8be9fd").bold("Usage: ") + chalk.hex("#f8f8f2")(usage),
75
+ )
76
+ // Section headers - Dracula green
77
+ .replace(/^Options:$/gm, "\n" + chalk.hex("#50fa7b").bold("Options:"))
78
+ .replace(/^Commands:$/gm, "\n" + chalk.hex("#50fa7b").bold("Commands:"))
79
+ // Flags - Dracula purple
80
+ .replace(/(-[a-zA-Z-]+)/g, chalk.hex("#bd93f9")("$1"))
81
+ // Arguments - Dracula yellow
82
+ .replace(/<([^>]+)>/g, chalk.hex("#f1fa8c")("<$1>"))
83
+ // Optional params - Dracula comment
84
+ .replace(/\[([^\]]+)\]/g, chalk.hex("#6272a4")("[$1]"))
85
+ // Command names at start of lines - Dracula pink
86
+ .replace(
87
+ /^(\s+)([a-z-]+)(\s)/gm,
88
+ (match, space, cmd, after) => space + chalk.hex("#ff79c6")(cmd) + after,
89
+ )
90
+ );
79
91
};
80
92
81
93
program
82
- .name("och")
83
- .description(
84
- chalk.hex('#f8f8f2')("Complete Git hosting with ") + // Dracula foreground
85
- chalk.hex('#bd93f9').bold("stack-first PR workflows") // Dracula purple
86
- )
87
- .version("1.1.0");
94
+ .name("och")
95
+ .description(
96
+ chalk.hex("#f8f8f2")("Complete Git hosting with ") + // Dracula foreground
97
+ chalk.hex("#bd93f9").bold("stack-first PR workflows"), // Dracula purple
98
+ )
99
+ .version("1.1.0");
88
100
89
101
// ================================
90
102
// Core Commands
131
143
// ================================
132
144
133
145
const repoCommand = program
134
- .command("repo")
135
- .description("Repository management");
146
+ .command("repo")
147
+ .description("Repository management");
136
148
137
149
repoCommand
138
- .command("push")
139
- .description("Push local repository to OpenCodeHub")
140
- .option("-b, --branch <branch>", "Branch to push")
141
- .option("-f, --force", "Force push (overwrite remote)")
142
- .action(async (options) => {
143
- await pushRepo(options);
144
- });
150
+ .command("push")
151
+ .description("Push local repository to OpenCodeHub")
152
+ .option("-b, --branch <branch>", "Branch to push")
153
+ .option("-f, --force", "Force push (overwrite remote)")
154
+ .action(async (options) => {
155
+ try {
156
+ await pushRepo(options);
157
+ } catch {
158
+ process.exit(1);
159
+ }
160
+ });
145
161
146
162
repoCommand
147
- .command("clone <repo>")
148
- .description("Clone a repository from OpenCodeHub")
149
- .argument("[destination]", "Local directory to clone into")
150
- .action(async (repo, destination) => {
151
- await cloneRepo(repo, destination);
152
- });
163
+ .command("clone <repo>")
164
+ .description("Clone a repository from OpenCodeHub")
165
+ .argument("[destination]", "Local directory to clone into")
166
+ .action(async (repo, destination) => {
167
+ try {
168
+ await cloneRepo(repo, destination);
169
+ } catch {
170
+ process.exit(1);
171
+ }
172
+ });
153
173
154
174
repoCommand
155
- .command("create <name>")
156
- .description("Create a new repository on OpenCodeHub")
157
- .option("-d, --description <desc>", "Repository description")
158
- .option("-p, --private", "Make repository private")
159
- .option("--no-init", "Don't initialize with README")
160
- .action(async (name, options) => {
161
- await createRepo({
162
- name,
163
- description: options.description,
164
- visibility: options.private ? "private" : "public",
165
- init: options.init,
166
- });
167
- });
175
+ .command("create <name>")
176
+ .description("Create a new repository on OpenCodeHub")
177
+ .option("-d, --description <desc>", "Repository description")
178
+ .option("-p, --private", "Make repository private")
179
+ .option("--no-init", "Don't initialize with README")
180
+ .action(async (name, options) => {
181
+ try {
182
+ await createRepo({
183
+ name,
184
+ description: options.description,
185
+ visibility: options.private ? "private" : "public",
186
+ init: options.init,
187
+ });
188
+ } catch {
189
+ process.exit(1);
190
+ }
191
+ });
168
192
169
193
repoCommand
170
- .command("list")
171
- .alias("ls")
172
- .description("List your repositories")
173
- .action(async () => {
174
- await listRepos();
175
- });
194
+ .command("list")
195
+ .alias("ls")
196
+ .description("List your repositories")
197
+ .action(async () => {
198
+ try {
199
+ await listRepos();
200
+ } catch {
201
+ process.exit(1);
202
+ }
203
+ });
176
204
177
205
// ================================
178
206
// Branch & Config Commands
197
225
198
226
// och push (shorthand for repo push)
199
227
program
200
- .command("push")
201
- .description("Push to OpenCodeHub (shorthand for 'repo push')")
202
- .option("-b, --branch <branch>", "Branch to push")
203
- .option("-f, --force", "Force push")
204
- .action(async (options) => {
205
- await pushRepo(options);
206
- });
228
+ .command("push")
229
+ .description("Push to OpenCodeHub (shorthand for 'repo push')")
230
+ .option("-b, --branch <branch>", "Branch to push")
231
+ .option("-f, --force", "Force push")
232
+ .action(async (options) => {
233
+ try {
234
+ await pushRepo(options);
235
+ } catch {
236
+ process.exit(1);
237
+ }
238
+ });
207
239
208
240
// och init
209
241
program
210
- .command("init")
211
- .description("Initialize repository for OpenCodeHub")
212
- .option("-u, --url <url>", "OpenCodeHub server URL")
213
- .action(async (options) => {
214
- console.log(chalk.blue("Initializing repository for OpenCodeHub..."));
215
-
216
- const { saveConfig } = await import("../src/lib/config.js");
217
- if (options.url) {
218
- saveConfig({ serverUrl: options.url });
219
- console.log(chalk.green(`✓ Server URL set to ${options.url}`));
220
- }
221
-
222
- // Check if git repo exists
223
- const isRepo = await git.checkIsRepo();
224
- if (!isRepo) {
225
- console.log(chalk.yellow("Not a git repository. Initialize with 'git init' first."));
226
- return;
227
- }
228
-
229
- console.log(chalk.green("✓ Repository initialized for OpenCodeHub"));
230
- console.log(chalk.dim("\nNext steps:"));
231
- console.log(chalk.dim(" 1. Run 'och auth login' to authenticate"));
232
- console.log(chalk.dim(" 2. Run 'och repo create <name>' to create remote"));
233
- console.log(chalk.dim(" 3. Run 'och push' to push your code"));
234
- });
242
+ .command("init")
243
+ .description("Initialize repository for OpenCodeHub")
244
+ .option("-u, --url <url>", "OpenCodeHub server URL")
245
+ .action(async (options) => {
246
+ console.log(chalk.blue("Initializing repository for OpenCodeHub..."));
247
+
248
+ const { saveConfig } = await import("../src/lib/config.js");
249
+ if (options.url) {
250
+ saveConfig({ serverUrl: options.url });
251
+ console.log(chalk.green(`✓ Server URL set to ${options.url}`));
252
+ }
253
+
254
+ // Check if git repo exists
255
+ const isRepo = await git.checkIsRepo();
256
+ if (!isRepo) {
257
+ console.log(
258
+ chalk.yellow("Not a git repository. Initialize with 'git init' first."),
259
+ );
260
+ return;
261
+ }
262
+
263
+ console.log(chalk.green("✓ Repository initialized for OpenCodeHub"));
264
+ console.log(chalk.dim("\nNext steps:"));
265
+ console.log(chalk.dim(" 1. Run 'och auth login' to authenticate"));
266
+ console.log(
267
+ chalk.dim(" 2. Run 'och repo create <name>' to create remote"),
268
+ );
269
+ console.log(chalk.dim(" 3. Run 'och push' to push your code"));
270
+ });
235
271
236
272
// och sync (bidirectional sync)
237
273
program
238
- .command("sync")
239
- .description("Sync with remote (fetch + push)")
240
- .option("-f, --force", "Force push after sync")
241
- .action(async (options) => {
242
- const ora = (await import("ora")).default;
243
- const spinner = ora("Syncing with remote...").start();
244
-
245
- try {
246
- spinner.text = "Fetching from remote...";
247
- await git.fetch();
248
-
249
- spinner.text = "Pulling changes...";
250
- try {
251
- await git.pull(["--rebase"]);
252
- } catch (e) {
253
- // May fail if no upstream
254
- }
255
-
256
- spinner.text = "Pushing changes...";
257
- const pushArgs = options.force ? ["--force-with-lease"] : [];
258
- await git.push(pushArgs);
259
-
260
- spinner.succeed("Synced with remote");
261
- } catch (error) {
262
- spinner.fail("Sync failed");
263
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
264
- process.exit(1);
265
- }
266
- });
274
+ .command("sync")
275
+ .description("Sync with remote (fetch + push)")
276
+ .option("-f, --force", "Force push after sync")
277
+ .action(async (options) => {
278
+ const ora = (await import("ora")).default;
279
+ const spinner = ora("Syncing with remote...").start();
280
+
281
+ try {
282
+ spinner.text = "Fetching from remote...";
283
+ await git.fetch();
284
+
285
+ spinner.text = "Pulling changes...";
286
+ try {
287
+ await git.pull(["--rebase"]);
288
+ } catch (e) {
289
+ // May fail if no upstream
290
+ }
291
+
292
+ spinner.text = "Pushing changes...";
293
+ const pushArgs = options.force ? ["--force-with-lease"] : [];
294
+ await git.push(pushArgs);
295
+
296
+ spinner.succeed("Synced with remote");
297
+ } catch (error) {
298
+ spinner.fail("Sync failed");
299
+ console.error(
300
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
301
+ );
302
+ process.exit(1);
303
+ }
304
+ });
267
305
268
306
// och status (stack status)
269
307
program
270
- .command("status")
271
- .alias("st")
272
- .description("Show current stack and branch status")
273
- .action(async () => {
274
- try {
275
- const isRepo = await git.checkIsRepo();
276
- if (!isRepo) {
277
- console.log(chalk.yellow("Not a git repository. Run this command inside a git repository."));
278
- return;
279
- }
280
-
281
- const branches = await git.branchLocal();
282
- const currentBranch = branches.current;
283
- const stackBranches = branches.all.filter(b => b.startsWith("stack/"));
284
-
285
- console.log(chalk.bold("\n📊 Status\n"));
286
- console.log(`Current branch: ${chalk.cyan(currentBranch)}`);
287
-
288
- if (stackBranches.length > 0) {
289
- console.log(`\nStack branches: ${stackBranches.length}`);
290
- for (const branch of stackBranches) {
291
- const isCurrent = branch === currentBranch;
292
- const marker = isCurrent ? chalk.yellow("●") : chalk.dim("○");
293
- console.log(` ${marker} ${isCurrent ? chalk.bold(branch) : branch}`);
294
- }
295
- }
296
-
297
- // Show git status
298
- const status = await git.status();
299
- if (status.modified.length > 0 || status.not_added.length > 0 || status.staged.length > 0) {
300
- console.log(chalk.bold("\nChanges:"));
301
- if (status.staged.length > 0) {
302
- console.log(chalk.green(` Staged: ${status.staged.length} files`));
303
- }
304
- if (status.modified.length > 0) {
305
- console.log(chalk.yellow(` Modified: ${status.modified.length} files`));
306
- }
307
- if (status.not_added.length > 0) {
308
- console.log(chalk.red(` Untracked: ${status.not_added.length} files`));
309
- }
310
- } else {
311
- console.log(chalk.dim("\nWorking tree clean"));
312
- }
313
-
314
- console.log("");
315
- } catch (error) {
316
- console.error(chalk.red("\nError checking status:"));
317
- console.error(chalk.dim(error instanceof Error ? error.message : "Unknown error"));
308
+ .command("status")
309
+ .alias("st")
310
+ .description("Show current stack and branch status")
311
+ .action(async () => {
312
+ try {
313
+ const isRepo = await git.checkIsRepo();
314
+ if (!isRepo) {
315
+ console.log(
316
+ chalk.yellow(
317
+ "Not a git repository. Run this command inside a git repository.",
318
+ ),
319
+ );
320
+ return;
321
+ }
322
+
323
+ const branches = await git.branchLocal();
324
+ const currentBranch = branches.current;
325
+ const stackBranches = branches.all.filter((b) => b.startsWith("stack/"));
326
+
327
+ console.log(chalk.bold("\n📊 Status\n"));
328
+ console.log(`Current branch: ${chalk.cyan(currentBranch)}`);
329
+
330
+ if (stackBranches.length > 0) {
331
+ console.log(`\nStack branches: ${stackBranches.length}`);
332
+ for (const branch of stackBranches) {
333
+ const isCurrent = branch === currentBranch;
334
+ const marker = isCurrent ? chalk.yellow("●") : chalk.dim("○");
335
+ console.log(` ${marker} ${isCurrent ? chalk.bold(branch) : branch}`);
336
+ }
337
+ }
338
+
339
+ // Show git status
340
+ const status = await git.status();
341
+ if (
342
+ status.modified.length > 0 ||
343
+ status.not_added.length > 0 ||
344
+ status.staged.length > 0
345
+ ) {
346
+ console.log(chalk.bold("\nChanges:"));
347
+ if (status.staged.length > 0) {
348
+ console.log(chalk.green(` Staged: ${status.staged.length} files`));
349
+ }
350
+ if (status.modified.length > 0) {
351
+ console.log(
352
+ chalk.yellow(` Modified: ${status.modified.length} files`),
353
+ );
318
354
}
319
- });
355
+ if (status.not_added.length > 0) {
356
+ console.log(
357
+ chalk.red(` Untracked: ${status.not_added.length} files`),
358
+ );
359
+ }
360
+ } else {
361
+ console.log(chalk.dim("\nWorking tree clean"));
362
+ }
363
+
364
+ console.log("");
365
+ } catch (error) {
366
+ console.error(chalk.red("\nError checking status:"));
367
+ console.error(
368
+ chalk.dim(error instanceof Error ? error.message : "Unknown error"),
369
+ );
370
+ }
371
+ });
320
372
321
373
// och whoami (alias for auth status)
322
374
program
323
- .command("whoami")
324
- .description("Show current authenticated user")
325
- .action(async () => {
326
- const { getConfig } = await import("../src/lib/config.js");
327
- const config = getConfig();
328
-
329
- if (!config.token) {
330
- console.log(chalk.yellow("Not authenticated"));
331
- console.log(chalk.dim("Run 'och auth login' to authenticate"));
332
- return;
333
- }
334
-
335
- const ora = (await import("ora")).default;
336
- const spinner = ora("Checking authentication...").start();
337
-
338
- try {
339
- const response = await fetch(`${config.serverUrl}/api/user`, {
340
- headers: { "Authorization": `Bearer ${config.token}` },
341
- });
342
-
343
- if (response.ok) {
344
- const userData = await response.json();
345
- spinner.stop();
346
- console.log(userData.data?.username || "unknown");
347
- } else {
348
- spinner.fail("Token is invalid or expired");
349
- }
350
- } catch {
351
- spinner.fail("Could not connect to server");
352
- }
353
- });
375
+ .command("whoami")
376
+ .description("Show current authenticated user")
377
+ .action(async () => {
378
+ const { getConfig } = await import("../src/lib/config.js");
379
+ const config = getConfig();
380
+
381
+ if (!config.token) {
382
+ console.log(chalk.yellow("Not authenticated"));
383
+ console.log(chalk.dim("Run 'och auth login' to authenticate"));
384
+ return;
385
+ }
386
+
387
+ const ora = (await import("ora")).default;
388
+ const spinner = ora("Checking authentication...").start();
389
+
390
+ try {
391
+ const response = await fetch(`${config.serverUrl}/api/user`, {
392
+ headers: { Authorization: `Bearer ${config.token}` },
393
+ });
394
+
395
+ if (response.ok) {
396
+ const userData = await response.json();
397
+ spinner.stop();
398
+ console.log(userData.data?.username || "unknown");
399
+ } else {
400
+ spinner.fail("Token is invalid or expired");
401
+ }
402
+ } catch {
403
+ spinner.fail("Could not connect to server");
404
+ }
405
+ });
354
406
355
407
// ================================
356
408
// Completions Command
357
409
// ================================
358
410
359
411
program
360
- .command("completion")
361
- .description("Generate shell completions")
362
- .argument("<shell>", "Shell type (bash, zsh, fish)")
363
- .action((shell: string) => {
364
- const shells: Record<string, string> = {
365
- bash: `
412
+ .command("completion")
413
+ .description("Generate shell completions")
414
+ .argument("<shell>", "Shell type (bash, zsh, fish)")
415
+ .action((shell: string) => {
416
+ const shells: Record<string, string> = {
417
+ bash: `
366
418
# OpenCodeHub CLI bash completion
367
419
_och_completions() {
368
420
local cur="\${COMP_WORDS[COMP_CWORD]}"
371
423
}
372
424
complete -F _och_completions och
373
425
`,
374
- zsh: `
426
+ zsh: `
375
427
# OpenCodeHub CLI zsh completion
376
428
#compdef och
377
429
405
457
406
458
compdef _och och
407
459
`,
408
- fish: `
460
+ fish: `
409
461
# OpenCodeHub CLI fish completion
410
462
complete -c och -f
411
463
complete -c och -n "__fish_use_subcommand" -a "auth" -d "Manage authentication"
430
482
complete -c och -n "__fish_use_subcommand" -a "status" -d "Show status"
431
483
complete -c och -n "__fish_use_subcommand" -a "whoami" -d "Show current user"
432
484
`,
433
- };
485
+ };
434
486
435
- if (!shells[shell]) {
436
- console.error(chalk.red(`Unknown shell: ${shell}`));
437
- console.log(chalk.dim("Supported shells: bash, zsh, fish"));
438
- process.exit(1);
439
- }
487
+ if (!shells[shell]) {
488
+ console.error(chalk.red(`Unknown shell: ${shell}`));
489
+ console.log(chalk.dim("Supported shells: bash, zsh, fish"));
490
+ process.exit(1);
491
+ }
440
492
441
- console.log(shells[shell]);
442
- });
493
+ console.log(shells[shell]);
494
+ });
443
495
444
496
// ================================
445
497
// Parse and Execute
449
501
450
502
// Show help if no arguments
451
503
if (process.argv.length === 2) {
452
- program.help();
504
+ program.help();
453
505
}
454
506
▼cli/src/commands/api/index.ts
+163-133
3
3
* Make direct API requests to OpenCodeHub
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
7
+import { Command } from "commander";
8
+import { applyTlsConfig } from "../../lib/api.js";
8
9
import { getConfig } from "../../lib/config.js";
9
10
10
11
export const apiCommands = new Command("api")
11
- .description("Make direct API requests")
12
- .argument("<endpoint>", "API endpoint (e.g., /user or repos/owner/name)")
13
- .option("-X, --method <method>", "HTTP method", "GET")
14
- .option("-F, --field <field>", "Add field to request body (key=value)", (val, acc: string[]) => [...acc, val], [])
15
- .option("-f, --raw-field <field>", "Add raw field (no JSON parsing)", (val, acc: string[]) => [...acc, val], [])
16
- .option("-H, --header <header>", "Add header", (val, acc: string[]) => [...acc, val], [])
17
- .option("--input <file>", "Read body from file")
18
- .option("-q, --quiet", "Only output response body")
19
- .option("--jq <query>", "Filter JSON output (basic support)")
20
- .option("-i, --include", "Include response headers")
21
- .action(async (endpoint: string, options) => {
22
- const config = getConfig();
23
- if (!config.token) {
24
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
25
- process.exit(1);
26
- }
12
+ .description("Make direct API requests")
13
+ .argument("<endpoint>", "API endpoint (e.g., /user or repos/owner/name)")
14
+ .option("-X, --method <method>", "HTTP method", "GET")
15
+ .option(
16
+ "-F, --field <field>",
17
+ "Add field to request body (key=value)",
18
+ (val, acc: string[]) => [...acc, val],
19
+ [],
20
+ )
21
+ .option(
22
+ "-f, --raw-field <field>",
23
+ "Add raw field (no JSON parsing)",
24
+ (val, acc: string[]) => [...acc, val],
25
+ [],
26
+ )
27
+ .option(
28
+ "-H, --header <header>",
29
+ "Add header",
30
+ (val, acc: string[]) => [...acc, val],
31
+ [],
32
+ )
33
+ .option("--input <file>", "Read body from file")
34
+ .option("-q, --quiet", "Only output response body")
35
+ .option("--jq <query>", "Filter JSON output (basic support)")
36
+ .option("-i, --include", "Include response headers")
37
+ .action(async (endpoint: string, options) => {
38
+ const config = getConfig();
39
+ if (!config.token) {
40
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
41
+ process.exit(1);
42
+ }
27
43
28
- try {
29
- // Normalize endpoint
30
- if (!endpoint.startsWith("/")) {
31
- endpoint = "/" + endpoint;
32
- }
33
- if (!endpoint.startsWith("/api")) {
34
- endpoint = "/api" + endpoint;
35
- }
36
-
37
- // Build headers
38
- const headers: Record<string, string> = {
39
- "Authorization": `Bearer ${config.token}`,
40
- "Content-Type": "application/json",
41
- };
42
-
43
- for (const header of options.header) {
44
- const [key, ...valueParts] = header.split(":");
45
- headers[key.trim()] = valueParts.join(":").trim();
46
- }
47
-
48
- // Build body
49
- let body: any = undefined;
50
-
51
- if (options.input) {
52
- const fs = await import("fs");
53
- body = fs.readFileSync(options.input, "utf-8");
54
- } else if (options.field.length > 0 || options.rawField.length > 0) {
55
- body = {};
56
- for (const field of options.field) {
57
- const [key, ...valueParts] = field.split("=");
58
- const value = valueParts.join("=");
59
- // Try to parse as JSON
60
- try {
61
- body[key] = JSON.parse(value);
62
- } catch {
63
- body[key] = value;
64
- }
65
- }
66
- for (const field of options.rawField) {
67
- const [key, ...valueParts] = field.split("=");
68
- body[key] = valueParts.join("=");
69
- }
70
- body = JSON.stringify(body);
71
- }
72
-
73
- const url = `${config.serverUrl}${endpoint}`;
74
-
75
- if (!options.quiet) {
76
- console.error(chalk.dim(`${options.method} ${url}\n`));
77
- }
78
-
79
- const response = await fetch(url, {
80
- method: options.method,
81
- headers,
82
- body,
83
- });
84
-
85
- // Output headers if requested
86
- if (options.include) {
87
- console.log(chalk.cyan(`HTTP/1.1 ${response.status} ${response.statusText}`));
88
- response.headers.forEach((value, key) => {
89
- console.log(`${key}: ${value}`);
90
- });
91
- console.log("");
92
- }
93
-
94
- // Parse and output response
95
- const contentType = response.headers.get("content-type") || "";
96
-
97
- if (contentType.includes("application/json")) {
98
- const data = await response.json();
99
-
100
- if (options.jq) {
101
- // Basic jq support
102
- const output = applyJqFilter(data, options.jq);
103
- console.log(JSON.stringify(output, null, 2));
104
- } else {
105
- console.log(JSON.stringify(data, null, 2));
106
- }
107
- } else {
108
- const text = await response.text();
109
- console.log(text);
110
- }
111
-
112
- if (!response.ok && !options.quiet) {
113
- process.exit(1);
114
- }
115
- } catch (error) {
116
- console.error(chalk.red("API request failed"));
117
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
118
- process.exit(1);
44
+ if (!config.serverUrl) {
45
+ console.error(
46
+ chalk.red(
47
+ "Server URL not configured. Run 'och config set serverUrl <url>' or 'och auth login --url <url>'.",
48
+ ),
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ try {
54
+ // Normalize endpoint
55
+ if (!endpoint.startsWith("/")) {
56
+ endpoint = "/" + endpoint;
57
+ }
58
+ if (!endpoint.startsWith("/api")) {
59
+ endpoint = "/api" + endpoint;
60
+ }
61
+
62
+ // Build headers
63
+ const headers: Record<string, string> = {
64
+ Authorization: `Bearer ${config.token}`,
65
+ "Content-Type": "application/json",
66
+ };
67
+
68
+ for (const header of options.header) {
69
+ const [key, ...valueParts] = header.split(":");
70
+ headers[key.trim()] = valueParts.join(":").trim();
71
+ }
72
+
73
+ // Build body
74
+ let body: any = undefined;
75
+
76
+ if (options.input) {
77
+ const fs = await import("fs");
78
+ body = fs.readFileSync(options.input, "utf-8");
79
+ } else if (options.field.length > 0 || options.rawField.length > 0) {
80
+ body = {};
81
+ for (const field of options.field) {
82
+ const [key, ...valueParts] = field.split("=");
83
+ const value = valueParts.join("=");
84
+ // Try to parse as JSON
85
+ try {
86
+ body[key] = JSON.parse(value);
87
+ } catch {
88
+ body[key] = value;
89
+ }
90
+ }
91
+ for (const field of options.rawField) {
92
+ const [key, ...valueParts] = field.split("=");
93
+ body[key] = valueParts.join("=");
94
+ }
95
+ body = JSON.stringify(body);
96
+ }
97
+
98
+ const url = `${config.serverUrl}${endpoint}`;
99
+
100
+ if (!options.quiet) {
101
+ console.error(chalk.dim(`${options.method} ${url}\n`));
102
+ }
103
+
104
+ applyTlsConfig();
105
+ const response = await fetch(url, {
106
+ method: options.method,
107
+ headers,
108
+ body,
109
+ });
110
+
111
+ // Output headers if requested
112
+ if (options.include) {
113
+ console.log(
114
+ chalk.cyan(`HTTP/1.1 ${response.status} ${response.statusText}`),
115
+ );
116
+ response.headers.forEach((value, key) => {
117
+ console.log(`${key}: ${value}`);
118
+ });
119
+ console.log("");
120
+ }
121
+
122
+ // Parse and output response
123
+ const contentType = response.headers.get("content-type") || "";
124
+
125
+ if (contentType.includes("application/json")) {
126
+ const data = await response.json();
127
+
128
+ if (options.jq) {
129
+ // Basic jq support
130
+ const output = applyJqFilter(data, options.jq);
131
+ console.log(JSON.stringify(output, null, 2));
132
+ } else {
133
+ console.log(JSON.stringify(data, null, 2));
119
134
}
120
- });
135
+ } else {
136
+ const text = await response.text();
137
+ console.log(text);
138
+ }
139
+
140
+ if (!response.ok && !options.quiet) {
141
+ process.exit(1);
142
+ }
143
+ } catch (error) {
144
+ console.error(chalk.red("API request failed"));
145
+ console.error(
146
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
147
+ );
148
+ process.exit(1);
149
+ }
150
+ });
121
151
122
152
/**
123
153
* Basic jq-like filter support
124
154
*/
125
155
function applyJqFilter(data: any, query: string): any {
126
- if (query === ".") return data;
127
-
128
- const parts = query.split(".").filter(Boolean);
129
- let result = data;
156
+ if (query === ".") return data;
130
157
131
- for (const part of parts) {
132
- // Handle array index
133
- const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
134
- if (arrayMatch) {
135
- result = result[arrayMatch[1]][parseInt(arrayMatch[2])];
136
- }
137
- // Handle array access []
138
- else if (part === "[]") {
139
- // Already handled - just continue
140
- }
141
- // Handle property access
142
- else {
143
- if (Array.isArray(result)) {
144
- result = result.map(item => item[part]);
145
- } else {
146
- result = result[part];
147
- }
148
- }
158
+ const parts = query.split(".").filter(Boolean);
159
+ let result = data;
149
160
150
- if (result === undefined) break;
161
+ for (const part of parts) {
162
+ // Handle array index
163
+ const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
164
+ if (arrayMatch) {
165
+ result = result[arrayMatch[1]][parseInt(arrayMatch[2])];
166
+ }
167
+ // Handle array access []
168
+ else if (part === "[]") {
169
+ // Already handled - just continue
151
170
}
171
+ // Handle property access
172
+ else {
173
+ if (Array.isArray(result)) {
174
+ result = result.map((item) => item[part]);
175
+ } else {
176
+ result = result[part];
177
+ }
178
+ }
179
+
180
+ if (result === undefined) break;
181
+ }
152
182
153
- return result;
183
+ return result;
154
184
}
155
185
156
186
export default apiCommands;
157
187
▼cli/src/commands/auth.ts
+200-145
3
3
* GitHub-style authentication for OpenCodeHub CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
7
+import { Command } from "commander";
9
8
import inquirer from "inquirer";
9
+import ora from "ora";
10
+import { applyTlsConfig } from "../lib/api.js";
10
11
import { getConfig, saveConfig } from "../lib/config.js";
11
-import { apiCall } from "../lib/api.js";
12
12
13
-export const authCommands = new Command("auth")
14
- .description("Authentication commands");
13
+export const authCommands = new Command("auth").description(
14
+ "Authentication commands",
15
+);
15
16
16
17
// Login - GitHub style
17
18
authCommands
18
- .command("login")
19
- .description("Login to OpenCodeHub")
20
- .option("-u, --url <url>", "OpenCodeHub server URL", "http://localhost:3000")
21
- .option("--with-token", "Authenticate using a Personal Access Token")
22
- .option("-t, --token <token>", "Personal access token (for non-interactive use)")
23
- .action(async (options) => {
24
- console.log(chalk.blue("\n🔐 OpenCodeHub Login\n"));
25
-
26
- try {
27
- let token: string;
28
- const serverUrl = options.url;
29
-
30
- if (options.token) {
31
- // Token provided directly via CLI
32
- token = options.token;
33
- } else if (options.withToken) {
34
- // Interactive token entry (GitHub style: gh auth login --with-token)
35
- console.log(chalk.dim("Tip: You can create a Personal Access Token at:"));
36
- console.log(chalk.cyan(`${serverUrl}/settings/tokens\n`));
37
-
38
- const answers = await inquirer.prompt([
39
- {
40
- type: "password",
41
- name: "token",
42
- message: "Paste your token:",
43
- mask: "*",
44
- },
45
- ]);
46
- token = answers.token;
47
- } else {
48
- // Default: Open browser for token creation (like gh auth login)
49
- console.log(chalk.yellow("! No token provided."));
50
- console.log(chalk.dim("\nTo authenticate, you need a Personal Access Token."));
51
- console.log(chalk.dim("Create one at: ") + chalk.cyan(`${serverUrl}/settings/tokens`));
52
- console.log();
53
-
54
- const { useToken } = await inquirer.prompt([
55
- {
56
- type: "confirm",
57
- name: "useToken",
58
- message: "Do you have a token to paste?",
59
- default: true,
60
- },
61
- ]);
62
-
63
- if (!useToken) {
64
- console.log(chalk.dim("\nRun ") + chalk.cyan("och auth login --with-token") + chalk.dim(" after creating a token."));
65
- process.exit(0);
66
- }
67
-
68
- const answers = await inquirer.prompt([
69
- {
70
- type: "password",
71
- name: "token",
72
- message: "Paste your token:",
73
- mask: "*",
74
- },
75
- ]);
76
- token = answers.token;
77
- }
78
-
79
- // Validate token
80
- const spinner = ora("Validating token...").start();
81
-
82
- if (!token.startsWith("och_")) {
83
- spinner.fail("Invalid token format");
84
- console.error(chalk.red("Token should start with 'och_'"));
85
- process.exit(1);
86
- }
87
-
88
- // Test the token by getting user info
89
- const response = await fetch(`${serverUrl}/api/user`, {
90
- headers: {
91
- "Authorization": `Bearer ${token}`,
92
- },
93
- });
94
-
95
- if (!response.ok) {
96
- spinner.fail("Invalid or expired token");
97
- process.exit(1);
98
- }
99
-
100
- const userData = await response.json();
101
- const username = userData.data?.username || "unknown";
102
-
103
- spinner.succeed(`Authenticated as ${chalk.green(username)}`);
104
-
105
- // Save config
106
- saveConfig({
107
- serverUrl,
108
- token,
109
- username,
110
- });
111
-
112
- console.log(chalk.green("\n✓ Logged in to " + serverUrl));
113
- console.log(chalk.dim(" Token saved to ~/.opencodehub/config.json"));
114
- } catch (error) {
115
- console.error(chalk.red("Login failed"));
116
- console.error(chalk.dim(error instanceof Error ? error.message : "Unknown error"));
117
- process.exit(1);
19
+ .command("login")
20
+ .description("Login to OpenCodeHub")
21
+ .option("-u, --url <url>", "OpenCodeHub server URL")
22
+ .option("--ca-file <path>", "Path to custom CA bundle (self-signed TLS)")
23
+ .option("--insecure", "Disable TLS certificate verification")
24
+ .option("--with-token", "Authenticate using a Personal Access Token")
25
+ .option(
26
+ "-t, --token <token>",
27
+ "Personal access token (for non-interactive use)",
28
+ )
29
+ .action(async (options) => {
30
+ console.log(chalk.blue("\n🔐 OpenCodeHub Login\n"));
31
+
32
+ try {
33
+ const existingConfig = getConfig();
34
+ let token: string;
35
+ let serverUrl = options.url || existingConfig.serverUrl;
36
+
37
+ if (!serverUrl) {
38
+ const answers = await inquirer.prompt([
39
+ {
40
+ type: "input",
41
+ name: "serverUrl",
42
+ message: "OpenCodeHub server URL:",
43
+ validate: (input) => input.length > 0 || "Server URL is required",
44
+ },
45
+ ]);
46
+ serverUrl = answers.serverUrl;
47
+ }
48
+
49
+ if (options.token) {
50
+ // Token provided directly via CLI
51
+ token = options.token;
52
+ } else if (options.withToken) {
53
+ // Interactive token entry (GitHub style: gh auth login --with-token)
54
+ console.log(
55
+ chalk.dim("Tip: You can create a Personal Access Token at:"),
56
+ );
57
+ console.log(chalk.cyan(`${serverUrl}/settings/tokens\n`));
58
+
59
+ const answers = await inquirer.prompt([
60
+ {
61
+ type: "password",
62
+ name: "token",
63
+ message: "Paste your token:",
64
+ mask: "*",
65
+ },
66
+ ]);
67
+ token = answers.token;
68
+ } else {
69
+ // Default: Open browser for token creation (like gh auth login)
70
+ console.log(chalk.yellow("! No token provided."));
71
+ console.log(
72
+ chalk.dim("\nTo authenticate, you need a Personal Access Token."),
73
+ );
74
+ console.log(
75
+ chalk.dim("Create one at: ") +
76
+ chalk.cyan(`${serverUrl}/settings/tokens`),
77
+ );
78
+ console.log();
79
+
80
+ const { useToken } = await inquirer.prompt([
81
+ {
82
+ type: "confirm",
83
+ name: "useToken",
84
+ message: "Do you have a token to paste?",
85
+ default: true,
86
+ },
87
+ ]);
88
+
89
+ if (!useToken) {
90
+ console.log(
91
+ chalk.dim("\nRun ") +
92
+ chalk.cyan("och auth login --with-token") +
93
+ chalk.dim(" after creating a token."),
94
+ );
95
+ process.exit(0);
118
96
}
119
- });
97
+
98
+ const answers = await inquirer.prompt([
99
+ {
100
+ type: "password",
101
+ name: "token",
102
+ message: "Paste your token:",
103
+ mask: "*",
104
+ },
105
+ ]);
106
+ token = answers.token;
107
+ }
108
+
109
+ // Validate token
110
+ const spinner = ora("Validating token...").start();
111
+
112
+ if (!token.startsWith("och_")) {
113
+ spinner.fail("Invalid token format");
114
+ console.error(chalk.red("Token should start with 'och_'"));
115
+ process.exit(1);
116
+ }
117
+
118
+ // Test the token by getting user info
119
+ saveConfig({
120
+ serverUrl,
121
+ caFile: options.caFile ?? existingConfig.caFile,
122
+ insecure: options.insecure ?? existingConfig.insecure,
123
+ });
124
+
125
+ applyTlsConfig();
126
+
127
+ const response = await fetch(`${serverUrl}/api/user`, {
128
+ headers: {
129
+ Authorization: `Bearer ${token}`,
130
+ },
131
+ });
132
+
133
+ if (!response.ok) {
134
+ spinner.fail("Invalid or expired token");
135
+ process.exit(1);
136
+ }
137
+
138
+ const userData = await response.json();
139
+ const username = userData.data?.username || "unknown";
140
+
141
+ spinner.succeed(`Authenticated as ${chalk.green(username)}`);
142
+
143
+ // Save config
144
+ saveConfig({
145
+ serverUrl,
146
+ token,
147
+ username,
148
+ caFile: options.caFile ?? existingConfig.caFile,
149
+ insecure: options.insecure ?? existingConfig.insecure,
150
+ });
151
+
152
+ console.log(chalk.green("\n✓ Logged in to " + serverUrl));
153
+ console.log(chalk.dim(" Token saved to ~/.opencodehub/config.json"));
154
+ } catch (error) {
155
+ console.error(chalk.red("Login failed"));
156
+ console.error(
157
+ chalk.dim(error instanceof Error ? error.message : "Unknown error"),
158
+ );
159
+ process.exit(1);
160
+ }
161
+ });
120
162
121
163
// Logout
122
164
authCommands
123
- .command("logout")
124
- .description("Logout from OpenCodeHub")
125
- .action(async () => {
126
- saveConfig({ serverUrl: "", token: "" });
127
- console.log(chalk.green("✓ Logged out successfully"));
128
- });
165
+ .command("logout")
166
+ .description("Logout from OpenCodeHub")
167
+ .action(async () => {
168
+ saveConfig({ serverUrl: "", token: "" });
169
+ console.log(chalk.green("✓ Logged out successfully"));
170
+ });
129
171
130
172
// Status
131
173
authCommands
132
- .command("status")
133
- .description("Show authentication status")
134
- .action(async () => {
135
- const config = getConfig();
136
-
137
- if (config.token) {
138
- const spinner = ora("Checking authentication...").start();
139
-
140
- try {
141
- const response = await fetch(`${config.serverUrl}/api/user`, {
142
- headers: {
143
- "Authorization": `Bearer ${config.token}`,
144
- },
145
- });
146
-
147
- if (response.ok) {
148
- const userData = await response.json();
149
- spinner.succeed("Authenticated");
150
- console.log(chalk.dim(" User: ") + chalk.cyan(userData.data?.username || "unknown"));
151
- console.log(chalk.dim(" Server: ") + config.serverUrl);
152
- console.log(chalk.dim(" Token: ") + config.token.slice(0, 12) + "...");
153
- } else {
154
- spinner.fail("Token is invalid or expired");
155
- console.log(chalk.dim(" Run ") + chalk.cyan("och auth login") + chalk.dim(" to re-authenticate"));
156
- }
157
- } catch (error) {
158
- spinner.fail("Could not connect to server");
159
- console.log(chalk.dim(" Server: ") + config.serverUrl);
160
- }
174
+ .command("status")
175
+ .description("Show authentication status")
176
+ .action(async () => {
177
+ const config = getConfig();
178
+
179
+ if (config.token) {
180
+ const spinner = ora("Checking authentication...").start();
181
+
182
+ try {
183
+ applyTlsConfig();
184
+ const response = await fetch(`${config.serverUrl}/api/user`, {
185
+ headers: {
186
+ Authorization: `Bearer ${config.token}`,
187
+ },
188
+ });
189
+
190
+ if (response.ok) {
191
+ const userData = await response.json();
192
+ spinner.succeed("Authenticated");
193
+ console.log(
194
+ chalk.dim(" User: ") +
195
+ chalk.cyan(userData.data?.username || "unknown"),
196
+ );
197
+ console.log(chalk.dim(" Server: ") + config.serverUrl);
198
+ console.log(
199
+ chalk.dim(" Token: ") + config.token.slice(0, 12) + "...",
200
+ );
161
201
} else {
162
- console.log(chalk.yellow("✗ Not authenticated"));
163
- console.log(chalk.dim(" Run ") + chalk.cyan("och auth login") + chalk.dim(" to authenticate"));
202
+ spinner.fail("Token is invalid or expired");
203
+ console.log(
204
+ chalk.dim(" Run ") +
205
+ chalk.cyan("och auth login") +
206
+ chalk.dim(" to re-authenticate"),
207
+ );
164
208
}
165
- });
209
+ } catch (error) {
210
+ spinner.fail("Could not connect to server");
211
+ console.log(chalk.dim(" Server: ") + config.serverUrl);
212
+ }
213
+ } else {
214
+ console.log(chalk.yellow("✗ Not authenticated"));
215
+ console.log(
216
+ chalk.dim(" Run ") +
217
+ chalk.cyan("och auth login") +
218
+ chalk.dim(" to authenticate"),
219
+ );
220
+ }
221
+ });
166
222
167
223
export default authCommands;
168
-
169
224
▼cli/src/commands/automate/index.ts
+293-263
3
3
* Manage workflow automation rules
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
9
7
import Table from "cli-table3";
8
+import { Command } from "commander";
10
9
import inquirer from "inquirer";
11
-import { getWithAuth, postWithAuth, patchWithAuth, deleteWithAuth } from "../../lib/api.js";
12
-import { getConfig } from "../../lib/config.js";
10
+import ora from "ora";
13
11
import simpleGit from "simple-git";
12
+import {
13
+ deleteWithAuth,
14
+ getWithAuth,
15
+ patchWithAuth,
16
+ postWithAuth,
17
+} from "../../lib/api.js";
18
+import { getConfig } from "../../lib/config.js";
19
+import { getRepoInfoFromGit } from "../../lib/git.js";
14
20
15
21
const git = simpleGit();
16
22
17
-export const automateCommand = new Command("automate")
18
- .description("Manage workflow automation rules");
23
+export const automateCommand = new Command("automate").description(
24
+ "Manage workflow automation rules",
25
+);
19
26
20
27
// Helper to get repo info
21
28
async function getRepoInfo() {
22
- try {
23
- const remotes = await git.getRemotes(true);
24
- const origin = remotes.find((r) => r.name === "origin");
25
- if (!origin?.refs?.fetch) return null;
26
-
27
- const url = origin.refs.fetch;
28
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
29
- if (!match) return null;
30
-
31
- return { owner: match[1], name: match[2] };
32
- } catch {
33
- return null;
34
- }
29
+ const repoInfo = await getRepoInfoFromGit(git);
30
+ return repoInfo ? { owner: repoInfo.owner, name: repoInfo.repo } : null;
35
31
}
36
32
37
33
// List automation rules
38
34
automateCommand
39
- .command("list")
40
- .description("List automation rules for current repository")
41
- .option("--json", "Output as JSON")
42
- .action(async (options) => {
43
- const spinner = ora("Loading automation rules...").start();
44
-
45
- try {
46
- const config = getConfig();
47
- if (!config.token) {
48
- spinner.fail("Not authenticated. Run 'och auth login' first.");
49
- process.exit(1);
50
- }
51
-
52
- const repoInfo = await getRepoInfo();
53
- if (!repoInfo) {
54
- spinner.fail("Not in a git repository with an origin remote");
55
- process.exit(1);
56
- }
57
-
58
- // Get repository ID first
59
- const repoRes = await getWithAuth(`/api/repos/${repoInfo.owner}/${repoInfo.name}`);
60
- if (!repoRes.repository?.id) {
61
- spinner.fail("Repository not found on OpenCodeHub");
62
- process.exit(1);
63
- }
64
-
65
- const res = await getWithAuth(`/api/automation-rules?repositoryId=${repoRes.repository.id}`);
66
- const rules = res.rules || [];
67
-
68
- spinner.stop();
69
-
70
- if (options.json) {
71
- console.log(JSON.stringify(rules, null, 2));
72
- return;
73
- }
74
-
75
- if (rules.length === 0) {
76
- console.log(chalk.gray("\n ⚡ No automation rules. Create one with: och automate create\n"));
77
- return;
78
- }
79
-
80
- console.log(chalk.bold(`\n⚡ Automation Rules for ${repoInfo.owner}/${repoInfo.name}\n`));
81
-
82
- const table = new Table({
83
- head: [
84
- chalk.gray("Name"),
85
- chalk.gray("Trigger"),
86
- chalk.gray("Status"),
87
- chalk.gray("Runs"),
88
- ],
89
- });
90
-
91
- for (const rule of rules) {
92
- table.push([
93
- rule.name,
94
- formatTrigger(rule.trigger),
95
- rule.isEnabled ? chalk.green("enabled") : chalk.gray("disabled"),
96
- String(rule.runCount || 0),
97
- ]);
98
- }
99
-
100
- console.log(table.toString());
101
- console.log();
102
- } catch (error: any) {
103
- spinner.fail(`Failed: ${error.message}`);
104
- process.exit(1);
105
- }
106
- });
35
+ .command("list")
36
+ .description("List automation rules for current repository")
37
+ .option("--json", "Output as JSON")
38
+ .action(async (options) => {
39
+ const spinner = ora("Loading automation rules...").start();
40
+
41
+ try {
42
+ const config = getConfig();
43
+ if (!config.token) {
44
+ spinner.fail("Not authenticated. Run 'och auth login' first.");
45
+ process.exit(1);
46
+ }
47
+
48
+ const repoInfo = await getRepoInfo();
49
+ if (!repoInfo) {
50
+ spinner.fail("Not in a git repository with an origin remote");
51
+ process.exit(1);
52
+ }
53
+
54
+ // Get repository ID first
55
+ const repoRes = await getWithAuth(
56
+ `/api/repos/${repoInfo.owner}/${repoInfo.name}`,
57
+ );
58
+ if (!repoRes.repository?.id) {
59
+ spinner.fail("Repository not found on OpenCodeHub");
60
+ process.exit(1);
61
+ }
62
+
63
+ const res = await getWithAuth(
64
+ `/api/automation-rules?repositoryId=${repoRes.repository.id}`,
65
+ );
66
+ const rules = res.rules || [];
67
+
68
+ spinner.stop();
69
+
70
+ if (options.json) {
71
+ console.log(JSON.stringify(rules, null, 2));
72
+ return;
73
+ }
74
+
75
+ if (rules.length === 0) {
76
+ console.log(
77
+ chalk.gray(
78
+ "\n ⚡ No automation rules. Create one with: och automate create\n",
79
+ ),
80
+ );
81
+ return;
82
+ }
83
+
84
+ console.log(
85
+ chalk.bold(
86
+ `\n⚡ Automation Rules for ${repoInfo.owner}/${repoInfo.name}\n`,
87
+ ),
88
+ );
89
+
90
+ const table = new Table({
91
+ head: [
92
+ chalk.gray("Name"),
93
+ chalk.gray("Trigger"),
94
+ chalk.gray("Status"),
95
+ chalk.gray("Runs"),
96
+ ],
97
+ });
98
+
99
+ for (const rule of rules) {
100
+ table.push([
101
+ rule.name,
102
+ formatTrigger(rule.trigger),
103
+ rule.isEnabled ? chalk.green("enabled") : chalk.gray("disabled"),
104
+ String(rule.runCount || 0),
105
+ ]);
106
+ }
107
+
108
+ console.log(table.toString());
109
+ console.log();
110
+ } catch (error: any) {
111
+ spinner.fail(`Failed: ${error.message}`);
112
+ process.exit(1);
113
+ }
114
+ });
107
115
108
116
// Create automation rule
109
117
automateCommand
110
- .command("create")
111
- .description("Create a new automation rule")
112
- .action(async () => {
113
- try {
114
- const config = getConfig();
115
- if (!config.token) {
116
- console.log(chalk.red("Not authenticated. Run 'och auth login' first."));
117
- process.exit(1);
118
- }
119
-
120
- const repoInfo = await getRepoInfo();
121
- if (!repoInfo) {
122
- console.log(chalk.red("Not in a git repository"));
123
- process.exit(1);
124
- }
125
-
126
- // Get repository ID
127
- const repoRes = await getWithAuth(`/api/repos/${repoInfo.owner}/${repoInfo.name}`);
128
- if (!repoRes.repository?.id) {
129
- console.log(chalk.red("Repository not found on OpenCodeHub"));
130
- process.exit(1);
131
- }
132
-
133
- console.log(chalk.bold(`\n⚡ Create Automation Rule for ${repoInfo.owner}/${repoInfo.name}\n`));
134
-
135
- const answers = await inquirer.prompt([
136
- {
137
- type: "input",
138
- name: "name",
139
- message: "Rule name:",
140
- validate: (input) => input.length > 0 || "Name required",
141
- },
142
- {
143
- type: "list",
144
- name: "trigger",
145
- message: "When should this run?",
146
- choices: [
147
- { name: "PR is opened", value: "pr_opened" },
148
- { name: "PR is approved", value: "pr_approved" },
149
- { name: "CI passes", value: "ci_passed" },
150
- { name: "CI fails", value: "ci_failed" },
151
- { name: "Label is added", value: "label_added" },
152
- { name: "Review is requested", value: "pr_review_requested" },
153
- ],
154
- },
155
- {
156
- type: "checkbox",
157
- name: "actions",
158
- message: "What actions to take?",
159
- choices: [
160
- { name: "Add label", value: "add_label" },
161
- { name: "Assign reviewer", value: "assign_reviewer" },
162
- { name: "Add to merge queue", value: "add_to_merge_queue" },
163
- { name: "Trigger AI review", value: "trigger_ai_review" },
164
- { name: "Add comment", value: "add_comment" },
165
- ],
166
- validate: (input) => input.length > 0 || "Select at least one action",
167
- },
168
- ]);
169
-
170
- // Build actions array with params
171
- const actions = [];
172
- for (const action of answers.actions) {
173
- const actionObj: any = { type: action, params: {} };
174
-
175
- if (action === "add_label") {
176
- const { label } = await inquirer.prompt([
177
- { type: "input", name: "label", message: "Label name:" },
178
- ]);
179
- actionObj.params.label = label;
180
- } else if (action === "assign_reviewer") {
181
- const { reviewer } = await inquirer.prompt([
182
- { type: "input", name: "reviewer", message: "Reviewer username (@codeowners for auto):" },
183
- ]);
184
- actionObj.params.assignee = reviewer;
185
- } else if (action === "add_comment") {
186
- const { comment } = await inquirer.prompt([
187
- { type: "input", name: "comment", message: "Comment text:" },
188
- ]);
189
- actionObj.params.body = comment;
190
- }
191
-
192
- actions.push(actionObj);
193
- }
194
-
195
- const spinner = ora("Creating rule...").start();
196
-
197
- await postWithAuth("/api/automation-rules", {
198
- repositoryId: repoRes.repository.id,
199
- name: answers.name,
200
- trigger: answers.trigger,
201
- actions: JSON.stringify(actions),
202
- });
203
-
204
- spinner.succeed(`Created automation: ${chalk.cyan(answers.name)}`);
205
- } catch (error: any) {
206
- console.error(chalk.red(`Failed: ${error.message}`));
118
+ .command("create")
119
+ .description("Create a new automation rule")
120
+ .action(async () => {
121
+ try {
122
+ const config = getConfig();
123
+ if (!config.token) {
124
+ console.log(
125
+ chalk.red("Not authenticated. Run 'och auth login' first."),
126
+ );
127
+ process.exit(1);
128
+ }
129
+
130
+ const repoInfo = await getRepoInfo();
131
+ if (!repoInfo) {
132
+ console.log(chalk.red("Not in a git repository"));
133
+ process.exit(1);
134
+ }
135
+
136
+ // Get repository ID
137
+ const repoRes = await getWithAuth(
138
+ `/api/repos/${repoInfo.owner}/${repoInfo.name}`,
139
+ );
140
+ if (!repoRes.repository?.id) {
141
+ console.log(chalk.red("Repository not found on OpenCodeHub"));
142
+ process.exit(1);
143
+ }
144
+
145
+ console.log(
146
+ chalk.bold(
147
+ `\n⚡ Create Automation Rule for ${repoInfo.owner}/${repoInfo.name}\n`,
148
+ ),
149
+ );
150
+
151
+ const answers = await inquirer.prompt([
152
+ {
153
+ type: "input",
154
+ name: "name",
155
+ message: "Rule name:",
156
+ validate: (input) => input.length > 0 || "Name required",
157
+ },
158
+ {
159
+ type: "list",
160
+ name: "trigger",
161
+ message: "When should this run?",
162
+ choices: [
163
+ { name: "PR is opened", value: "pr_opened" },
164
+ { name: "PR is approved", value: "pr_approved" },
165
+ { name: "CI passes", value: "ci_passed" },
166
+ { name: "CI fails", value: "ci_failed" },
167
+ { name: "Label is added", value: "label_added" },
168
+ { name: "Review is requested", value: "pr_review_requested" },
169
+ ],
170
+ },
171
+ {
172
+ type: "checkbox",
173
+ name: "actions",
174
+ message: "What actions to take?",
175
+ choices: [
176
+ { name: "Add label", value: "add_label" },
177
+ { name: "Assign reviewer", value: "assign_reviewer" },
178
+ { name: "Add to merge queue", value: "add_to_merge_queue" },
179
+ { name: "Trigger AI review", value: "trigger_ai_review" },
180
+ { name: "Add comment", value: "add_comment" },
181
+ ],
182
+ validate: (input) => input.length > 0 || "Select at least one action",
183
+ },
184
+ ]);
185
+
186
+ // Build actions array with params
187
+ const actions = [];
188
+ for (const action of answers.actions) {
189
+ const actionObj: any = { type: action, params: {} };
190
+
191
+ if (action === "add_label") {
192
+ const { label } = await inquirer.prompt([
193
+ { type: "input", name: "label", message: "Label name:" },
194
+ ]);
195
+ actionObj.params.label = label;
196
+ } else if (action === "assign_reviewer") {
197
+ const { reviewer } = await inquirer.prompt([
198
+ {
199
+ type: "input",
200
+ name: "reviewer",
201
+ message: "Reviewer username (@codeowners for auto):",
202
+ },
203
+ ]);
204
+ actionObj.params.assignee = reviewer;
205
+ } else if (action === "add_comment") {
206
+ const { comment } = await inquirer.prompt([
207
+ { type: "input", name: "comment", message: "Comment text:" },
208
+ ]);
209
+ actionObj.params.body = comment;
207
210
}
208
- });
211
+
212
+ actions.push(actionObj);
213
+ }
214
+
215
+ const spinner = ora("Creating rule...").start();
216
+
217
+ await postWithAuth("/api/automation-rules", {
218
+ repositoryId: repoRes.repository.id,
219
+ name: answers.name,
220
+ trigger: answers.trigger,
221
+ actions: JSON.stringify(actions),
222
+ });
223
+
224
+ spinner.succeed(`Created automation: ${chalk.cyan(answers.name)}`);
225
+ } catch (error: any) {
226
+ console.error(chalk.red(`Failed: ${error.message}`));
227
+ }
228
+ });
209
229
210
230
// Enable/disable rule
211
231
automateCommand
212
- .command("enable <name>")
213
- .description("Enable an automation rule")
214
- .action(async (name) => {
215
- await toggleRule(name, true);
216
- });
232
+ .command("enable <name>")
233
+ .description("Enable an automation rule")
234
+ .action(async (name) => {
235
+ await toggleRule(name, true);
236
+ });
217
237
218
238
automateCommand
219
- .command("disable <name>")
220
- .description("Disable an automation rule")
221
- .action(async (name) => {
222
- await toggleRule(name, false);
223
- });
239
+ .command("disable <name>")
240
+ .description("Disable an automation rule")
241
+ .action(async (name) => {
242
+ await toggleRule(name, false);
243
+ });
224
244
225
245
async function toggleRule(name: string, enabled: boolean) {
226
- const spinner = ora(`${enabled ? "Enabling" : "Disabling"} rule...`).start();
227
-
228
- try {
229
- const repoInfo = await getRepoInfo();
230
- if (!repoInfo) {
231
- spinner.fail("Not in a git repository");
232
- return;
233
- }
234
-
235
- const repoRes = await getWithAuth(`/api/repos/${repoInfo.owner}/${repoInfo.name}`);
236
- const res = await getWithAuth(`/api/automation-rules?repositoryId=${repoRes.repository?.id}`);
237
- const rule = res.rules?.find((r: any) => r.name === name);
246
+ const spinner = ora(`${enabled ? "Enabling" : "Disabling"} rule...`).start();
238
247
239
- if (!rule) {
240
- spinner.fail(`Rule "${name}" not found`);
241
- return;
242
- }
248
+ try {
249
+ const repoInfo = await getRepoInfo();
250
+ if (!repoInfo) {
251
+ spinner.fail("Not in a git repository");
252
+ return;
253
+ }
243
254
244
- await patchWithAuth(`/api/automation-rules?id=${rule.id}`, { isEnabled: enabled });
245
- spinner.succeed(`${enabled ? "Enabled" : "Disabled"}: ${chalk.cyan(name)}`);
246
- } catch (error: any) {
247
- spinner.fail(`Failed: ${error.message}`);
255
+ const repoRes = await getWithAuth(
256
+ `/api/repos/${repoInfo.owner}/${repoInfo.name}`,
257
+ );
258
+ const res = await getWithAuth(
259
+ `/api/automation-rules?repositoryId=${repoRes.repository?.id}`,
260
+ );
261
+ const rule = res.rules?.find((r: any) => r.name === name);
262
+
263
+ if (!rule) {
264
+ spinner.fail(`Rule "${name}" not found`);
265
+ return;
248
266
}
267
+
268
+ await patchWithAuth(`/api/automation-rules?id=${rule.id}`, {
269
+ isEnabled: enabled,
270
+ });
271
+ spinner.succeed(`${enabled ? "Enabled" : "Disabled"}: ${chalk.cyan(name)}`);
272
+ } catch (error: any) {
273
+ spinner.fail(`Failed: ${error.message}`);
274
+ }
249
275
}
250
276
251
277
// Delete rule
252
278
automateCommand
253
- .command("delete <name>")
254
- .description("Delete an automation rule")
255
- .action(async (name) => {
256
- try {
257
- const { confirm } = await inquirer.prompt([
258
- {
259
- type: "confirm",
260
- name: "confirm",
261
- message: `Delete automation rule "${name}"?`,
262
- default: false,
263
- },
264
- ]);
265
-
266
- if (!confirm) return;
267
-
268
- const spinner = ora("Deleting rule...").start();
269
-
270
- const repoInfo = await getRepoInfo();
271
- if (!repoInfo) {
272
- spinner.fail("Not in a git repository");
273
- return;
274
- }
275
-
276
- const repoRes = await getWithAuth(`/api/repos/${repoInfo.owner}/${repoInfo.name}`);
277
- const res = await getWithAuth(`/api/automation-rules?repositoryId=${repoRes.repository?.id}`);
278
- const rule = res.rules?.find((r: any) => r.name === name);
279
-
280
- if (!rule) {
281
- spinner.fail(`Rule "${name}" not found`);
282
- return;
283
- }
284
-
285
- await deleteWithAuth(`/api/automation-rules?id=${rule.id}`);
286
- spinner.succeed(`Deleted: ${chalk.cyan(name)}`);
287
- } catch (error: any) {
288
- console.error(chalk.red(`Failed: ${error.message}`));
289
- }
290
- });
279
+ .command("delete <name>")
280
+ .description("Delete an automation rule")
281
+ .action(async (name) => {
282
+ try {
283
+ const { confirm } = await inquirer.prompt([
284
+ {
285
+ type: "confirm",
286
+ name: "confirm",
287
+ message: `Delete automation rule "${name}"?`,
288
+ default: false,
289
+ },
290
+ ]);
291
+
292
+ if (!confirm) return;
293
+
294
+ const spinner = ora("Deleting rule...").start();
295
+
296
+ const repoInfo = await getRepoInfo();
297
+ if (!repoInfo) {
298
+ spinner.fail("Not in a git repository");
299
+ return;
300
+ }
301
+
302
+ const repoRes = await getWithAuth(
303
+ `/api/repos/${repoInfo.owner}/${repoInfo.name}`,
304
+ );
305
+ const res = await getWithAuth(
306
+ `/api/automation-rules?repositoryId=${repoRes.repository?.id}`,
307
+ );
308
+ const rule = res.rules?.find((r: any) => r.name === name);
309
+
310
+ if (!rule) {
311
+ spinner.fail(`Rule "${name}" not found`);
312
+ return;
313
+ }
314
+
315
+ await deleteWithAuth(`/api/automation-rules?id=${rule.id}`);
316
+ spinner.succeed(`Deleted: ${chalk.cyan(name)}`);
317
+ } catch (error: any) {
318
+ console.error(chalk.red(`Failed: ${error.message}`));
319
+ }
320
+ });
291
321
292
322
function formatTrigger(trigger: string): string {
293
- const labels: Record<string, string> = {
294
- pr_opened: "PR opened",
295
- pr_approved: "PR approved",
296
- pr_merged: "PR merged",
297
- ci_passed: "CI passes",
298
- ci_failed: "CI fails",
299
- label_added: "Label added",
300
- pr_review_requested: "Review requested",
301
- };
302
- return labels[trigger] || trigger;
323
+ const labels: Record<string, string> = {
324
+ pr_opened: "PR opened",
325
+ pr_approved: "PR approved",
326
+ pr_merged: "PR merged",
327
+ ci_passed: "CI passes",
328
+ ci_failed: "CI fails",
329
+ label_added: "Label added",
330
+ pr_review_requested: "Review requested",
331
+ };
332
+ return labels[trigger] || trigger;
303
333
}
304
334
305
335
export default automateCommand;
306
336
▼cli/src/commands/ci/index.ts
+321-292
3
3
* Manage CI/CD pipelines from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
7
+import { Command } from "commander";
8
8
import ora from "ora";
9
9
import { simpleGit } from "simple-git";
10
-import { getConfig } from "../../lib/config.js";
11
10
import { getWithAuth, postWithAuth } from "../../lib/api.js";
11
+import { getConfig } from "../../lib/config.js";
12
+import { getRepoInfoFromGit } from "../../lib/git.js";
12
13
13
14
const git = simpleGit();
14
15
15
16
interface PipelineRun {
16
- id: string;
17
- number: number;
18
- status: string;
19
- conclusion?: string;
20
- branch: string;
21
- commit: string;
22
- event: string;
23
- createdAt: string;
24
- startedAt?: string;
25
- completedAt?: string;
26
- duration?: number;
27
- jobs?: PipelineJob[];
17
+ id: string;
18
+ number: number;
19
+ status: string;
20
+ conclusion?: string;
21
+ branch: string;
22
+ commit: string;
23
+ event: string;
24
+ createdAt: string;
25
+ startedAt?: string;
26
+ completedAt?: string;
27
+ duration?: number;
28
+ jobs?: PipelineJob[];
28
29
}
29
30
30
31
interface PipelineJob {
31
- id: string;
32
- name: string;
33
- status: string;
34
- conclusion?: string;
35
- startedAt?: string;
36
- completedAt?: string;
32
+ id: string;
33
+ name: string;
34
+ status: string;
35
+ conclusion?: string;
36
+ startedAt?: string;
37
+ completedAt?: string;
37
38
}
38
39
39
-export const ciCommands = new Command("ci")
40
- .description("Manage CI/CD pipelines");
40
+export const ciCommands = new Command("ci").description(
41
+ "Manage CI/CD pipelines",
42
+);
41
43
42
44
// Get repo info from git remote
43
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
44
- try {
45
- const remotes = await git.getRemotes(true);
46
- const origin = remotes.find(r => r.name === "origin");
47
- if (!origin?.refs?.push) return null;
48
-
49
- const url = origin.refs.push;
50
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
51
- if (match) {
52
- return { owner: match[1], repo: match[2] };
53
- }
54
- return null;
55
- } catch {
56
- return null;
57
- }
45
+async function getRepoInfo() {
46
+ return getRepoInfoFromGit(git);
58
47
}
59
48
60
49
function getStatusIcon(status: string, conclusion?: string): string {
61
- if (status === "completed") {
62
- switch (conclusion) {
63
- case "success": return chalk.green("✓");
64
- case "failure": return chalk.red("✗");
65
- case "cancelled": return chalk.yellow("⊘");
66
- case "skipped": return chalk.dim("○");
67
- default: return chalk.dim("?");
68
- }
69
- }
70
- switch (status) {
71
- case "queued": return chalk.yellow("◷");
72
- case "in_progress": return chalk.blue("●");
73
- case "waiting": return chalk.dim("◌");
74
- default: return chalk.dim("?");
50
+ if (status === "completed") {
51
+ switch (conclusion) {
52
+ case "success":
53
+ return chalk.green("✓");
54
+ case "failure":
55
+ return chalk.red("✗");
56
+ case "cancelled":
57
+ return chalk.yellow("⊘");
58
+ case "skipped":
59
+ return chalk.dim("○");
60
+ default:
61
+ return chalk.dim("?");
75
62
}
63
+ }
64
+ switch (status) {
65
+ case "queued":
66
+ return chalk.yellow("◷");
67
+ case "in_progress":
68
+ return chalk.blue("●");
69
+ case "waiting":
70
+ return chalk.dim("◌");
71
+ default:
72
+ return chalk.dim("?");
73
+ }
76
74
}
77
75
78
76
function formatDuration(seconds?: number): string {
79
- if (!seconds) return "-";
80
- if (seconds < 60) return `${seconds}s`;
81
- const mins = Math.floor(seconds / 60);
82
- const secs = seconds % 60;
83
- return `${mins}m ${secs}s`;
77
+ if (!seconds) return "-";
78
+ if (seconds < 60) return `${seconds}s`;
79
+ const mins = Math.floor(seconds / 60);
80
+ const secs = seconds % 60;
81
+ return `${mins}m ${secs}s`;
84
82
}
85
83
86
84
// CI List
87
85
ciCommands
88
- .command("list")
89
- .alias("ls")
90
- .description("List pipeline runs")
91
- .option("-b, --branch <branch>", "Filter by branch")
92
- .option("-s, --status <status>", "Filter by status (queued, in_progress, completed)")
93
- .option("-L, --limit <n>", "Maximum results", "20")
94
- .action(async (options) => {
95
- const config = getConfig();
96
- if (!config.token) {
97
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
98
- process.exit(1);
99
- }
100
-
101
- const spinner = ora("Fetching pipeline runs...").start();
102
-
103
- try {
104
- const repoInfo = await getRepoInfo();
105
- if (!repoInfo) {
106
- spinner.fail("Could not determine repository");
107
- process.exit(1);
108
- }
109
-
110
- const params = new URLSearchParams();
111
- if (options.branch) params.append("branch", options.branch);
112
- if (options.status) params.append("status", options.status);
113
- params.append("limit", options.limit);
114
-
115
- const result = await getWithAuth<{ data: PipelineRun[] }>(
116
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs?${params}`
117
- );
118
-
119
- spinner.stop();
120
-
121
- if (result.data.length === 0) {
122
- console.log(chalk.dim("No pipeline runs found."));
123
- return;
124
- }
125
-
126
- console.log(chalk.bold(`\n🔄 Pipeline Runs (${result.data.length})\n`));
127
-
128
- for (const run of result.data) {
129
- const icon = getStatusIcon(run.status, run.conclusion);
130
- const duration = formatDuration(run.duration);
131
- const time = new Date(run.createdAt).toLocaleString();
86
+ .command("list")
87
+ .alias("ls")
88
+ .description("List pipeline runs")
89
+ .option("-b, --branch <branch>", "Filter by branch")
90
+ .option(
91
+ "-s, --status <status>",
92
+ "Filter by status (queued, in_progress, completed)",
93
+ )
94
+ .option("-L, --limit <n>", "Maximum results", "20")
95
+ .action(async (options) => {
96
+ const config = getConfig();
97
+ if (!config.token) {
98
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
99
+ process.exit(1);
100
+ }
132
101
133
- console.log(`${icon} #${run.number} ${run.event} on ${chalk.cyan(run.branch)}`);
134
- console.log(chalk.dim(` ${run.commit.slice(0, 7)} • ${time} • ${duration}`));
135
- }
102
+ const spinner = ora("Fetching pipeline runs...").start();
136
103
137
- console.log("");
138
- } catch (error) {
139
- spinner.fail("Failed to list pipeline runs");
140
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
141
- process.exit(1);
142
- }
143
- });
104
+ try {
105
+ const repoInfo = await getRepoInfo();
106
+ if (!repoInfo) {
107
+ spinner.fail("Could not determine repository");
108
+ process.exit(1);
109
+ }
110
+
111
+ const params = new URLSearchParams();
112
+ if (options.branch) params.append("branch", options.branch);
113
+ if (options.status) params.append("status", options.status);
114
+ params.append("limit", options.limit);
115
+
116
+ const result = await getWithAuth<{ data: PipelineRun[] }>(
117
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs?${params}`,
118
+ );
119
+
120
+ spinner.stop();
121
+
122
+ if (result.data.length === 0) {
123
+ console.log(chalk.dim("No pipeline runs found."));
124
+ return;
125
+ }
126
+
127
+ console.log(chalk.bold(`\n🔄 Pipeline Runs (${result.data.length})\n`));
128
+
129
+ for (const run of result.data) {
130
+ const icon = getStatusIcon(run.status, run.conclusion);
131
+ const duration = formatDuration(run.duration);
132
+ const time = new Date(run.createdAt).toLocaleString();
133
+
134
+ console.log(
135
+ `${icon} #${run.number} ${run.event} on ${chalk.cyan(run.branch)}`,
136
+ );
137
+ console.log(
138
+ chalk.dim(` ${run.commit.slice(0, 7)} • ${time} • ${duration}`),
139
+ );
140
+ }
141
+
142
+ console.log("");
143
+ } catch (error) {
144
+ spinner.fail("Failed to list pipeline runs");
145
+ console.error(
146
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
147
+ );
148
+ process.exit(1);
149
+ }
150
+ });
144
151
145
152
// CI View
146
153
ciCommands
147
- .command("view <run-id>")
148
- .description("View a pipeline run")
149
- .option("-j, --jobs", "Show jobs")
150
- .action(async (runId: string, options) => {
151
- const config = getConfig();
152
- if (!config.token) {
153
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
154
- process.exit(1);
155
- }
154
+ .command("view <run-id>")
155
+ .description("View a pipeline run")
156
+ .option("-j, --jobs", "Show jobs")
157
+ .action(async (runId: string, options) => {
158
+ const config = getConfig();
159
+ if (!config.token) {
160
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
161
+ process.exit(1);
162
+ }
156
163
157
- const spinner = ora("Fetching pipeline run...").start();
158
-
159
- try {
160
- const repoInfo = await getRepoInfo();
161
- if (!repoInfo) {
162
- spinner.fail("Could not determine repository");
163
- process.exit(1);
164
- }
165
-
166
- const result = await getWithAuth<{ data: PipelineRun }>(
167
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}`
168
- );
169
-
170
- spinner.stop();
171
-
172
- const run = result.data;
173
- const icon = getStatusIcon(run.status, run.conclusion);
174
-
175
- console.log(chalk.bold(`\n${icon} Pipeline Run #${run.number}\n`));
176
- console.log(`Status: ${run.status}${run.conclusion ? ` (${run.conclusion})` : ""}`);
177
- console.log(`Event: ${run.event}`);
178
- console.log(`Branch: ${chalk.cyan(run.branch)}`);
179
- console.log(`Commit: ${run.commit.slice(0, 7)}`);
180
- console.log(`Started: ${run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}`);
181
- console.log(`Duration: ${formatDuration(run.duration)}`);
182
-
183
- if (run.jobs && run.jobs.length > 0) {
184
- console.log(chalk.bold("\nJobs:"));
185
- for (const job of run.jobs) {
186
- const jobIcon = getStatusIcon(job.status, job.conclusion);
187
- console.log(` ${jobIcon} ${job.name}`);
188
- }
189
- }
190
-
191
- console.log("");
192
- } catch (error) {
193
- spinner.fail("Failed to fetch pipeline run");
194
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
195
- process.exit(1);
164
+ const spinner = ora("Fetching pipeline run...").start();
165
+
166
+ try {
167
+ const repoInfo = await getRepoInfo();
168
+ if (!repoInfo) {
169
+ spinner.fail("Could not determine repository");
170
+ process.exit(1);
171
+ }
172
+
173
+ const result = await getWithAuth<{ data: PipelineRun }>(
174
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}`,
175
+ );
176
+
177
+ spinner.stop();
178
+
179
+ const run = result.data;
180
+ const icon = getStatusIcon(run.status, run.conclusion);
181
+
182
+ console.log(chalk.bold(`\n${icon} Pipeline Run #${run.number}\n`));
183
+ console.log(
184
+ `Status: ${run.status}${run.conclusion ? ` (${run.conclusion})` : ""}`,
185
+ );
186
+ console.log(`Event: ${run.event}`);
187
+ console.log(`Branch: ${chalk.cyan(run.branch)}`);
188
+ console.log(`Commit: ${run.commit.slice(0, 7)}`);
189
+ console.log(
190
+ `Started: ${run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}`,
191
+ );
192
+ console.log(`Duration: ${formatDuration(run.duration)}`);
193
+
194
+ if (run.jobs && run.jobs.length > 0) {
195
+ console.log(chalk.bold("\nJobs:"));
196
+ for (const job of run.jobs) {
197
+ const jobIcon = getStatusIcon(job.status, job.conclusion);
198
+ console.log(` ${jobIcon} ${job.name}`);
196
199
}
197
- });
200
+ }
201
+
202
+ console.log("");
203
+ } catch (error) {
204
+ spinner.fail("Failed to fetch pipeline run");
205
+ console.error(
206
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
207
+ );
208
+ process.exit(1);
209
+ }
210
+ });
198
211
199
212
// CI Trace (stream logs)
200
213
ciCommands
201
- .command("trace <job-id>")
202
- .alias("logs")
203
- .description("Stream job logs")
204
- .option("-f, --follow", "Follow log output")
205
- .action(async (jobId: string, options) => {
206
- const config = getConfig();
207
- if (!config.token) {
208
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
209
- process.exit(1);
210
- }
211
-
212
- const spinner = ora("Fetching job logs...").start();
213
-
214
- try {
215
- const repoInfo = await getRepoInfo();
216
- if (!repoInfo) {
217
- spinner.fail("Could not determine repository");
218
- process.exit(1);
219
- }
220
-
221
- const result = await getWithAuth<{ data: { logs: string } }>(
222
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/jobs/${jobId}/logs`
223
- );
214
+ .command("trace <job-id>")
215
+ .alias("logs")
216
+ .description("Stream job logs")
217
+ .option("-f, --follow", "Follow log output")
218
+ .action(async (jobId: string, options) => {
219
+ const config = getConfig();
220
+ if (!config.token) {
221
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
222
+ process.exit(1);
223
+ }
224
224
225
- spinner.stop();
225
+ const spinner = ora("Fetching job logs...").start();
226
226
227
- console.log(result.data.logs);
228
- } catch (error) {
229
- spinner.fail("Failed to fetch job logs");
230
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
231
- process.exit(1);
232
- }
233
- });
227
+ try {
228
+ const repoInfo = await getRepoInfo();
229
+ if (!repoInfo) {
230
+ spinner.fail("Could not determine repository");
231
+ process.exit(1);
232
+ }
233
+
234
+ const result = await getWithAuth<{ data: { logs: string } }>(
235
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/jobs/${jobId}/logs`,
236
+ );
237
+
238
+ spinner.stop();
239
+
240
+ console.log(result.data.logs);
241
+ } catch (error) {
242
+ spinner.fail("Failed to fetch job logs");
243
+ console.error(
244
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
245
+ );
246
+ process.exit(1);
247
+ }
248
+ });
234
249
235
250
// CI Retry
236
251
ciCommands
237
- .command("retry <run-id>")
238
- .alias("rerun")
239
- .description("Retry a failed pipeline run")
240
- .option("--failed-only", "Only retry failed jobs")
241
- .action(async (runId: string, options) => {
242
- const config = getConfig();
243
- if (!config.token) {
244
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
245
- process.exit(1);
246
- }
252
+ .command("retry <run-id>")
253
+ .alias("rerun")
254
+ .description("Retry a failed pipeline run")
255
+ .option("--failed-only", "Only retry failed jobs")
256
+ .action(async (runId: string, options) => {
257
+ const config = getConfig();
258
+ if (!config.token) {
259
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
260
+ process.exit(1);
261
+ }
247
262
248
- const spinner = ora(`Retrying pipeline run #${runId}...`).start();
249
-
250
- try {
251
- const repoInfo = await getRepoInfo();
252
- if (!repoInfo) {
253
- spinner.fail("Could not determine repository");
254
- process.exit(1);
255
- }
256
-
257
- await postWithAuth(
258
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}/rerun`,
259
- { failedOnly: options.failedOnly }
260
- );
261
-
262
- spinner.succeed(`Retried pipeline run #${runId}`);
263
- } catch (error) {
264
- spinner.fail("Failed to retry pipeline run");
265
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
266
- process.exit(1);
267
- }
268
- });
263
+ const spinner = ora(`Retrying pipeline run #${runId}...`).start();
264
+
265
+ try {
266
+ const repoInfo = await getRepoInfo();
267
+ if (!repoInfo) {
268
+ spinner.fail("Could not determine repository");
269
+ process.exit(1);
270
+ }
271
+
272
+ await postWithAuth(
273
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}/rerun`,
274
+ { failedOnly: options.failedOnly },
275
+ );
276
+
277
+ spinner.succeed(`Retried pipeline run #${runId}`);
278
+ } catch (error) {
279
+ spinner.fail("Failed to retry pipeline run");
280
+ console.error(
281
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
282
+ );
283
+ process.exit(1);
284
+ }
285
+ });
269
286
270
287
// CI Cancel
271
288
ciCommands
272
- .command("cancel <run-id>")
273
- .description("Cancel a running pipeline")
274
- .action(async (runId: string) => {
275
- const config = getConfig();
276
- if (!config.token) {
277
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
278
- process.exit(1);
279
- }
289
+ .command("cancel <run-id>")
290
+ .description("Cancel a running pipeline")
291
+ .action(async (runId: string) => {
292
+ const config = getConfig();
293
+ if (!config.token) {
294
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
295
+ process.exit(1);
296
+ }
280
297
281
- const spinner = ora(`Cancelling pipeline run #${runId}...`).start();
282
-
283
- try {
284
- const repoInfo = await getRepoInfo();
285
- if (!repoInfo) {
286
- spinner.fail("Could not determine repository");
287
- process.exit(1);
288
- }
289
-
290
- await postWithAuth(
291
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}/cancel`,
292
- {}
293
- );
294
-
295
- spinner.succeed(`Cancelled pipeline run #${runId}`);
296
- } catch (error) {
297
- spinner.fail("Failed to cancel pipeline run");
298
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
299
- process.exit(1);
300
- }
301
- });
298
+ const spinner = ora(`Cancelling pipeline run #${runId}...`).start();
299
+
300
+ try {
301
+ const repoInfo = await getRepoInfo();
302
+ if (!repoInfo) {
303
+ spinner.fail("Could not determine repository");
304
+ process.exit(1);
305
+ }
306
+
307
+ await postWithAuth(
308
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}/cancel`,
309
+ {},
310
+ );
311
+
312
+ spinner.succeed(`Cancelled pipeline run #${runId}`);
313
+ } catch (error) {
314
+ spinner.fail("Failed to cancel pipeline run");
315
+ console.error(
316
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
317
+ );
318
+ process.exit(1);
319
+ }
320
+ });
302
321
303
322
// CI Status (current branch)
304
323
ciCommands
305
- .command("status")
306
- .description("Show CI status for current branch")
307
- .action(async () => {
308
- const config = getConfig();
309
- if (!config.token) {
310
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
311
- process.exit(1);
312
- }
313
-
314
- const spinner = ora("Checking CI status...").start();
315
-
316
- try {
317
- const repoInfo = await getRepoInfo();
318
- if (!repoInfo) {
319
- spinner.fail("Could not determine repository");
320
- process.exit(1);
321
- }
322
-
323
- const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
324
-
325
- const result = await getWithAuth<{ data: PipelineRun[] }>(
326
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs?branch=${currentBranch}&limit=1`
327
- );
328
-
329
- spinner.stop();
330
-
331
- if (result.data.length === 0) {
332
- console.log(chalk.dim(`No CI runs found for branch ${currentBranch}`));
333
- return;
334
- }
324
+ .command("status")
325
+ .description("Show CI status for current branch")
326
+ .action(async () => {
327
+ const config = getConfig();
328
+ if (!config.token) {
329
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
330
+ process.exit(1);
331
+ }
335
332
336
- const run = result.data[0];
337
- const icon = getStatusIcon(run.status, run.conclusion);
333
+ const spinner = ora("Checking CI status...").start();
338
334
339
- console.log(`\n${icon} ${run.status}${run.conclusion ? ` (${run.conclusion})` : ""}`);
340
- console.log(chalk.dim(` Run #${run.number} on ${currentBranch}`));
341
- console.log(chalk.dim(` ${run.commit.slice(0, 7)} • ${formatDuration(run.duration)}`));
342
- console.log("");
343
- } catch (error) {
344
- spinner.fail("Failed to check CI status");
345
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
346
- process.exit(1);
347
- }
348
- });
335
+ try {
336
+ const repoInfo = await getRepoInfo();
337
+ if (!repoInfo) {
338
+ spinner.fail("Could not determine repository");
339
+ process.exit(1);
340
+ }
341
+
342
+ const currentBranch = (
343
+ await git.revparse(["--abbrev-ref", "HEAD"])
344
+ ).trim();
345
+
346
+ const result = await getWithAuth<{ data: PipelineRun[] }>(
347
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs?branch=${currentBranch}&limit=1`,
348
+ );
349
+
350
+ spinner.stop();
351
+
352
+ if (result.data.length === 0) {
353
+ console.log(chalk.dim(`No CI runs found for branch ${currentBranch}`));
354
+ return;
355
+ }
356
+
357
+ const run = result.data[0];
358
+ const icon = getStatusIcon(run.status, run.conclusion);
359
+
360
+ console.log(
361
+ `\n${icon} ${run.status}${run.conclusion ? ` (${run.conclusion})` : ""}`,
362
+ );
363
+ console.log(chalk.dim(` Run #${run.number} on ${currentBranch}`));
364
+ console.log(
365
+ chalk.dim(
366
+ ` ${run.commit.slice(0, 7)} • ${formatDuration(run.duration)}`,
367
+ ),
368
+ );
369
+ console.log("");
370
+ } catch (error) {
371
+ spinner.fail("Failed to check CI status");
372
+ console.error(
373
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
374
+ );
375
+ process.exit(1);
376
+ }
377
+ });
349
378
350
379
export default ciCommands;
351
380
▼cli/src/commands/config/index.ts
+248-118
3
3
* Manage CLI configuration
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
7
+import { Command } from "commander";
8
8
import Conf from "conf";
9
+import fs from "fs";
10
+import { applyTlsConfig } from "../../lib/api.js";
9
11
10
12
interface OchConfig {
11
- serverUrl: string;
12
- token: string;
13
- defaultBranch: string;
14
- editor: string;
15
- pager: string;
13
+ serverUrl: string;
14
+ token: string;
15
+ defaultBranch: string;
16
+ editor: string;
17
+ pager: string;
18
+ caFile: string;
19
+ insecure: boolean;
16
20
}
17
21
18
22
const config = new Conf<OchConfig>({
19
- projectName: "opencodehub-cli",
20
- defaults: {
21
- serverUrl: "http://localhost:4321",
22
- token: "",
23
- defaultBranch: "main",
24
- editor: "",
25
- pager: "",
26
- },
23
+ projectName: "opencodehub-cli",
24
+ defaults: {
25
+ serverUrl: "",
26
+ token: "",
27
+ defaultBranch: "main",
28
+ editor: "",
29
+ pager: "",
30
+ caFile: "",
31
+ insecure: false,
32
+ },
27
33
});
28
34
29
35
const configDescriptions: Record<string, string> = {
30
- serverUrl: "OpenCodeHub server URL",
31
- token: "Personal access token",
32
- defaultBranch: "Default base branch for PRs",
33
- editor: "Editor to use for interactive editing",
34
- pager: "Pager to use for long output",
36
+ serverUrl: "OpenCodeHub server URL",
37
+ token: "Personal access token",
38
+ defaultBranch: "Default base branch for PRs",
39
+ editor: "Editor to use for interactive editing",
40
+ pager: "Pager to use for long output",
41
+ caFile: "Path to custom CA bundle (self-signed TLS)",
42
+ insecure: "Disable TLS certificate verification",
35
43
};
36
44
37
-export const configCommands = new Command("config")
38
- .description("Manage CLI configuration");
45
+export const configCommands = new Command("config").description(
46
+ "Manage CLI configuration",
47
+);
39
48
40
49
// Config List
41
50
configCommands
42
- .command("list")
43
- .alias("ls")
44
- .description("List all configuration values")
45
- .action(() => {
46
- console.log(chalk.bold("\n⚙️ CLI Configuration\n"));
47
-
48
- const keys = ["serverUrl", "defaultBranch", "editor", "pager"];
49
-
50
- for (const key of keys) {
51
- const value = config.get(key as keyof OchConfig);
52
- const desc = configDescriptions[key] || "";
53
- const displayValue = value || chalk.dim("(not set)");
54
-
55
- console.log(`${chalk.cyan(key)}: ${displayValue}`);
56
- if (desc) {
57
- console.log(chalk.dim(` ${desc}`));
58
- }
59
- }
51
+ .command("list")
52
+ .alias("ls")
53
+ .description("List all configuration values")
54
+ .action(() => {
55
+ console.log(chalk.bold("\n⚙️ CLI Configuration\n"));
56
+
57
+ const keys = [
58
+ "serverUrl",
59
+ "defaultBranch",
60
+ "editor",
61
+ "pager",
62
+ "caFile",
63
+ "insecure",
64
+ ];
60
65
61
- // Show token status (not the actual token)
62
- const hasToken = !!config.get("token");
63
- console.log(`${chalk.cyan("token")}: ${hasToken ? chalk.green("●") + " configured" : chalk.dim("(not set)")}`);
64
- console.log(chalk.dim(" Personal access token"));
66
+ for (const key of keys) {
67
+ const value = config.get(key as keyof OchConfig);
68
+ const desc = configDescriptions[key] || "";
69
+ const displayValue =
70
+ typeof value === "boolean"
71
+ ? String(value)
72
+ : value || chalk.dim("(not set)");
65
73
66
- console.log(chalk.dim(`\nConfig file: ${config.path}`));
67
- console.log("");
68
- });
74
+ console.log(`${chalk.cyan(key)}: ${displayValue}`);
75
+ if (desc) {
76
+ console.log(chalk.dim(` ${desc}`));
77
+ }
78
+ }
79
+
80
+ // Show token status (not the actual token)
81
+ const hasToken = !!config.get("token");
82
+ console.log(
83
+ `${chalk.cyan("token")}: ${hasToken ? chalk.green("●") + " configured" : chalk.dim("(not set)")}`,
84
+ );
85
+ console.log(chalk.dim(" Personal access token"));
86
+
87
+ console.log(chalk.dim(`\nConfig file: ${config.path}`));
88
+ console.log("");
89
+ });
69
90
70
91
// Config Get
71
92
configCommands
72
- .command("get <key>")
73
- .description("Get a configuration value")
74
- .action((key: string) => {
75
- const value = config.get(key as keyof OchConfig);
76
-
77
- if (key === "token") {
78
- if (value) {
79
- console.log(value.slice(0, 12) + "...");
80
- } else {
81
- console.log(chalk.dim("(not set)"));
82
- }
83
- } else if (value !== undefined) {
84
- console.log(value);
85
- } else {
86
- console.error(chalk.red(`Unknown config key: ${key}`));
87
- console.log(chalk.dim("Available keys: serverUrl, token, defaultBranch, editor, pager"));
88
- process.exit(1);
89
- }
90
- });
93
+ .command("get <key>")
94
+ .description("Get a configuration value")
95
+ .action((key: string) => {
96
+ const value = config.get(key as keyof OchConfig);
97
+
98
+ if (key === "token") {
99
+ if (value) {
100
+ console.log(value.slice(0, 12) + "...");
101
+ } else {
102
+ console.log(chalk.dim("(not set)"));
103
+ }
104
+ } else if (value !== undefined) {
105
+ console.log(value);
106
+ } else {
107
+ console.error(chalk.red(`Unknown config key: ${key}`));
108
+ console.log(
109
+ chalk.dim(
110
+ "Available keys: serverUrl, token, defaultBranch, editor, pager, caFile, insecure",
111
+ ),
112
+ );
113
+ process.exit(1);
114
+ }
115
+ });
91
116
92
117
// Config Set
93
118
configCommands
94
- .command("set <key> <value>")
95
- .description("Set a configuration value")
96
- .action((key: string, value: string) => {
97
- const validKeys = ["serverUrl", "token", "defaultBranch", "editor", "pager"];
98
-
99
- if (!validKeys.includes(key)) {
100
- console.error(chalk.red(`Unknown config key: ${key}`));
101
- console.log(chalk.dim(`Available keys: ${validKeys.join(", ")}`));
102
- process.exit(1);
103
- }
119
+ .command("set <key> <value>")
120
+ .description("Set a configuration value")
121
+ .action((key: string, value: string) => {
122
+ const validKeys = [
123
+ "serverUrl",
124
+ "token",
125
+ "defaultBranch",
126
+ "editor",
127
+ "pager",
128
+ "caFile",
129
+ "insecure",
130
+ ];
131
+
132
+ if (!validKeys.includes(key)) {
133
+ console.error(chalk.red(`Unknown config key: ${key}`));
134
+ console.log(chalk.dim(`Available keys: ${validKeys.join(", ")}`));
135
+ process.exit(1);
136
+ }
137
+
138
+ const normalizedValue =
139
+ key === "insecure" ? value === "true" || value === "1" : value;
104
140
105
- config.set(key as keyof OchConfig, value);
106
- console.log(chalk.green(`✓ Set ${key} = ${key === "token" ? value.slice(0, 12) + "..." : value}`));
107
- });
141
+ config.set(key as keyof OchConfig, normalizedValue as any);
142
+ console.log(
143
+ chalk.green(
144
+ `✓ Set ${key} = ${key === "token" ? value.slice(0, 12) + "..." : value}`,
145
+ ),
146
+ );
147
+ });
108
148
109
149
// Config Unset
110
150
configCommands
111
- .command("unset <key>")
112
- .alias("delete")
113
- .description("Unset a configuration value")
114
- .action((key: string) => {
115
- const validKeys = ["serverUrl", "token", "defaultBranch", "editor", "pager"];
116
-
117
- if (!validKeys.includes(key)) {
118
- console.error(chalk.red(`Unknown config key: ${key}`));
119
- process.exit(1);
120
- }
151
+ .command("unset <key>")
152
+ .alias("delete")
153
+ .description("Unset a configuration value")
154
+ .action((key: string) => {
155
+ const validKeys = [
156
+ "serverUrl",
157
+ "token",
158
+ "defaultBranch",
159
+ "editor",
160
+ "pager",
161
+ "caFile",
162
+ "insecure",
163
+ ];
164
+
165
+ if (!validKeys.includes(key)) {
166
+ console.error(chalk.red(`Unknown config key: ${key}`));
167
+ process.exit(1);
168
+ }
121
169
122
- config.delete(key as keyof OchConfig);
123
- console.log(chalk.green(`✓ Unset ${key}`));
124
- });
170
+ config.delete(key as keyof OchConfig);
171
+ console.log(chalk.green(`✓ Unset ${key}`));
172
+ });
125
173
126
174
// Config Path
127
175
configCommands
128
- .command("path")
129
- .description("Show config file path")
130
- .action(() => {
131
- console.log(config.path);
132
- });
176
+ .command("path")
177
+ .description("Show config file path")
178
+ .action(() => {
179
+ console.log(config.path);
180
+ });
133
181
134
182
// Config Reset
135
183
configCommands
136
- .command("reset")
137
- .description("Reset all configuration to defaults")
138
- .option("-y, --yes", "Skip confirmation")
139
- .action(async (options) => {
140
- if (!options.yes) {
141
- const inquirer = await import("inquirer");
142
- const { confirm } = await inquirer.default.prompt([
143
- {
144
- type: "confirm",
145
- name: "confirm",
146
- message: "Reset all configuration to defaults?",
147
- default: false,
148
- },
149
- ]);
150
- if (!confirm) {
151
- console.log(chalk.dim("Cancelled."));
152
- return;
153
- }
154
- }
184
+ .command("reset")
185
+ .description("Reset all configuration to defaults")
186
+ .option("-y, --yes", "Skip confirmation")
187
+ .action(async (options) => {
188
+ if (!options.yes) {
189
+ const inquirer = await import("inquirer");
190
+ const { confirm } = await inquirer.default.prompt([
191
+ {
192
+ type: "confirm",
193
+ name: "confirm",
194
+ message: "Reset all configuration to defaults?",
195
+ default: false,
196
+ },
197
+ ]);
198
+ if (!confirm) {
199
+ console.log(chalk.dim("Cancelled."));
200
+ return;
201
+ }
202
+ }
155
203
156
- config.clear();
157
- console.log(chalk.green("✓ Configuration reset to defaults"));
158
- });
204
+ config.clear();
205
+ console.log(chalk.green("✓ Configuration reset to defaults"));
206
+ });
159
207
160
208
export default configCommands;
209
+
210
+// Config Doctor
211
+configCommands
212
+ .command("doctor")
213
+ .description("Validate CLI configuration and connectivity")
214
+ .action(async () => {
215
+ console.log(chalk.bold("\n🩺 Config Doctor\n"));
216
+
217
+ const serverUrl = config.get("serverUrl");
218
+ const token = config.get("token");
219
+ const caFile = config.get("caFile");
220
+ const insecure = config.get("insecure");
221
+
222
+ let hasIssues = false;
223
+
224
+ if (!serverUrl) {
225
+ hasIssues = true;
226
+ console.log(chalk.red("✗ serverUrl is not set"));
227
+ console.log(chalk.dim(" Run: och config set serverUrl <url>"));
228
+ } else {
229
+ console.log(chalk.green("✓ serverUrl configured"));
230
+ console.log(chalk.dim(` ${serverUrl}`));
231
+ }
232
+
233
+ if (!token) {
234
+ hasIssues = true;
235
+ console.log(chalk.red("✗ token is not set"));
236
+ console.log(chalk.dim(" Run: och auth login --url <url>"));
237
+ } else {
238
+ console.log(chalk.green("✓ token configured"));
239
+ }
240
+
241
+ if (caFile) {
242
+ if (!fs.existsSync(caFile)) {
243
+ hasIssues = true;
244
+ console.log(chalk.red("✗ caFile does not exist"));
245
+ console.log(chalk.dim(` ${caFile}`));
246
+ } else {
247
+ console.log(chalk.green("✓ caFile found"));
248
+ console.log(chalk.dim(` ${caFile}`));
249
+ }
250
+ }
251
+
252
+ if (insecure) {
253
+ console.log(chalk.yellow("! insecure TLS is enabled"));
254
+ }
255
+
256
+ if (serverUrl && token) {
257
+ try {
258
+ applyTlsConfig();
259
+ const response = await fetch(`${serverUrl}/api/user`, {
260
+ headers: {
261
+ Authorization: `Bearer ${token}`,
262
+ },
263
+ });
264
+
265
+ if (!response.ok) {
266
+ hasIssues = true;
267
+ console.log(chalk.red("✗ token validation failed"));
268
+ console.log(chalk.dim(` HTTP ${response.status}`));
269
+ } else {
270
+ const data = await response.json();
271
+ const username = data?.data?.username || "unknown";
272
+ console.log(chalk.green("✓ token is valid"));
273
+ console.log(chalk.dim(` Authenticated as ${username}`));
274
+ }
275
+ } catch (error) {
276
+ hasIssues = true;
277
+ console.log(chalk.red("✗ could not reach server"));
278
+ console.log(
279
+ chalk.dim(String(error instanceof Error ? error.message : error)),
280
+ );
281
+ }
282
+ }
283
+
284
+ if (hasIssues) {
285
+ console.log(chalk.red("\nConfig doctor found issues."));
286
+ process.exit(1);
287
+ }
288
+
289
+ console.log(chalk.green("\nConfig doctor found no issues."));
290
+ });
161
291
▼cli/src/commands/insights/index.ts
+253-227
3
3
* View developer metrics and team insights
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
9
7
import Table from "cli-table3";
8
+import { Command } from "commander";
9
+import ora from "ora";
10
+import simpleGit from "simple-git";
10
11
import { getWithAuth } from "../../lib/api.js";
11
12
import { getConfig } from "../../lib/config.js";
12
-import simpleGit from "simple-git";
13
+import { getRepoInfoFromGit } from "../../lib/git.js";
13
14
14
15
const git = simpleGit();
15
16
16
-export const insightsCommand = new Command("insights")
17
- .description("View developer metrics and insights");
17
+export const insightsCommand = new Command("insights").description(
18
+ "View developer metrics and insights",
19
+);
18
20
19
21
// Helper to get repo info
20
22
async function getRepoInfo() {
21
- try {
22
- const remotes = await git.getRemotes(true);
23
- const origin = remotes.find((r) => r.name === "origin");
24
- if (!origin?.refs?.fetch) return null;
25
-
26
- const url = origin.refs.fetch;
27
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
28
- if (!match) return null;
29
-
30
- return { owner: match[1], name: match[2] };
31
- } catch {
32
- return null;
33
- }
23
+ const repoInfo = await getRepoInfoFromGit(git);
24
+ return repoInfo ? { owner: repoInfo.owner, name: repoInfo.repo } : null;
34
25
}
35
26
36
27
// Show personal metrics
37
28
insightsCommand
38
- .command("show")
39
- .description("Show your developer metrics")
40
- .option("-p, --period <period>", "Time period: 1w, 4w, 3m, 1y", "4w")
41
- .option("--json", "Output as JSON")
42
- .action(async (options) => {
43
- const spinner = ora("Loading metrics...").start();
44
-
45
- try {
46
- const config = getConfig();
47
- if (!config.token) {
48
- spinner.fail("Not authenticated. Run 'och auth login' first.");
49
- process.exit(1);
50
- }
51
-
52
- const res = await getWithAuth(`/api/metrics/user?period=${options.period}`);
53
-
54
- spinner.stop();
55
-
56
- if (options.json) {
57
- console.log(JSON.stringify(res, null, 2));
58
- return;
59
- }
60
-
61
- const metrics = res.metrics || {
62
- prsOpened: 0,
63
- prsMerged: 0,
64
- reviewsGiven: 0,
65
- avgTimeToMerge: 0,
66
- avgReviewTime: 0,
67
- };
68
-
69
- console.log(chalk.bold("\n📊 Your Developer Metrics\n"));
70
- console.log(chalk.gray(` Period: Last ${formatPeriod(options.period)}\n`));
71
-
72
- // Main metrics
73
- const table = new Table({
74
- chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
75
- });
76
-
77
- table.push(
78
- [chalk.cyan("PRs Opened"), chalk.bold.white(metrics.prsOpened)],
79
- [chalk.magenta("PRs Merged"), chalk.bold.white(metrics.prsMerged)],
80
- [chalk.blue("Reviews Given"), chalk.bold.white(metrics.reviewsGiven)],
81
- [chalk.yellow("Avg Time to Merge"), chalk.bold.white(formatDuration(metrics.avgTimeToMerge))],
82
- [chalk.green("Merge Rate"), chalk.bold.white(
83
- metrics.prsOpened > 0
84
- ? `${Math.round((metrics.prsMerged / metrics.prsOpened) * 100)}%`
85
- : "N/A"
86
- )],
87
- );
88
-
89
- console.log(table.toString());
90
- console.log();
91
-
92
- // Productivity tip
93
- if (metrics.prsOpened > 0 && metrics.avgTimeToMerge > 48) {
94
- console.log(chalk.yellow(" 💡 Tip: Your PRs take an average of " +
95
- formatDuration(metrics.avgTimeToMerge) + " to merge."));
96
- console.log(chalk.gray(" Consider smaller PRs for faster reviews.\n"));
97
- }
98
- } catch (error: any) {
99
- spinner.fail(`Failed: ${error.message}`);
100
- process.exit(1);
101
- }
102
- });
29
+ .command("show")
30
+ .description("Show your developer metrics")
31
+ .option("-p, --period <period>", "Time period: 1w, 4w, 3m, 1y", "4w")
32
+ .option("--json", "Output as JSON")
33
+ .action(async (options) => {
34
+ const spinner = ora("Loading metrics...").start();
35
+
36
+ try {
37
+ const config = getConfig();
38
+ if (!config.token) {
39
+ spinner.fail("Not authenticated. Run 'och auth login' first.");
40
+ process.exit(1);
41
+ }
42
+
43
+ const res = await getWithAuth(
44
+ `/api/metrics/user?period=${options.period}`,
45
+ );
46
+
47
+ spinner.stop();
48
+
49
+ if (options.json) {
50
+ console.log(JSON.stringify(res, null, 2));
51
+ return;
52
+ }
53
+
54
+ const metrics = res.metrics || {
55
+ prsOpened: 0,
56
+ prsMerged: 0,
57
+ reviewsGiven: 0,
58
+ avgTimeToMerge: 0,
59
+ avgReviewTime: 0,
60
+ };
61
+
62
+ console.log(chalk.bold("\n📊 Your Developer Metrics\n"));
63
+ console.log(
64
+ chalk.gray(` Period: Last ${formatPeriod(options.period)}\n`),
65
+ );
66
+
67
+ // Main metrics
68
+ const table = new Table({
69
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
70
+ });
71
+
72
+ table.push(
73
+ [chalk.cyan("PRs Opened"), chalk.bold.white(metrics.prsOpened)],
74
+ [chalk.magenta("PRs Merged"), chalk.bold.white(metrics.prsMerged)],
75
+ [chalk.blue("Reviews Given"), chalk.bold.white(metrics.reviewsGiven)],
76
+ [
77
+ chalk.yellow("Avg Time to Merge"),
78
+ chalk.bold.white(formatDuration(metrics.avgTimeToMerge)),
79
+ ],
80
+ [
81
+ chalk.green("Merge Rate"),
82
+ chalk.bold.white(
83
+ metrics.prsOpened > 0
84
+ ? `${Math.round((metrics.prsMerged / metrics.prsOpened) * 100)}%`
85
+ : "N/A",
86
+ ),
87
+ ],
88
+ );
89
+
90
+ console.log(table.toString());
91
+ console.log();
92
+
93
+ // Productivity tip
94
+ if (metrics.prsOpened > 0 && metrics.avgTimeToMerge > 48) {
95
+ console.log(
96
+ chalk.yellow(
97
+ " 💡 Tip: Your PRs take an average of " +
98
+ formatDuration(metrics.avgTimeToMerge) +
99
+ " to merge.",
100
+ ),
101
+ );
102
+ console.log(
103
+ chalk.gray(" Consider smaller PRs for faster reviews.\n"),
104
+ );
105
+ }
106
+ } catch (error: any) {
107
+ spinner.fail(`Failed: ${error.message}`);
108
+ process.exit(1);
109
+ }
110
+ });
103
111
104
112
// Show team metrics
105
113
insightsCommand
106
- .command("team")
107
- .description("Show team/repository metrics and leaderboard")
108
- .option("-p, --period <period>", "Time period: 1w, 4w, 3m, 1y", "4w")
109
- .option("-n, --limit <n>", "Number of top contributors", "10")
110
- .option("--json", "Output as JSON")
111
- .action(async (options) => {
112
- const spinner = ora("Loading team metrics...").start();
113
-
114
- try {
115
- const config = getConfig();
116
- if (!config.token) {
117
- spinner.fail("Not authenticated. Run 'och auth login' first.");
118
- process.exit(1);
119
- }
120
-
121
- const repoInfo = await getRepoInfo();
122
- let endpoint = `/api/metrics/team?period=${options.period}&limit=${options.limit}`;
123
-
124
- if (repoInfo) {
125
- const repoRes = await getWithAuth(`/api/repos/${repoInfo.owner}/${repoInfo.name}`);
126
- if (repoRes.repository?.id) {
127
- endpoint += `&repositoryId=${repoRes.repository.id}`;
128
- }
129
- }
130
-
131
- const res = await getWithAuth(endpoint);
132
-
133
- spinner.stop();
134
-
135
- if (options.json) {
136
- console.log(JSON.stringify(res, null, 2));
137
- return;
138
- }
139
-
140
- const contributors = res.contributors || [];
141
- const repoMetrics = res.metrics || {};
142
-
143
- if (repoInfo) {
144
- console.log(chalk.bold(`\n👥 Team Metrics for ${repoInfo.owner}/${repoInfo.name}\n`));
145
- } else {
146
- console.log(chalk.bold("\n👥 Team Metrics\n"));
147
- }
148
-
149
- console.log(chalk.gray(` Period: Last ${formatPeriod(options.period)}\n`));
150
-
151
- // Repository summary
152
- if (repoMetrics.totalPrs !== undefined) {
153
- console.log(` Total PRs: ${chalk.cyan(repoMetrics.totalPrs)}`);
154
- console.log(` Merged: ${chalk.green(repoMetrics.mergedPrs || 0)}`);
155
- console.log(` Open: ${chalk.yellow(repoMetrics.openPrs || 0)}`);
156
- console.log();
157
- }
158
-
159
- // Leaderboard
160
- if (contributors.length > 0) {
161
- console.log(chalk.bold(" 🏆 Top Contributors\n"));
162
-
163
- const table = new Table({
164
- head: [
165
- chalk.gray("#"),
166
- chalk.gray("Developer"),
167
- chalk.gray("PRs"),
168
- chalk.gray("Reviews"),
169
- chalk.gray("Score"),
170
- ],
171
- });
172
-
173
- contributors.forEach((c: any, i: number) => {
174
- const medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `${i + 1}`;
175
- const score = (c.prs || 0) * 2 + (c.reviews || 0);
176
-
177
- table.push([
178
- medal,
179
- chalk.white(c.username),
180
- chalk.cyan(c.prs || 0),
181
- chalk.blue(c.reviews || 0),
182
- chalk.yellow(score),
183
- ]);
184
- });
185
-
186
- console.log(table.toString());
187
- } else {
188
- console.log(chalk.gray(" No contributor data available.\n"));
189
- }
190
-
191
- console.log();
192
- } catch (error: any) {
193
- spinner.fail(`Failed: ${error.message}`);
194
- process.exit(1);
114
+ .command("team")
115
+ .description("Show team/repository metrics and leaderboard")
116
+ .option("-p, --period <period>", "Time period: 1w, 4w, 3m, 1y", "4w")
117
+ .option("-n, --limit <n>", "Number of top contributors", "10")
118
+ .option("--json", "Output as JSON")
119
+ .action(async (options) => {
120
+ const spinner = ora("Loading team metrics...").start();
121
+
122
+ try {
123
+ const config = getConfig();
124
+ if (!config.token) {
125
+ spinner.fail("Not authenticated. Run 'och auth login' first.");
126
+ process.exit(1);
127
+ }
128
+
129
+ const repoInfo = await getRepoInfo();
130
+ let endpoint = `/api/metrics/team?period=${options.period}&limit=${options.limit}`;
131
+
132
+ if (repoInfo) {
133
+ const repoRes = await getWithAuth(
134
+ `/api/repos/${repoInfo.owner}/${repoInfo.name}`,
135
+ );
136
+ if (repoRes.repository?.id) {
137
+ endpoint += `&repositoryId=${repoRes.repository.id}`;
195
138
}
196
- });
139
+ }
140
+
141
+ const res = await getWithAuth(endpoint);
142
+
143
+ spinner.stop();
144
+
145
+ if (options.json) {
146
+ console.log(JSON.stringify(res, null, 2));
147
+ return;
148
+ }
149
+
150
+ const contributors = res.contributors || [];
151
+ const repoMetrics = res.metrics || {};
152
+
153
+ if (repoInfo) {
154
+ console.log(
155
+ chalk.bold(
156
+ `\n👥 Team Metrics for ${repoInfo.owner}/${repoInfo.name}\n`,
157
+ ),
158
+ );
159
+ } else {
160
+ console.log(chalk.bold("\n👥 Team Metrics\n"));
161
+ }
162
+
163
+ console.log(
164
+ chalk.gray(` Period: Last ${formatPeriod(options.period)}\n`),
165
+ );
166
+
167
+ // Repository summary
168
+ if (repoMetrics.totalPrs !== undefined) {
169
+ console.log(` Total PRs: ${chalk.cyan(repoMetrics.totalPrs)}`);
170
+ console.log(` Merged: ${chalk.green(repoMetrics.mergedPrs || 0)}`);
171
+ console.log(` Open: ${chalk.yellow(repoMetrics.openPrs || 0)}`);
172
+ console.log();
173
+ }
174
+
175
+ // Leaderboard
176
+ if (contributors.length > 0) {
177
+ console.log(chalk.bold(" 🏆 Top Contributors\n"));
178
+
179
+ const table = new Table({
180
+ head: [
181
+ chalk.gray("#"),
182
+ chalk.gray("Developer"),
183
+ chalk.gray("PRs"),
184
+ chalk.gray("Reviews"),
185
+ chalk.gray("Score"),
186
+ ],
187
+ });
188
+
189
+ contributors.forEach((c: any, i: number) => {
190
+ const medal =
191
+ i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `${i + 1}`;
192
+ const score = (c.prs || 0) * 2 + (c.reviews || 0);
193
+
194
+ table.push([
195
+ medal,
196
+ chalk.white(c.username),
197
+ chalk.cyan(c.prs || 0),
198
+ chalk.blue(c.reviews || 0),
199
+ chalk.yellow(score),
200
+ ]);
201
+ });
202
+
203
+ console.log(table.toString());
204
+ } else {
205
+ console.log(chalk.gray(" No contributor data available.\n"));
206
+ }
207
+
208
+ console.log();
209
+ } catch (error: any) {
210
+ spinner.fail(`Failed: ${error.message}`);
211
+ process.exit(1);
212
+ }
213
+ });
197
214
198
215
// Repository metrics
199
216
insightsCommand
200
- .command("repo")
201
- .description("Show current repository metrics")
202
- .option("--json", "Output as JSON")
203
- .action(async (options) => {
204
- const spinner = ora("Loading repository metrics...").start();
205
-
206
- try {
207
- const repoInfo = await getRepoInfo();
208
- if (!repoInfo) {
209
- spinner.fail("Not in a git repository");
210
- process.exit(1);
211
- }
212
-
213
- const res = await getWithAuth(`/api/repos/${repoInfo.owner}/${repoInfo.name}/metrics`);
214
-
215
- spinner.stop();
216
-
217
- if (options.json) {
218
- console.log(JSON.stringify(res, null, 2));
219
- return;
220
- }
221
-
222
- const m = res.metrics || {};
223
-
224
- console.log(chalk.bold(`\n📈 Repository Metrics: ${repoInfo.owner}/${repoInfo.name}\n`));
225
-
226
- const table = new Table();
227
- table.push(
228
- [chalk.gray("Stars"), chalk.yellow(`⭐ ${m.stars || 0}`)],
229
- [chalk.gray("Forks"), chalk.blue(`🍴 ${m.forks || 0}`)],
230
- [chalk.gray("Open PRs"), chalk.green(`📝 ${m.openPrs || 0}`)],
231
- [chalk.gray("Open Issues"), chalk.red(`🐛 ${m.openIssues || 0}`)],
232
- [chalk.gray("Contributors"), chalk.cyan(`👥 ${m.contributors || 0}`)],
233
- [chalk.gray("Commits (30d)"), chalk.magenta(`📊 ${m.recentCommits || 0}`)],
234
- );
235
-
236
- console.log(table.toString());
237
- console.log();
238
- } catch (error: any) {
239
- spinner.fail(`Failed: ${error.message}`);
240
- process.exit(1);
241
- }
242
- });
217
+ .command("repo")
218
+ .description("Show current repository metrics")
219
+ .option("--json", "Output as JSON")
220
+ .action(async (options) => {
221
+ const spinner = ora("Loading repository metrics...").start();
222
+
223
+ try {
224
+ const repoInfo = await getRepoInfo();
225
+ if (!repoInfo) {
226
+ spinner.fail("Not in a git repository");
227
+ process.exit(1);
228
+ }
229
+
230
+ const res = await getWithAuth(
231
+ `/api/repos/${repoInfo.owner}/${repoInfo.name}/metrics`,
232
+ );
233
+
234
+ spinner.stop();
235
+
236
+ if (options.json) {
237
+ console.log(JSON.stringify(res, null, 2));
238
+ return;
239
+ }
240
+
241
+ const m = res.metrics || {};
242
+
243
+ console.log(
244
+ chalk.bold(
245
+ `\n📈 Repository Metrics: ${repoInfo.owner}/${repoInfo.name}\n`,
246
+ ),
247
+ );
248
+
249
+ const table = new Table();
250
+ table.push(
251
+ [chalk.gray("Stars"), chalk.yellow(`⭐ ${m.stars || 0}`)],
252
+ [chalk.gray("Forks"), chalk.blue(`🍴 ${m.forks || 0}`)],
253
+ [chalk.gray("Open PRs"), chalk.green(`📝 ${m.openPrs || 0}`)],
254
+ [chalk.gray("Open Issues"), chalk.red(`🐛 ${m.openIssues || 0}`)],
255
+ [chalk.gray("Contributors"), chalk.cyan(`👥 ${m.contributors || 0}`)],
256
+ [
257
+ chalk.gray("Commits (30d)"),
258
+ chalk.magenta(`📊 ${m.recentCommits || 0}`),
259
+ ],
260
+ );
261
+
262
+ console.log(table.toString());
263
+ console.log();
264
+ } catch (error: any) {
265
+ spinner.fail(`Failed: ${error.message}`);
266
+ process.exit(1);
267
+ }
268
+ });
243
269
244
270
function formatPeriod(period: string): string {
245
- const labels: Record<string, string> = {
246
- "1w": "1 week",
247
- "4w": "4 weeks",
248
- "3m": "3 months",
249
- "1y": "1 year",
250
- };
251
- return labels[period] || period;
271
+ const labels: Record<string, string> = {
272
+ "1w": "1 week",
273
+ "4w": "4 weeks",
274
+ "3m": "3 months",
275
+ "1y": "1 year",
276
+ };
277
+ return labels[period] || period;
252
278
}
253
279
254
280
function formatDuration(hours: number): string {
255
- if (!hours || hours === 0) return "N/A";
256
- if (hours < 1) return "< 1h";
257
- if (hours < 24) return `${Math.round(hours)}h`;
258
- return `${Math.round(hours / 24)}d`;
281
+ if (!hours || hours === 0) return "N/A";
282
+ if (hours < 1) return "< 1h";
283
+ if (hours < 24) return `${Math.round(hours)}h`;
284
+ return `${Math.round(hours / 24)}d`;
259
285
}
260
286
261
287
export default insightsCommand;
262
288
▼cli/src/commands/issue/index.ts
+358-332
3
3
* Manage issues from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
7
+import { Command } from "commander";
9
8
import inquirer from "inquirer";
9
+import ora from "ora";
10
10
import { simpleGit } from "simple-git";
11
+import { getWithAuth, patchWithAuth, postWithAuth } from "../../lib/api.js";
11
12
import { getConfig } from "../../lib/config.js";
12
-import { getWithAuth, postWithAuth, patchWithAuth } from "../../lib/api.js";
13
+import { getRepoInfoFromGit } from "../../lib/git.js";
13
14
14
15
const git = simpleGit();
15
16
16
17
interface Issue {
17
- id: string;
18
- number: number;
19
- title: string;
20
- state: string;
21
- author: { username: string };
22
- createdAt: string;
23
- updatedAt: string;
24
- labels?: { name: string; color: string }[];
25
- assignees?: { username: string }[];
26
- milestone?: { title: string };
18
+ id: string;
19
+ number: number;
20
+ title: string;
21
+ state: string;
22
+ author: { username: string };
23
+ createdAt: string;
24
+ updatedAt: string;
25
+ labels?: { name: string; color: string }[];
26
+ assignees?: { username: string }[];
27
+ milestone?: { title: string };
27
28
}
28
29
29
-export const issueCommands = new Command("issue")
30
- .description("Manage issues");
30
+export const issueCommands = new Command("issue").description("Manage issues");
31
31
32
32
// Get repo info from git remote
33
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
34
- try {
35
- const remotes = await git.getRemotes(true);
36
- const origin = remotes.find(r => r.name === "origin");
37
- if (!origin?.refs?.push) return null;
38
-
39
- const url = origin.refs.push;
40
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
41
- if (match) {
42
- return { owner: match[1], repo: match[2] };
43
- }
44
- return null;
45
- } catch {
46
- return null;
47
- }
33
+async function getRepoInfo() {
34
+ return getRepoInfoFromGit(git);
48
35
}
49
36
50
37
// Issue Create
51
38
issueCommands
52
- .command("create")
53
- .description("Create a new issue")
54
- .option("-t, --title <title>", "Issue title")
55
- .option("-b, --body <body>", "Issue description")
56
- .option("-a, --assignee <user>", "Assign to user")
57
- .option("-l, --label <label>", "Add label", (val, acc: string[]) => [...acc, val], [])
58
- .option("-m, --milestone <name>", "Add to milestone")
59
- .action(async (options) => {
60
- const config = getConfig();
61
- if (!config.token) {
62
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
63
- process.exit(1);
64
- }
65
-
66
- try {
67
- const repoInfo = await getRepoInfo();
68
- if (!repoInfo) {
69
- console.error(chalk.red("Could not determine repository"));
70
- process.exit(1);
71
- }
72
-
73
- let title = options.title;
74
- let body = options.body;
75
-
76
- if (!title) {
77
- const answers = await inquirer.prompt([
78
- {
79
- type: "input",
80
- name: "title",
81
- message: "Issue Title:",
82
- validate: (input) => input.length > 0 || "Title is required",
83
- },
84
- {
85
- type: "editor",
86
- name: "body",
87
- message: "Issue Description (optional):",
88
- },
89
- ]);
90
- title = answers.title;
91
- body = answers.body;
92
- }
93
-
94
- const spinner = ora("Creating issue...").start();
95
-
96
- const result = await postWithAuth<{ data: Issue }>(
97
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues`,
98
- {
99
- title,
100
- body,
101
- assignee: options.assignee,
102
- labels: options.label,
103
- milestone: options.milestone,
104
- }
105
- );
106
-
107
- spinner.succeed(`Created issue #${result.data.number}: ${result.data.title}`);
108
- console.log(chalk.dim(` ${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/issues/${result.data.number}`));
109
- } catch (error) {
110
- console.error(chalk.red("Failed to create issue"));
111
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
112
- process.exit(1);
113
- }
114
- });
39
+ .command("create")
40
+ .description("Create a new issue")
41
+ .option("-t, --title <title>", "Issue title")
42
+ .option("-b, --body <body>", "Issue description")
43
+ .option("-a, --assignee <user>", "Assign to user")
44
+ .option(
45
+ "-l, --label <label>",
46
+ "Add label",
47
+ (val, acc: string[]) => [...acc, val],
48
+ [],
49
+ )
50
+ .option("-m, --milestone <name>", "Add to milestone")
51
+ .action(async (options) => {
52
+ const config = getConfig();
53
+ if (!config.token) {
54
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
55
+ process.exit(1);
56
+ }
57
+
58
+ try {
59
+ const repoInfo = await getRepoInfo();
60
+ if (!repoInfo) {
61
+ console.error(chalk.red("Could not determine repository"));
62
+ process.exit(1);
63
+ }
64
+
65
+ let title = options.title;
66
+ let body = options.body;
67
+
68
+ if (!title) {
69
+ const answers = await inquirer.prompt([
70
+ {
71
+ type: "input",
72
+ name: "title",
73
+ message: "Issue Title:",
74
+ validate: (input) => input.length > 0 || "Title is required",
75
+ },
76
+ {
77
+ type: "editor",
78
+ name: "body",
79
+ message: "Issue Description (optional):",
80
+ },
81
+ ]);
82
+ title = answers.title;
83
+ body = answers.body;
84
+ }
85
+
86
+ const spinner = ora("Creating issue...").start();
87
+
88
+ const result = await postWithAuth<{ data: Issue }>(
89
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues`,
90
+ {
91
+ title,
92
+ body,
93
+ assignee: options.assignee,
94
+ labels: options.label,
95
+ milestone: options.milestone,
96
+ },
97
+ );
98
+
99
+ spinner.succeed(
100
+ `Created issue #${result.data.number}: ${result.data.title}`,
101
+ );
102
+ console.log(
103
+ chalk.dim(
104
+ ` ${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/issues/${result.data.number}`,
105
+ ),
106
+ );
107
+ } catch (error) {
108
+ console.error(chalk.red("Failed to create issue"));
109
+ console.error(
110
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
111
+ );
112
+ process.exit(1);
113
+ }
114
+ });
115
115
116
116
// Issue List
117
117
issueCommands
118
- .command("list")
119
- .alias("ls")
120
- .description("List issues")
121
- .option("-s, --state <state>", "Filter by state (open, closed, all)", "open")
122
- .option("-a, --author <user>", "Filter by author")
123
- .option("-A, --assignee <user>", "Filter by assignee")
124
- .option("-l, --label <label>", "Filter by label")
125
- .option("-m, --milestone <name>", "Filter by milestone")
126
- .option("-L, --limit <n>", "Maximum results", "30")
127
- .action(async (options) => {
128
- const config = getConfig();
129
- if (!config.token) {
130
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
131
- process.exit(1);
132
- }
133
-
134
- const spinner = ora("Fetching issues...").start();
135
-
136
- try {
137
- const repoInfo = await getRepoInfo();
138
- if (!repoInfo) {
139
- spinner.fail("Could not determine repository");
140
- process.exit(1);
141
- }
142
-
143
- const params = new URLSearchParams();
144
- if (options.state !== "all") params.append("state", options.state);
145
- if (options.author) params.append("author", options.author);
146
- if (options.assignee) params.append("assignee", options.assignee);
147
- if (options.label) params.append("label", options.label);
148
- if (options.milestone) params.append("milestone", options.milestone);
149
- params.append("limit", options.limit);
150
-
151
- const result = await getWithAuth<{ data: Issue[] }>(
152
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues?${params}`
153
- );
154
-
155
- spinner.stop();
156
-
157
- if (result.data.length === 0) {
158
- console.log(chalk.dim("No issues found."));
159
- return;
160
- }
161
-
162
- console.log(chalk.bold(`\n📋 Issues (${result.data.length})\n`));
163
-
164
- for (const issue of result.data) {
165
- const stateIcon = issue.state === "open" ? chalk.green("●") : chalk.red("●");
166
- const labels = issue.labels?.map(l => chalk.hex(l.color || "#888")(l.name)).join(" ") || "";
167
-
168
- console.log(`${stateIcon} #${issue.number} ${issue.title} ${labels}`);
169
- console.log(chalk.dim(` @${issue.author.username} • ${new Date(issue.createdAt).toLocaleDateString()}`));
170
- }
171
-
172
- console.log("");
173
- } catch (error) {
174
- spinner.fail("Failed to list issues");
175
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
176
- process.exit(1);
177
- }
178
- });
118
+ .command("list")
119
+ .alias("ls")
120
+ .description("List issues")
121
+ .option("-s, --state <state>", "Filter by state (open, closed, all)", "open")
122
+ .option("-a, --author <user>", "Filter by author")
123
+ .option("-A, --assignee <user>", "Filter by assignee")
124
+ .option("-l, --label <label>", "Filter by label")
125
+ .option("-m, --milestone <name>", "Filter by milestone")
126
+ .option("-L, --limit <n>", "Maximum results", "30")
127
+ .action(async (options) => {
128
+ const config = getConfig();
129
+ if (!config.token) {
130
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
131
+ process.exit(1);
132
+ }
133
+
134
+ const spinner = ora("Fetching issues...").start();
135
+
136
+ try {
137
+ const repoInfo = await getRepoInfo();
138
+ if (!repoInfo) {
139
+ spinner.fail("Could not determine repository");
140
+ process.exit(1);
141
+ }
142
+
143
+ const params = new URLSearchParams();
144
+ if (options.state !== "all") params.append("state", options.state);
145
+ if (options.author) params.append("author", options.author);
146
+ if (options.assignee) params.append("assignee", options.assignee);
147
+ if (options.label) params.append("label", options.label);
148
+ if (options.milestone) params.append("milestone", options.milestone);
149
+ params.append("limit", options.limit);
150
+
151
+ const result = await getWithAuth<{ data: Issue[] }>(
152
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues?${params}`,
153
+ );
154
+
155
+ spinner.stop();
156
+
157
+ if (result.data.length === 0) {
158
+ console.log(chalk.dim("No issues found."));
159
+ return;
160
+ }
161
+
162
+ console.log(chalk.bold(`\n📋 Issues (${result.data.length})\n`));
163
+
164
+ for (const issue of result.data) {
165
+ const stateIcon =
166
+ issue.state === "open" ? chalk.green("●") : chalk.red("●");
167
+ const labels =
168
+ issue.labels
169
+ ?.map((l) => chalk.hex(l.color || "#888")(l.name))
170
+ .join(" ") || "";
171
+
172
+ console.log(`${stateIcon} #${issue.number} ${issue.title} ${labels}`);
173
+ console.log(
174
+ chalk.dim(
175
+ ` @${issue.author.username} • ${new Date(issue.createdAt).toLocaleDateString()}`,
176
+ ),
177
+ );
178
+ }
179
+
180
+ console.log("");
181
+ } catch (error) {
182
+ spinner.fail("Failed to list issues");
183
+ console.error(
184
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
185
+ );
186
+ process.exit(1);
187
+ }
188
+ });
179
189
180
190
// Issue View
181
191
issueCommands
182
- .command("view <number>")
183
- .description("View an issue")
184
- .option("-c, --comments", "Show comments")
185
- .action(async (number: string, options) => {
186
- const config = getConfig();
187
- if (!config.token) {
188
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
189
- process.exit(1);
190
- }
191
-
192
- const spinner = ora("Fetching issue...").start();
193
-
194
- try {
195
- const repoInfo = await getRepoInfo();
196
- if (!repoInfo) {
197
- spinner.fail("Could not determine repository");
198
- process.exit(1);
199
- }
200
-
201
- const result = await getWithAuth<{ data: Issue & { body?: string } }>(
202
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`
203
- );
204
-
205
- spinner.stop();
206
-
207
- const issue = result.data;
208
- const stateColor = issue.state === "open" ? chalk.green : chalk.red;
209
-
210
- console.log(chalk.bold(`\n#${issue.number} ${issue.title}\n`));
211
- console.log(`State: ${stateColor(issue.state.toUpperCase())}`);
212
- console.log(`Author: @${issue.author.username}`);
213
- console.log(`Created: ${new Date(issue.createdAt).toLocaleDateString()}`);
214
-
215
- if (issue.labels && issue.labels.length > 0) {
216
- const labels = issue.labels.map(l => chalk.hex(l.color || "#888")(l.name)).join(", ");
217
- console.log(`Labels: ${labels}`);
218
- }
219
-
220
- if (issue.assignees && issue.assignees.length > 0) {
221
- const assignees = issue.assignees.map(a => `@${a.username}`).join(", ");
222
- console.log(`Assignees: ${assignees}`);
223
- }
224
-
225
- if (issue.milestone) {
226
- console.log(`Milestone: ${issue.milestone.title}`);
227
- }
228
-
229
- if (result.data.body) {
230
- console.log(chalk.dim("\n─".repeat(40)));
231
- console.log(result.data.body);
232
- }
233
-
234
- console.log(chalk.dim("\n─".repeat(40)));
235
- console.log(chalk.dim(`${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`));
236
- console.log("");
237
- } catch (error) {
238
- spinner.fail("Failed to fetch issue");
239
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
240
- process.exit(1);
241
- }
242
- });
192
+ .command("view <number>")
193
+ .description("View an issue")
194
+ .option("-c, --comments", "Show comments")
195
+ .action(async (number: string, options) => {
196
+ const config = getConfig();
197
+ if (!config.token) {
198
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
199
+ process.exit(1);
200
+ }
201
+
202
+ const spinner = ora("Fetching issue...").start();
203
+
204
+ try {
205
+ const repoInfo = await getRepoInfo();
206
+ if (!repoInfo) {
207
+ spinner.fail("Could not determine repository");
208
+ process.exit(1);
209
+ }
210
+
211
+ const result = await getWithAuth<{ data: Issue & { body?: string } }>(
212
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`,
213
+ );
214
+
215
+ spinner.stop();
216
+
217
+ const issue = result.data;
218
+ const stateColor = issue.state === "open" ? chalk.green : chalk.red;
219
+
220
+ console.log(chalk.bold(`\n#${issue.number} ${issue.title}\n`));
221
+ console.log(`State: ${stateColor(issue.state.toUpperCase())}`);
222
+ console.log(`Author: @${issue.author.username}`);
223
+ console.log(`Created: ${new Date(issue.createdAt).toLocaleDateString()}`);
224
+
225
+ if (issue.labels && issue.labels.length > 0) {
226
+ const labels = issue.labels
227
+ .map((l) => chalk.hex(l.color || "#888")(l.name))
228
+ .join(", ");
229
+ console.log(`Labels: ${labels}`);
230
+ }
231
+
232
+ if (issue.assignees && issue.assignees.length > 0) {
233
+ const assignees = issue.assignees
234
+ .map((a) => `@${a.username}`)
235
+ .join(", ");
236
+ console.log(`Assignees: ${assignees}`);
237
+ }
238
+
239
+ if (issue.milestone) {
240
+ console.log(`Milestone: ${issue.milestone.title}`);
241
+ }
242
+
243
+ if (result.data.body) {
244
+ console.log(chalk.dim("\n─".repeat(40)));
245
+ console.log(result.data.body);
246
+ }
247
+
248
+ console.log(chalk.dim("\n─".repeat(40)));
249
+ console.log(
250
+ chalk.dim(
251
+ `${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`,
252
+ ),
253
+ );
254
+ console.log("");
255
+ } catch (error) {
256
+ spinner.fail("Failed to fetch issue");
257
+ console.error(
258
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
259
+ );
260
+ process.exit(1);
261
+ }
262
+ });
243
263
244
264
// Issue Close
245
265
issueCommands
246
- .command("close <number>")
247
- .description("Close an issue")
248
- .option("-c, --comment <comment>", "Add a closing comment")
249
- .action(async (number: string, options) => {
250
- const config = getConfig();
251
- if (!config.token) {
252
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
253
- process.exit(1);
254
- }
255
-
256
- const spinner = ora(`Closing issue #${number}...`).start();
257
-
258
- try {
259
- const repoInfo = await getRepoInfo();
260
- if (!repoInfo) {
261
- spinner.fail("Could not determine repository");
262
- process.exit(1);
263
- }
264
-
265
- if (options.comment) {
266
- await postWithAuth(
267
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}/comments`,
268
- { body: options.comment }
269
- );
270
- }
271
-
272
- await patchWithAuth(
273
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`,
274
- { state: "closed" }
275
- );
276
-
277
- spinner.succeed(`Closed issue #${number}`);
278
- } catch (error) {
279
- spinner.fail("Failed to close issue");
280
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
281
- process.exit(1);
282
- }
283
- });
266
+ .command("close <number>")
267
+ .description("Close an issue")
268
+ .option("-c, --comment <comment>", "Add a closing comment")
269
+ .action(async (number: string, options) => {
270
+ const config = getConfig();
271
+ if (!config.token) {
272
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
273
+ process.exit(1);
274
+ }
275
+
276
+ const spinner = ora(`Closing issue #${number}...`).start();
277
+
278
+ try {
279
+ const repoInfo = await getRepoInfo();
280
+ if (!repoInfo) {
281
+ spinner.fail("Could not determine repository");
282
+ process.exit(1);
283
+ }
284
+
285
+ if (options.comment) {
286
+ await postWithAuth(
287
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}/comments`,
288
+ { body: options.comment },
289
+ );
290
+ }
291
+
292
+ await patchWithAuth(
293
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`,
294
+ { state: "closed" },
295
+ );
296
+
297
+ spinner.succeed(`Closed issue #${number}`);
298
+ } catch (error) {
299
+ spinner.fail("Failed to close issue");
300
+ console.error(
301
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
302
+ );
303
+ process.exit(1);
304
+ }
305
+ });
284
306
285
307
// Issue Reopen
286
308
issueCommands
287
- .command("reopen <number>")
288
- .description("Reopen an issue")
289
- .action(async (number: string) => {
290
- const config = getConfig();
291
- if (!config.token) {
292
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
293
- process.exit(1);
294
- }
295
-
296
- const spinner = ora(`Reopening issue #${number}...`).start();
297
-
298
- try {
299
- const repoInfo = await getRepoInfo();
300
- if (!repoInfo) {
301
- spinner.fail("Could not determine repository");
302
- process.exit(1);
303
- }
304
-
305
- await patchWithAuth(
306
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`,
307
- { state: "open" }
308
- );
309
-
310
- spinner.succeed(`Reopened issue #${number}`);
311
- } catch (error) {
312
- spinner.fail("Failed to reopen issue");
313
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
314
- process.exit(1);
315
- }
316
- });
309
+ .command("reopen <number>")
310
+ .description("Reopen an issue")
311
+ .action(async (number: string) => {
312
+ const config = getConfig();
313
+ if (!config.token) {
314
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
315
+ process.exit(1);
316
+ }
317
+
318
+ const spinner = ora(`Reopening issue #${number}...`).start();
319
+
320
+ try {
321
+ const repoInfo = await getRepoInfo();
322
+ if (!repoInfo) {
323
+ spinner.fail("Could not determine repository");
324
+ process.exit(1);
325
+ }
326
+
327
+ await patchWithAuth(
328
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}`,
329
+ { state: "open" },
330
+ );
331
+
332
+ spinner.succeed(`Reopened issue #${number}`);
333
+ } catch (error) {
334
+ spinner.fail("Failed to reopen issue");
335
+ console.error(
336
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
337
+ );
338
+ process.exit(1);
339
+ }
340
+ });
317
341
318
342
// Issue Comment
319
343
issueCommands
320
- .command("comment <number>")
321
- .description("Add a comment to an issue")
322
- .option("-b, --body <body>", "Comment body")
323
- .action(async (number: string, options) => {
324
- const config = getConfig();
325
- if (!config.token) {
326
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
327
- process.exit(1);
328
- }
329
-
330
- try {
331
- const repoInfo = await getRepoInfo();
332
- if (!repoInfo) {
333
- console.error(chalk.red("Could not determine repository"));
334
- process.exit(1);
335
- }
336
-
337
- let body = options.body;
338
-
339
- if (!body) {
340
- const answers = await inquirer.prompt([
341
- {
342
- type: "editor",
343
- name: "body",
344
- message: "Comment:",
345
- },
346
- ]);
347
- body = answers.body;
348
- }
349
-
350
- if (!body || body.trim() === "") {
351
- console.error(chalk.red("Comment cannot be empty"));
352
- process.exit(1);
353
- }
354
-
355
- const spinner = ora("Adding comment...").start();
356
-
357
- await postWithAuth(
358
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}/comments`,
359
- { body }
360
- );
361
-
362
- spinner.succeed(`Added comment to issue #${number}`);
363
- } catch (error) {
364
- console.error(chalk.red("Failed to add comment"));
365
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
366
- process.exit(1);
367
- }
368
- });
344
+ .command("comment <number>")
345
+ .description("Add a comment to an issue")
346
+ .option("-b, --body <body>", "Comment body")
347
+ .action(async (number: string, options) => {
348
+ const config = getConfig();
349
+ if (!config.token) {
350
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
351
+ process.exit(1);
352
+ }
353
+
354
+ try {
355
+ const repoInfo = await getRepoInfo();
356
+ if (!repoInfo) {
357
+ console.error(chalk.red("Could not determine repository"));
358
+ process.exit(1);
359
+ }
360
+
361
+ let body = options.body;
362
+
363
+ if (!body) {
364
+ const answers = await inquirer.prompt([
365
+ {
366
+ type: "editor",
367
+ name: "body",
368
+ message: "Comment:",
369
+ },
370
+ ]);
371
+ body = answers.body;
372
+ }
373
+
374
+ if (!body || body.trim() === "") {
375
+ console.error(chalk.red("Comment cannot be empty"));
376
+ process.exit(1);
377
+ }
378
+
379
+ const spinner = ora("Adding comment...").start();
380
+
381
+ await postWithAuth(
382
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}/comments`,
383
+ { body },
384
+ );
385
+
386
+ spinner.succeed(`Added comment to issue #${number}`);
387
+ } catch (error) {
388
+ console.error(chalk.red("Failed to add comment"));
389
+ console.error(
390
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
391
+ );
392
+ process.exit(1);
393
+ }
394
+ });
369
395
370
396
export default issueCommands;
371
397
▼cli/src/commands/metrics/index.ts
+167-159
3
3
* View developer metrics from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
7
+import { Command } from "commander";
8
8
import ora from "ora";
9
9
import { simpleGit } from "simple-git";
10
-import { getConfig } from "../../lib/config.js";
11
10
import { getWithAuth } from "../../lib/api.js";
11
+import { getConfig } from "../../lib/config.js";
12
+import { getRepoInfoFromGit } from "../../lib/git.js";
12
13
13
14
const git = simpleGit();
14
15
15
16
interface UserMetrics {
16
- authored: {
17
- total: number;
18
- merged: number;
19
- avgTimeToMerge: number;
20
- };
21
- reviewed: {
22
- total: number;
23
- approvals: number;
24
- changesRequested: number;
25
- };
26
- trends: Array<{
27
- week: string;
28
- authored: number;
29
- reviewed: number;
30
- }>;
17
+ authored: {
18
+ total: number;
19
+ merged: number;
20
+ avgTimeToMerge: number;
21
+ };
22
+ reviewed: {
23
+ total: number;
24
+ approvals: number;
25
+ changesRequested: number;
26
+ };
27
+ trends: Array<{
28
+ week: string;
29
+ authored: number;
30
+ reviewed: number;
31
+ }>;
31
32
}
32
33
33
34
interface RepoMetrics {
34
- period: string;
35
- prsOpened: number;
36
- prsMerged: number;
37
- avgTimeToFirstReview: number;
38
- avgTimeToMerge: number;
39
- avgReviewRounds: number;
40
- topAuthors: Array<{ username: string; prCount: number }>;
41
- topReviewers: Array<{ username: string; reviewCount: number }>;
35
+ period: string;
36
+ prsOpened: number;
37
+ prsMerged: number;
38
+ avgTimeToFirstReview: number;
39
+ avgTimeToMerge: number;
40
+ avgReviewRounds: number;
41
+ topAuthors: Array<{ username: string; prCount: number }>;
42
+ topReviewers: Array<{ username: string; reviewCount: number }>;
42
43
}
43
44
44
-export const metricsCommands = new Command("metrics")
45
- .description("View developer metrics");
45
+export const metricsCommands = new Command("metrics").description(
46
+ "View developer metrics",
47
+);
46
48
47
49
// Get repo info from git remote
48
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
49
- try {
50
- const remotes = await git.getRemotes(true);
51
- const origin = remotes.find(r => r.name === "origin");
52
- if (!origin?.refs?.push) return null;
53
-
54
- const url = origin.refs.push;
55
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
56
- if (match) {
57
- return { owner: match[1], repo: match[2] };
58
- }
59
- return null;
60
- } catch {
61
- return null;
62
- }
50
+async function getRepoInfo() {
51
+ return getRepoInfoFromGit(git);
63
52
}
64
53
65
54
function formatDuration(hours: number): string {
66
- if (hours < 1) return `${Math.round(hours * 60)}m`;
67
- if (hours < 24) return `${hours.toFixed(1)}h`;
68
- return `${(hours / 24).toFixed(1)}d`;
55
+ if (hours < 1) return `${Math.round(hours * 60)}m`;
56
+ if (hours < 24) return `${hours.toFixed(1)}h`;
57
+ return `${(hours / 24).toFixed(1)}d`;
69
58
}
70
59
71
60
function progressBar(value: number, max: number, width: number = 20): string {
72
- const filled = Math.round((value / max) * width);
73
- const empty = width - filled;
74
- return chalk.green("━".repeat(filled)) + chalk.dim("━".repeat(empty));
61
+ const filled = Math.round((value / max) * width);
62
+ const empty = width - filled;
63
+ return chalk.green("━".repeat(filled)) + chalk.dim("━".repeat(empty));
75
64
}
76
65
77
66
// Metrics Show
78
67
metricsCommands
79
- .command("show")
80
- .description("Show metrics")
81
- .option("-u, --user <username>", "Show metrics for specific user")
82
- .option("-r, --repo", "Show repository metrics")
83
- .option("-w, --weeks <n>", "Number of weeks to show", "4")
84
- .action(async (options) => {
85
- const config = getConfig();
86
- if (!config.token) {
87
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
88
- process.exit(1);
89
- }
68
+ .command("show")
69
+ .description("Show metrics")
70
+ .option("-u, --user <username>", "Show metrics for specific user")
71
+ .option("-r, --repo", "Show repository metrics")
72
+ .option("-w, --weeks <n>", "Number of weeks to show", "4")
73
+ .action(async (options) => {
74
+ const config = getConfig();
75
+ if (!config.token) {
76
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
77
+ process.exit(1);
78
+ }
79
+
80
+ const spinner = ora("Fetching metrics...").start();
90
81
91
- const spinner = ora("Fetching metrics...").start();
92
-
93
- try {
94
- if (options.repo) {
95
- // Repository metrics
96
- const repoInfo = await getRepoInfo();
97
- if (!repoInfo) {
98
- spinner.fail("Could not determine repository");
99
- process.exit(1);
100
- }
101
-
102
- const result = await getWithAuth<{ data: RepoMetrics[] }>(
103
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/metrics?weeks=${options.weeks}`
104
- );
105
-
106
- spinner.stop();
107
- displayRepoMetrics(result.data, `${repoInfo.owner}/${repoInfo.repo}`);
108
- } else {
109
- // User metrics
110
- const endpoint = options.user
111
- ? `/api/users/${options.user}/metrics?weeks=${options.weeks}`
112
- : `/api/user/metrics?weeks=${options.weeks}`;
113
-
114
- const result = await getWithAuth<{ data: UserMetrics }>(endpoint);
115
-
116
- spinner.stop();
117
- displayUserMetrics(result.data, options.user || "You");
118
- }
119
- } catch (error) {
120
- spinner.fail("Failed to fetch metrics");
121
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
122
- process.exit(1);
82
+ try {
83
+ if (options.repo) {
84
+ // Repository metrics
85
+ const repoInfo = await getRepoInfo();
86
+ if (!repoInfo) {
87
+ spinner.fail("Could not determine repository");
88
+ process.exit(1);
123
89
}
124
- });
90
+
91
+ const result = await getWithAuth<{ data: RepoMetrics[] }>(
92
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/metrics?weeks=${options.weeks}`,
93
+ );
94
+
95
+ spinner.stop();
96
+ displayRepoMetrics(result.data, `${repoInfo.owner}/${repoInfo.repo}`);
97
+ } else {
98
+ // User metrics
99
+ const endpoint = options.user
100
+ ? `/api/users/${options.user}/metrics?weeks=${options.weeks}`
101
+ : `/api/user/metrics?weeks=${options.weeks}`;
102
+
103
+ const result = await getWithAuth<{ data: UserMetrics }>(endpoint);
104
+
105
+ spinner.stop();
106
+ displayUserMetrics(result.data, options.user || "You");
107
+ }
108
+ } catch (error) {
109
+ spinner.fail("Failed to fetch metrics");
110
+ console.error(
111
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
112
+ );
113
+ process.exit(1);
114
+ }
115
+ });
125
116
126
117
function displayUserMetrics(metrics: UserMetrics, username: string) {
127
- console.log(chalk.bold(`\n📊 Metrics for ${username}\n`));
128
-
129
- // Authored PRs
130
- console.log(chalk.bold("PRs Authored"));
131
- console.log(` Total: ${metrics.authored.total}`);
132
- console.log(` Merged: ${metrics.authored.merged}`);
133
- console.log(` Avg Merge: ${formatDuration(metrics.authored.avgTimeToMerge)}`);
134
-
135
- // Reviews
136
- console.log(chalk.bold("\nReviews Given"));
137
- console.log(` Total: ${metrics.reviewed.total}`);
138
- console.log(` Approvals: ${metrics.reviewed.approvals}`);
139
- console.log(` Changes Requested: ${metrics.reviewed.changesRequested}`);
140
-
141
- // Trends
142
- if (metrics.trends && metrics.trends.length > 0) {
143
- console.log(chalk.bold("\nWeekly Trend"));
144
- const maxAuthored = Math.max(...metrics.trends.map(t => t.authored), 1);
145
- const maxReviewed = Math.max(...metrics.trends.map(t => t.reviewed), 1);
146
-
147
- for (const week of metrics.trends) {
148
- console.log(chalk.dim(` ${week.week}`));
149
- console.log(` Authored: ${progressBar(week.authored, maxAuthored)} ${week.authored}`);
150
- console.log(` Reviewed: ${progressBar(week.reviewed, maxReviewed)} ${week.reviewed}`);
151
- }
118
+ console.log(chalk.bold(`\n📊 Metrics for ${username}\n`));
119
+
120
+ // Authored PRs
121
+ console.log(chalk.bold("PRs Authored"));
122
+ console.log(` Total: ${metrics.authored.total}`);
123
+ console.log(` Merged: ${metrics.authored.merged}`);
124
+ console.log(
125
+ ` Avg Merge: ${formatDuration(metrics.authored.avgTimeToMerge)}`,
126
+ );
127
+
128
+ // Reviews
129
+ console.log(chalk.bold("\nReviews Given"));
130
+ console.log(` Total: ${metrics.reviewed.total}`);
131
+ console.log(` Approvals: ${metrics.reviewed.approvals}`);
132
+ console.log(` Changes Requested: ${metrics.reviewed.changesRequested}`);
133
+
134
+ // Trends
135
+ if (metrics.trends && metrics.trends.length > 0) {
136
+ console.log(chalk.bold("\nWeekly Trend"));
137
+ const maxAuthored = Math.max(...metrics.trends.map((t) => t.authored), 1);
138
+ const maxReviewed = Math.max(...metrics.trends.map((t) => t.reviewed), 1);
139
+
140
+ for (const week of metrics.trends) {
141
+ console.log(chalk.dim(` ${week.week}`));
142
+ console.log(
143
+ ` Authored: ${progressBar(week.authored, maxAuthored)} ${week.authored}`,
144
+ );
145
+ console.log(
146
+ ` Reviewed: ${progressBar(week.reviewed, maxReviewed)} ${week.reviewed}`,
147
+ );
152
148
}
149
+ }
153
150
154
- console.log("");
151
+ console.log("");
155
152
}
156
153
157
154
function displayRepoMetrics(metrics: RepoMetrics[], repoName: string) {
158
- console.log(chalk.bold(`\n📊 Metrics for ${repoName}\n`));
159
-
160
- if (metrics.length === 0) {
161
- console.log(chalk.dim("No metrics data available."));
162
- return;
163
- }
164
-
165
- const latest = metrics[0];
166
-
167
- // PR Activity
168
- console.log(chalk.bold("PR Activity (Latest Period)"));
169
- console.log(` PRs Opened: ${latest.prsOpened}`);
170
- console.log(` PRs Merged: ${latest.prsMerged}`);
171
- console.log(` Avg First Review: ${formatDuration(latest.avgTimeToFirstReview)}`);
172
- console.log(` Avg Time to Merge: ${formatDuration(latest.avgTimeToMerge)}`);
173
- console.log(` Avg Review Rounds: ${latest.avgReviewRounds.toFixed(1)}`);
174
-
175
- // Top Authors
176
- if (latest.topAuthors && latest.topAuthors.length > 0) {
177
- console.log(chalk.bold("\nTop Authors"));
178
- const maxPRs = Math.max(...latest.topAuthors.map(a => a.prCount), 1);
179
- for (const author of latest.topAuthors.slice(0, 5)) {
180
- console.log(` @${author.username.padEnd(15)} ${progressBar(author.prCount, maxPRs, 15)} ${author.prCount}`);
181
- }
155
+ console.log(chalk.bold(`\n📊 Metrics for ${repoName}\n`));
156
+
157
+ if (metrics.length === 0) {
158
+ console.log(chalk.dim("No metrics data available."));
159
+ return;
160
+ }
161
+
162
+ const latest = metrics[0];
163
+
164
+ // PR Activity
165
+ console.log(chalk.bold("PR Activity (Latest Period)"));
166
+ console.log(` PRs Opened: ${latest.prsOpened}`);
167
+ console.log(` PRs Merged: ${latest.prsMerged}`);
168
+ console.log(
169
+ ` Avg First Review: ${formatDuration(latest.avgTimeToFirstReview)}`,
170
+ );
171
+ console.log(` Avg Time to Merge: ${formatDuration(latest.avgTimeToMerge)}`);
172
+ console.log(` Avg Review Rounds: ${latest.avgReviewRounds.toFixed(1)}`);
173
+
174
+ // Top Authors
175
+ if (latest.topAuthors && latest.topAuthors.length > 0) {
176
+ console.log(chalk.bold("\nTop Authors"));
177
+ const maxPRs = Math.max(...latest.topAuthors.map((a) => a.prCount), 1);
178
+ for (const author of latest.topAuthors.slice(0, 5)) {
179
+ console.log(
180
+ ` @${author.username.padEnd(15)} ${progressBar(author.prCount, maxPRs, 15)} ${author.prCount}`,
181
+ );
182
182
}
183
-
184
- // Top Reviewers
185
- if (latest.topReviewers && latest.topReviewers.length > 0) {
186
- console.log(chalk.bold("\nTop Reviewers"));
187
- const maxReviews = Math.max(...latest.topReviewers.map(r => r.reviewCount), 1);
188
- for (const reviewer of latest.topReviewers.slice(0, 5)) {
189
- console.log(` @${reviewer.username.padEnd(15)} ${progressBar(reviewer.reviewCount, maxReviews, 15)} ${reviewer.reviewCount}`);
190
- }
183
+ }
184
+
185
+ // Top Reviewers
186
+ if (latest.topReviewers && latest.topReviewers.length > 0) {
187
+ console.log(chalk.bold("\nTop Reviewers"));
188
+ const maxReviews = Math.max(
189
+ ...latest.topReviewers.map((r) => r.reviewCount),
190
+ 1,
191
+ );
192
+ for (const reviewer of latest.topReviewers.slice(0, 5)) {
193
+ console.log(
194
+ ` @${reviewer.username.padEnd(15)} ${progressBar(reviewer.reviewCount, maxReviews, 15)} ${reviewer.reviewCount}`,
195
+ );
191
196
}
192
-
193
- // Trends
194
- if (metrics.length > 1) {
195
- console.log(chalk.bold("\nWeekly Trend"));
196
- const maxMerged = Math.max(...metrics.map(m => m.prsMerged), 1);
197
- for (const week of metrics.slice(0, 4)) {
198
- console.log(` ${week.period}: ${progressBar(week.prsMerged, maxMerged)} ${week.prsMerged} merged`);
199
- }
197
+ }
198
+
199
+ // Trends
200
+ if (metrics.length > 1) {
201
+ console.log(chalk.bold("\nWeekly Trend"));
202
+ const maxMerged = Math.max(...metrics.map((m) => m.prsMerged), 1);
203
+ for (const week of metrics.slice(0, 4)) {
204
+ console.log(
205
+ ` ${week.period}: ${progressBar(week.prsMerged, maxMerged)} ${week.prsMerged} merged`,
206
+ );
200
207
}
208
+ }
201
209
202
- console.log("");
210
+ console.log("");
203
211
}
204
212
205
213
export default metricsCommands;
206
214
▼cli/src/commands/pr/index.ts
+440-393
3
3
* Manage pull requests from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
7
+import { Command } from "commander";
9
8
import inquirer from "inquirer";
9
+import ora from "ora";
10
10
import { simpleGit } from "simple-git";
11
+import { getWithAuth, patchWithAuth, postWithAuth } from "../../lib/api.js";
11
12
import { getConfig } from "../../lib/config.js";
12
-import { getWithAuth, postWithAuth, patchWithAuth, deleteWithAuth } from "../../lib/api.js";
13
+import { getRepoInfoFromGit } from "../../lib/git.js";
13
14
14
15
const git = simpleGit();
15
16
16
17
interface PullRequest {
17
- id: string;
18
- number: number;
19
- title: string;
20
- state: string;
21
- author: { username: string };
22
- sourceBranch: string;
23
- targetBranch: string;
24
- createdAt: string;
25
- updatedAt: string;
26
- isDraft: boolean;
27
- additions?: number;
28
- deletions?: number;
29
- changedFiles?: number;
18
+ id: string;
19
+ number: number;
20
+ title: string;
21
+ state: string;
22
+ author: { username: string };
23
+ sourceBranch: string;
24
+ targetBranch: string;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ isDraft: boolean;
28
+ additions?: number;
29
+ deletions?: number;
30
+ changedFiles?: number;
30
31
}
31
32
32
-export const prCommands = new Command("pr")
33
- .description("Manage pull requests");
33
+export const prCommands = new Command("pr").description("Manage pull requests");
34
34
35
35
// Get repo info from git remote
36
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
37
- try {
38
- const remotes = await git.getRemotes(true);
39
- const origin = remotes.find(r => r.name === "origin");
40
- if (!origin?.refs?.push) return null;
41
-
42
- const url = origin.refs.push;
43
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
44
- if (match) {
45
- return { owner: match[1], repo: match[2] };
46
- }
47
- return null;
48
- } catch {
49
- return null;
50
- }
36
+async function getRepoInfo() {
37
+ return getRepoInfoFromGit(git);
51
38
}
52
39
53
40
// PR Create
54
41
prCommands
55
- .command("create")
56
- .description("Create a new pull request")
57
- .option("-t, --title <title>", "PR title")
58
- .option("-b, --body <body>", "PR description")
59
- .option("-B, --base <branch>", "Base branch", "main")
60
- .option("-d, --draft", "Create as draft PR")
61
- .option("-a, --assignee <user>", "Assign to user")
62
- .option("-l, --label <label>", "Add label (can be used multiple times)", (val, acc: string[]) => [...acc, val], [])
63
- .action(async (options) => {
64
- const config = getConfig();
65
- if (!config.token) {
66
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
67
- process.exit(1);
68
- }
69
-
70
- const spinner = ora("Preparing pull request...").start();
71
-
72
- try {
73
- const repoInfo = await getRepoInfo();
74
- if (!repoInfo) {
75
- spinner.fail("Could not determine repository");
76
- process.exit(1);
77
- }
78
-
79
- const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
80
-
81
- if (currentBranch === options.base) {
82
- spinner.fail(`Cannot create PR from ${options.base} to ${options.base}`);
83
- process.exit(1);
84
- }
85
-
86
- spinner.stop();
87
-
88
- // Get title interactively if not provided
89
- let title = options.title;
90
- let body = options.body;
91
-
92
- if (!title) {
93
- const lastCommit = await git.log({ maxCount: 1 });
94
- const defaultTitle = lastCommit.latest?.message?.split("\n")[0] || currentBranch;
95
-
96
- const answers = await inquirer.prompt([
97
- {
98
- type: "input",
99
- name: "title",
100
- message: "PR Title:",
101
- default: defaultTitle,
102
- },
103
- {
104
- type: "editor",
105
- name: "body",
106
- message: "PR Description (optional):",
107
- },
108
- ]);
109
- title = answers.title;
110
- body = answers.body;
111
- }
112
-
113
- spinner.start("Creating pull request...");
114
-
115
- const result = await postWithAuth<{ data: PullRequest }>(
116
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls`,
117
- {
118
- title,
119
- body,
120
- sourceBranch: currentBranch,
121
- targetBranch: options.base,
122
- isDraft: options.draft || false,
123
- assignee: options.assignee,
124
- labels: options.label,
125
- }
126
- );
127
-
128
- spinner.succeed(`Created PR #${result.data.number}: ${result.data.title}`);
129
- console.log(chalk.dim(` ${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/pull/${result.data.number}`));
130
- } catch (error) {
131
- spinner.fail("Failed to create pull request");
132
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
133
- process.exit(1);
134
- }
135
- });
42
+ .command("create")
43
+ .description("Create a new pull request")
44
+ .option("-t, --title <title>", "PR title")
45
+ .option("-b, --body <body>", "PR description")
46
+ .option("-B, --base <branch>", "Base branch", "main")
47
+ .option("-d, --draft", "Create as draft PR")
48
+ .option("-a, --assignee <user>", "Assign to user")
49
+ .option(
50
+ "-l, --label <label>",
51
+ "Add label (can be used multiple times)",
52
+ (val, acc: string[]) => [...acc, val],
53
+ [],
54
+ )
55
+ .action(async (options) => {
56
+ const config = getConfig();
57
+ if (!config.token) {
58
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
59
+ process.exit(1);
60
+ }
61
+
62
+ const spinner = ora("Preparing pull request...").start();
63
+
64
+ try {
65
+ const repoInfo = await getRepoInfo();
66
+ if (!repoInfo) {
67
+ spinner.fail("Could not determine repository");
68
+ process.exit(1);
69
+ }
70
+
71
+ const currentBranch = (
72
+ await git.revparse(["--abbrev-ref", "HEAD"])
73
+ ).trim();
74
+
75
+ if (currentBranch === options.base) {
76
+ spinner.fail(
77
+ `Cannot create PR from ${options.base} to ${options.base}`,
78
+ );
79
+ process.exit(1);
80
+ }
81
+
82
+ spinner.stop();
83
+
84
+ // Get title interactively if not provided
85
+ let title = options.title;
86
+ let body = options.body;
87
+
88
+ if (!title) {
89
+ const lastCommit = await git.log({ maxCount: 1 });
90
+ const defaultTitle =
91
+ lastCommit.latest?.message?.split("\n")[0] || currentBranch;
92
+
93
+ const answers = await inquirer.prompt([
94
+ {
95
+ type: "input",
96
+ name: "title",
97
+ message: "PR Title:",
98
+ default: defaultTitle,
99
+ },
100
+ {
101
+ type: "editor",
102
+ name: "body",
103
+ message: "PR Description (optional):",
104
+ },
105
+ ]);
106
+ title = answers.title;
107
+ body = answers.body;
108
+ }
109
+
110
+ spinner.start("Creating pull request...");
111
+
112
+ const result = await postWithAuth<{ data: PullRequest }>(
113
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls`,
114
+ {
115
+ title,
116
+ body,
117
+ sourceBranch: currentBranch,
118
+ targetBranch: options.base,
119
+ isDraft: options.draft || false,
120
+ assignee: options.assignee,
121
+ labels: options.label,
122
+ },
123
+ );
124
+
125
+ spinner.succeed(
126
+ `Created PR #${result.data.number}: ${result.data.title}`,
127
+ );
128
+ console.log(
129
+ chalk.dim(
130
+ ` ${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/pull/${result.data.number}`,
131
+ ),
132
+ );
133
+ } catch (error) {
134
+ spinner.fail("Failed to create pull request");
135
+ console.error(
136
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
137
+ );
138
+ process.exit(1);
139
+ }
140
+ });
136
141
137
142
// PR List
138
143
prCommands
139
- .command("list")
140
- .alias("ls")
141
- .description("List pull requests")
142
- .option("-s, --state <state>", "Filter by state (open, closed, merged, all)", "open")
143
- .option("-a, --author <user>", "Filter by author")
144
- .option("-l, --label <label>", "Filter by label")
145
- .option("-L, --limit <n>", "Maximum results", "30")
146
- .action(async (options) => {
147
- const config = getConfig();
148
- if (!config.token) {
149
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
150
- process.exit(1);
151
- }
152
-
153
- const spinner = ora("Fetching pull requests...").start();
154
-
155
- try {
156
- const repoInfo = await getRepoInfo();
157
- if (!repoInfo) {
158
- spinner.fail("Could not determine repository");
159
- process.exit(1);
160
- }
161
-
162
- const params = new URLSearchParams();
163
- if (options.state !== "all") params.append("state", options.state);
164
- if (options.author) params.append("author", options.author);
165
- if (options.label) params.append("label", options.label);
166
- params.append("limit", options.limit);
167
-
168
- const result = await getWithAuth<{ data: PullRequest[] }>(
169
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls?${params}`
170
- );
171
-
172
- spinner.stop();
173
-
174
- if (result.data.length === 0) {
175
- console.log(chalk.dim("No pull requests found."));
176
- return;
177
- }
178
-
179
- console.log(chalk.bold(`\n📋 Pull Requests (${result.data.length})\n`));
180
-
181
- for (const pr of result.data) {
182
- const stateIcon = pr.state === "open" ? chalk.green("●") :
183
- pr.state === "merged" ? chalk.magenta("●") : chalk.red("●");
184
- const draftLabel = pr.isDraft ? chalk.dim(" [draft]") : "";
185
-
186
- console.log(`${stateIcon} #${pr.number} ${pr.title}${draftLabel}`);
187
- console.log(chalk.dim(` ${pr.sourceBranch} → ${pr.targetBranch} • @${pr.author.username}`));
188
- }
189
-
190
- console.log("");
191
- } catch (error) {
192
- spinner.fail("Failed to list pull requests");
193
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
194
- process.exit(1);
195
- }
196
- });
144
+ .command("list")
145
+ .alias("ls")
146
+ .description("List pull requests")
147
+ .option(
148
+ "-s, --state <state>",
149
+ "Filter by state (open, closed, merged, all)",
150
+ "open",
151
+ )
152
+ .option("-a, --author <user>", "Filter by author")
153
+ .option("-l, --label <label>", "Filter by label")
154
+ .option("-L, --limit <n>", "Maximum results", "30")
155
+ .action(async (options) => {
156
+ const config = getConfig();
157
+ if (!config.token) {
158
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
159
+ process.exit(1);
160
+ }
161
+
162
+ const spinner = ora("Fetching pull requests...").start();
163
+
164
+ try {
165
+ const repoInfo = await getRepoInfo();
166
+ if (!repoInfo) {
167
+ spinner.fail("Could not determine repository");
168
+ process.exit(1);
169
+ }
170
+
171
+ const params = new URLSearchParams();
172
+ if (options.state !== "all") params.append("state", options.state);
173
+ if (options.author) params.append("author", options.author);
174
+ if (options.label) params.append("label", options.label);
175
+ params.append("limit", options.limit);
176
+
177
+ const result = await getWithAuth<{ data: PullRequest[] }>(
178
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls?${params}`,
179
+ );
180
+
181
+ spinner.stop();
182
+
183
+ if (result.data.length === 0) {
184
+ console.log(chalk.dim("No pull requests found."));
185
+ return;
186
+ }
187
+
188
+ console.log(chalk.bold(`\n📋 Pull Requests (${result.data.length})\n`));
189
+
190
+ for (const pr of result.data) {
191
+ const stateIcon =
192
+ pr.state === "open"
193
+ ? chalk.green("●")
194
+ : pr.state === "merged"
195
+ ? chalk.magenta("●")
196
+ : chalk.red("●");
197
+ const draftLabel = pr.isDraft ? chalk.dim(" [draft]") : "";
198
+
199
+ console.log(`${stateIcon} #${pr.number} ${pr.title}${draftLabel}`);
200
+ console.log(
201
+ chalk.dim(
202
+ ` ${pr.sourceBranch} → ${pr.targetBranch} • @${pr.author.username}`,
203
+ ),
204
+ );
205
+ }
206
+
207
+ console.log("");
208
+ } catch (error) {
209
+ spinner.fail("Failed to list pull requests");
210
+ console.error(
211
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
212
+ );
213
+ process.exit(1);
214
+ }
215
+ });
197
216
198
217
// PR View
199
218
prCommands
200
- .command("view <number>")
201
- .description("View a pull request")
202
- .option("-w, --web", "Open in browser")
203
- .action(async (number: string, options) => {
204
- const config = getConfig();
205
- if (!config.token) {
206
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
207
- process.exit(1);
208
- }
209
-
210
- const spinner = ora("Fetching pull request...").start();
211
-
212
- try {
213
- const repoInfo = await getRepoInfo();
214
- if (!repoInfo) {
215
- spinner.fail("Could not determine repository");
216
- process.exit(1);
217
- }
218
-
219
- const result = await getWithAuth<{ data: PullRequest & { body?: string } }>(
220
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`
221
- );
222
-
223
- spinner.stop();
224
-
225
- const pr = result.data;
226
- const stateColor = pr.state === "open" ? chalk.green :
227
- pr.state === "merged" ? chalk.magenta : chalk.red;
228
-
229
- console.log(chalk.bold(`\n#${pr.number} ${pr.title}\n`));
230
- console.log(`State: ${stateColor(pr.state.toUpperCase())}${pr.isDraft ? chalk.dim(" (draft)") : ""}`);
231
- console.log(`Author: @${pr.author.username}`);
232
- console.log(`Branch: ${pr.sourceBranch} → ${pr.targetBranch}`);
233
- console.log(`Created: ${new Date(pr.createdAt).toLocaleDateString()}`);
234
-
235
- if (pr.additions !== undefined) {
236
- console.log(`Changes: ${chalk.green(`+${pr.additions}`)} ${chalk.red(`-${pr.deletions}`)} (${pr.changedFiles} files)`);
237
- }
238
-
239
- if (pr.body) {
240
- console.log(chalk.dim("\n─".repeat(40)));
241
- console.log(pr.body);
242
- }
243
-
244
- console.log(chalk.dim("\n─".repeat(40)));
245
- console.log(chalk.dim(`${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/pull/${number}`));
246
- console.log("");
247
- } catch (error) {
248
- spinner.fail("Failed to fetch pull request");
249
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
250
- process.exit(1);
251
- }
252
- });
219
+ .command("view <number>")
220
+ .description("View a pull request")
221
+ .option("-w, --web", "Open in browser")
222
+ .action(async (number: string, options) => {
223
+ const config = getConfig();
224
+ if (!config.token) {
225
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
226
+ process.exit(1);
227
+ }
228
+
229
+ const spinner = ora("Fetching pull request...").start();
230
+
231
+ try {
232
+ const repoInfo = await getRepoInfo();
233
+ if (!repoInfo) {
234
+ spinner.fail("Could not determine repository");
235
+ process.exit(1);
236
+ }
237
+
238
+ const result = await getWithAuth<{
239
+ data: PullRequest & { body?: string };
240
+ }>(`/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`);
241
+
242
+ spinner.stop();
243
+
244
+ const pr = result.data;
245
+ const stateColor =
246
+ pr.state === "open"
247
+ ? chalk.green
248
+ : pr.state === "merged"
249
+ ? chalk.magenta
250
+ : chalk.red;
251
+
252
+ console.log(chalk.bold(`\n#${pr.number} ${pr.title}\n`));
253
+ console.log(
254
+ `State: ${stateColor(pr.state.toUpperCase())}${pr.isDraft ? chalk.dim(" (draft)") : ""}`,
255
+ );
256
+ console.log(`Author: @${pr.author.username}`);
257
+ console.log(`Branch: ${pr.sourceBranch} → ${pr.targetBranch}`);
258
+ console.log(`Created: ${new Date(pr.createdAt).toLocaleDateString()}`);
259
+
260
+ if (pr.additions !== undefined) {
261
+ console.log(
262
+ `Changes: ${chalk.green(`+${pr.additions}`)} ${chalk.red(`-${pr.deletions}`)} (${pr.changedFiles} files)`,
263
+ );
264
+ }
265
+
266
+ if (pr.body) {
267
+ console.log(chalk.dim("\n─".repeat(40)));
268
+ console.log(pr.body);
269
+ }
270
+
271
+ console.log(chalk.dim("\n─".repeat(40)));
272
+ console.log(
273
+ chalk.dim(
274
+ `${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/pull/${number}`,
275
+ ),
276
+ );
277
+ console.log("");
278
+ } catch (error) {
279
+ spinner.fail("Failed to fetch pull request");
280
+ console.error(
281
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
282
+ );
283
+ process.exit(1);
284
+ }
285
+ });
253
286
254
287
// PR Merge
255
288
prCommands
256
- .command("merge <number>")
257
- .description("Merge a pull request")
258
- .option("-m, --merge", "Use merge commit")
259
- .option("-s, --squash", "Squash and merge")
260
- .option("-r, --rebase", "Rebase and merge")
261
- .option("-d, --delete-branch", "Delete branch after merge")
262
- .action(async (number: string, options) => {
263
- const config = getConfig();
264
- if (!config.token) {
265
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
266
- process.exit(1);
267
- }
268
-
269
- const mergeMethod = options.squash ? "squash" : options.rebase ? "rebase" : "merge";
270
- const spinner = ora(`Merging PR #${number} (${mergeMethod})...`).start();
271
-
272
- try {
273
- const repoInfo = await getRepoInfo();
274
- if (!repoInfo) {
275
- spinner.fail("Could not determine repository");
276
- process.exit(1);
277
- }
278
-
279
- await postWithAuth(
280
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}/merge`,
281
- {
282
- method: mergeMethod,
283
- deleteBranch: options.deleteBranch,
284
- }
285
- );
286
-
287
- spinner.succeed(`Merged PR #${number}`);
288
- } catch (error) {
289
- spinner.fail("Failed to merge pull request");
290
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
291
- process.exit(1);
292
- }
293
- });
289
+ .command("merge <number>")
290
+ .description("Merge a pull request")
291
+ .option("-m, --merge", "Use merge commit")
292
+ .option("-s, --squash", "Squash and merge")
293
+ .option("-r, --rebase", "Rebase and merge")
294
+ .option("-d, --delete-branch", "Delete branch after merge")
295
+ .action(async (number: string, options) => {
296
+ const config = getConfig();
297
+ if (!config.token) {
298
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
299
+ process.exit(1);
300
+ }
301
+
302
+ const mergeMethod = options.squash
303
+ ? "squash"
304
+ : options.rebase
305
+ ? "rebase"
306
+ : "merge";
307
+ const spinner = ora(`Merging PR #${number} (${mergeMethod})...`).start();
308
+
309
+ try {
310
+ const repoInfo = await getRepoInfo();
311
+ if (!repoInfo) {
312
+ spinner.fail("Could not determine repository");
313
+ process.exit(1);
314
+ }
315
+
316
+ await postWithAuth(
317
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}/merge`,
318
+ {
319
+ method: mergeMethod,
320
+ deleteBranch: options.deleteBranch,
321
+ },
322
+ );
323
+
324
+ spinner.succeed(`Merged PR #${number}`);
325
+ } catch (error) {
326
+ spinner.fail("Failed to merge pull request");
327
+ console.error(
328
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
329
+ );
330
+ process.exit(1);
331
+ }
332
+ });
294
333
295
334
// PR Checkout
296
335
prCommands
297
- .command("checkout <number>")
298
- .alias("co")
299
- .description("Checkout a pull request locally")
300
- .action(async (number: string) => {
301
- const config = getConfig();
302
- if (!config.token) {
303
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
304
- process.exit(1);
305
- }
306
-
307
- const spinner = ora(`Checking out PR #${number}...`).start();
308
-
309
- try {
310
- const repoInfo = await getRepoInfo();
311
- if (!repoInfo) {
312
- spinner.fail("Could not determine repository");
313
- process.exit(1);
314
- }
315
-
316
- // Get PR info
317
- const result = await getWithAuth<{ data: PullRequest }>(
318
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`
319
- );
320
-
321
- const branchName = result.data.sourceBranch;
322
-
323
- // Fetch and checkout
324
- spinner.text = `Fetching ${branchName}...`;
325
- await git.fetch(["origin", branchName]);
326
-
327
- spinner.text = `Checking out ${branchName}...`;
328
- await git.checkout(branchName);
329
-
330
- spinner.succeed(`Checked out PR #${number} (${branchName})`);
331
- } catch (error) {
332
- spinner.fail("Failed to checkout pull request");
333
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
334
- process.exit(1);
335
- }
336
- });
336
+ .command("checkout <number>")
337
+ .alias("co")
338
+ .description("Checkout a pull request locally")
339
+ .action(async (number: string) => {
340
+ const config = getConfig();
341
+ if (!config.token) {
342
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
343
+ process.exit(1);
344
+ }
345
+
346
+ const spinner = ora(`Checking out PR #${number}...`).start();
347
+
348
+ try {
349
+ const repoInfo = await getRepoInfo();
350
+ if (!repoInfo) {
351
+ spinner.fail("Could not determine repository");
352
+ process.exit(1);
353
+ }
354
+
355
+ // Get PR info
356
+ const result = await getWithAuth<{ data: PullRequest }>(
357
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`,
358
+ );
359
+
360
+ const branchName = result.data.sourceBranch;
361
+
362
+ // Fetch and checkout
363
+ spinner.text = `Fetching ${branchName}...`;
364
+ await git.fetch(["origin", branchName]);
365
+
366
+ spinner.text = `Checking out ${branchName}...`;
367
+ await git.checkout(branchName);
368
+
369
+ spinner.succeed(`Checked out PR #${number} (${branchName})`);
370
+ } catch (error) {
371
+ spinner.fail("Failed to checkout pull request");
372
+ console.error(
373
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
374
+ );
375
+ process.exit(1);
376
+ }
377
+ });
337
378
338
379
// PR Close
339
380
prCommands
340
- .command("close <number>")
341
- .description("Close a pull request")
342
- .action(async (number: string) => {
343
- const config = getConfig();
344
- if (!config.token) {
345
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
346
- process.exit(1);
347
- }
348
-
349
- const spinner = ora(`Closing PR #${number}...`).start();
350
-
351
- try {
352
- const repoInfo = await getRepoInfo();
353
- if (!repoInfo) {
354
- spinner.fail("Could not determine repository");
355
- process.exit(1);
356
- }
357
-
358
- await patchWithAuth(
359
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`,
360
- { state: "closed" }
361
- );
362
-
363
- spinner.succeed(`Closed PR #${number}`);
364
- } catch (error) {
365
- spinner.fail("Failed to close pull request");
366
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
367
- process.exit(1);
368
- }
369
- });
381
+ .command("close <number>")
382
+ .description("Close a pull request")
383
+ .action(async (number: string) => {
384
+ const config = getConfig();
385
+ if (!config.token) {
386
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
387
+ process.exit(1);
388
+ }
389
+
390
+ const spinner = ora(`Closing PR #${number}...`).start();
391
+
392
+ try {
393
+ const repoInfo = await getRepoInfo();
394
+ if (!repoInfo) {
395
+ spinner.fail("Could not determine repository");
396
+ process.exit(1);
397
+ }
398
+
399
+ await patchWithAuth(
400
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`,
401
+ { state: "closed" },
402
+ );
403
+
404
+ spinner.succeed(`Closed PR #${number}`);
405
+ } catch (error) {
406
+ spinner.fail("Failed to close pull request");
407
+ console.error(
408
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
409
+ );
410
+ process.exit(1);
411
+ }
412
+ });
370
413
371
414
// PR Diff
372
415
prCommands
373
- .command("diff <number>")
374
- .description("View pull request diff")
375
- .action(async (number: string) => {
376
- const config = getConfig();
377
- if (!config.token) {
378
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
379
- process.exit(1);
380
- }
381
-
382
- const spinner = ora(`Fetching diff for PR #${number}...`).start();
383
-
384
- try {
385
- const repoInfo = await getRepoInfo();
386
- if (!repoInfo) {
387
- spinner.fail("Could not determine repository");
388
- process.exit(1);
389
- }
390
-
391
- const result = await getWithAuth<{ data: { diff: string } }>(
392
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}/diff`
393
- );
394
-
395
- spinner.stop();
396
- console.log(result.data.diff);
397
- } catch (error) {
398
- spinner.fail("Failed to fetch diff");
399
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
400
- process.exit(1);
401
- }
402
- });
416
+ .command("diff <number>")
417
+ .description("View pull request diff")
418
+ .action(async (number: string) => {
419
+ const config = getConfig();
420
+ if (!config.token) {
421
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
422
+ process.exit(1);
423
+ }
424
+
425
+ const spinner = ora(`Fetching diff for PR #${number}...`).start();
426
+
427
+ try {
428
+ const repoInfo = await getRepoInfo();
429
+ if (!repoInfo) {
430
+ spinner.fail("Could not determine repository");
431
+ process.exit(1);
432
+ }
433
+
434
+ const result = await getWithAuth<{ data: { diff: string } }>(
435
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}/diff`,
436
+ );
437
+
438
+ spinner.stop();
439
+ console.log(result.data.diff);
440
+ } catch (error) {
441
+ spinner.fail("Failed to fetch diff");
442
+ console.error(
443
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
444
+ );
445
+ process.exit(1);
446
+ }
447
+ });
403
448
404
449
// PR Ready (mark as ready for review)
405
450
prCommands
406
- .command("ready <number>")
407
- .description("Mark pull request as ready for review")
408
- .action(async (number: string) => {
409
- const config = getConfig();
410
- if (!config.token) {
411
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
412
- process.exit(1);
413
- }
414
-
415
- const spinner = ora(`Marking PR #${number} as ready...`).start();
416
-
417
- try {
418
- const repoInfo = await getRepoInfo();
419
- if (!repoInfo) {
420
- spinner.fail("Could not determine repository");
421
- process.exit(1);
422
- }
423
-
424
- await patchWithAuth(
425
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`,
426
- { isDraft: false }
427
- );
428
-
429
- spinner.succeed(`PR #${number} is now ready for review`);
430
- } catch (error) {
431
- spinner.fail("Failed to update pull request");
432
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
433
- process.exit(1);
434
- }
435
- });
451
+ .command("ready <number>")
452
+ .description("Mark pull request as ready for review")
453
+ .action(async (number: string) => {
454
+ const config = getConfig();
455
+ if (!config.token) {
456
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
457
+ process.exit(1);
458
+ }
459
+
460
+ const spinner = ora(`Marking PR #${number} as ready...`).start();
461
+
462
+ try {
463
+ const repoInfo = await getRepoInfo();
464
+ if (!repoInfo) {
465
+ spinner.fail("Could not determine repository");
466
+ process.exit(1);
467
+ }
468
+
469
+ await patchWithAuth(
470
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${number}`,
471
+ { isDraft: false },
472
+ );
473
+
474
+ spinner.succeed(`PR #${number} is now ready for review`);
475
+ } catch (error) {
476
+ spinner.fail("Failed to update pull request");
477
+ console.error(
478
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
479
+ );
480
+ process.exit(1);
481
+ }
482
+ });
436
483
437
484
export default prCommands;
438
485
▼cli/src/commands/queue/index.ts
+249-223
3
3
* Manage merge queue from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
7
+import { Command } from "commander";
8
8
import ora from "ora";
9
9
import { simpleGit } from "simple-git";
10
+import { deleteWithAuth, getWithAuth, postWithAuth } from "../../lib/api.js";
10
11
import { getConfig } from "../../lib/config.js";
11
-import { getWithAuth, postWithAuth, deleteWithAuth } from "../../lib/api.js";
12
+import { getRepoInfoFromGit } from "../../lib/git.js";
12
13
13
14
const git = simpleGit();
14
15
15
16
interface QueueItem {
16
- id: string;
17
- position: number;
18
- status: string;
19
- priority: number;
20
- pullRequest: {
21
- number: number;
22
- title: string;
23
- sourceBranch: string;
24
- targetBranch: string;
25
- author: { username: string };
26
- };
27
- addedAt: string;
28
- estimatedMergeTime?: string;
17
+ id: string;
18
+ position: number;
19
+ status: string;
20
+ priority: number;
21
+ pullRequest: {
22
+ number: number;
23
+ title: string;
24
+ sourceBranch: string;
25
+ targetBranch: string;
26
+ author: { username: string };
27
+ };
28
+ addedAt: string;
29
+ estimatedMergeTime?: string;
29
30
}
30
31
31
-export const queueCommands = new Command("queue")
32
- .description("Manage merge queue");
32
+export const queueCommands = new Command("queue").description(
33
+ "Manage merge queue",
34
+);
33
35
34
36
// Get repo info from git remote
35
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
36
- try {
37
- const remotes = await git.getRemotes(true);
38
- const origin = remotes.find(r => r.name === "origin");
39
- if (!origin?.refs?.push) return null;
40
-
41
- const url = origin.refs.push;
42
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
43
- if (match) {
44
- return { owner: match[1], repo: match[2] };
45
- }
46
- return null;
47
- } catch {
48
- return null;
49
- }
37
+async function getRepoInfo() {
38
+ return getRepoInfoFromGit(git);
50
39
}
51
40
52
41
function getStatusIcon(status: string): string {
53
- switch (status) {
54
- case "pending": return chalk.yellow("◷");
55
- case "running_ci": return chalk.blue("●");
56
- case "ready": return chalk.green("✓");
57
- case "merging": return chalk.magenta("↻");
58
- case "failed": return chalk.red("✗");
59
- default: return chalk.dim("○");
60
- }
42
+ switch (status) {
43
+ case "pending":
44
+ return chalk.yellow("◷");
45
+ case "running_ci":
46
+ return chalk.blue("●");
47
+ case "ready":
48
+ return chalk.green("✓");
49
+ case "merging":
50
+ return chalk.magenta("↻");
51
+ case "failed":
52
+ return chalk.red("✗");
53
+ default:
54
+ return chalk.dim("○");
55
+ }
61
56
}
62
57
63
58
// Queue Add
64
59
queueCommands
65
- .command("add <pr-number>")
66
- .description("Add a pull request to the merge queue")
67
- .option("-p, --priority <n>", "Priority (higher = sooner)", "0")
68
- .option("-m, --method <method>", "Merge method (merge, squash, rebase)", "merge")
69
- .action(async (prNumber: string, options) => {
70
- const config = getConfig();
71
- if (!config.token) {
72
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
73
- process.exit(1);
74
- }
75
-
76
- const spinner = ora(`Adding PR #${prNumber} to merge queue...`).start();
77
-
78
- try {
79
- const repoInfo = await getRepoInfo();
80
- if (!repoInfo) {
81
- spinner.fail("Could not determine repository");
82
- process.exit(1);
83
- }
84
-
85
- const result = await postWithAuth<{ data: QueueItem }>(
86
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`,
87
- {
88
- pullRequestNumber: parseInt(prNumber),
89
- priority: parseInt(options.priority),
90
- mergeMethod: options.method,
91
- }
92
- );
93
-
94
- spinner.succeed(`Added PR #${prNumber} to merge queue at position ${result.data.position}`);
95
-
96
- if (result.data.estimatedMergeTime) {
97
- const eta = new Date(result.data.estimatedMergeTime);
98
- console.log(chalk.dim(` Estimated merge: ${eta.toLocaleTimeString()}`));
99
- }
100
- } catch (error) {
101
- spinner.fail("Failed to add to merge queue");
102
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
103
- process.exit(1);
104
- }
105
- });
60
+ .command("add <pr-number>")
61
+ .description("Add a pull request to the merge queue")
62
+ .option("-p, --priority <n>", "Priority (higher = sooner)", "0")
63
+ .option(
64
+ "-m, --method <method>",
65
+ "Merge method (merge, squash, rebase)",
66
+ "merge",
67
+ )
68
+ .action(async (prNumber: string, options) => {
69
+ const config = getConfig();
70
+ if (!config.token) {
71
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
72
+ process.exit(1);
73
+ }
74
+
75
+ const spinner = ora(`Adding PR #${prNumber} to merge queue...`).start();
76
+
77
+ try {
78
+ const repoInfo = await getRepoInfo();
79
+ if (!repoInfo) {
80
+ spinner.fail("Could not determine repository");
81
+ process.exit(1);
82
+ }
83
+
84
+ const result = await postWithAuth<{ data: QueueItem }>(
85
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`,
86
+ {
87
+ pullRequestNumber: parseInt(prNumber),
88
+ priority: parseInt(options.priority),
89
+ mergeMethod: options.method,
90
+ },
91
+ );
92
+
93
+ spinner.succeed(
94
+ `Added PR #${prNumber} to merge queue at position ${result.data.position}`,
95
+ );
96
+
97
+ if (result.data.estimatedMergeTime) {
98
+ const eta = new Date(result.data.estimatedMergeTime);
99
+ console.log(
100
+ chalk.dim(` Estimated merge: ${eta.toLocaleTimeString()}`),
101
+ );
102
+ }
103
+ } catch (error) {
104
+ spinner.fail("Failed to add to merge queue");
105
+ console.error(
106
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
107
+ );
108
+ process.exit(1);
109
+ }
110
+ });
106
111
107
112
// Queue List
108
113
queueCommands
109
- .command("list")
110
- .alias("ls")
111
- .description("List merge queue items")
112
- .action(async () => {
113
- const config = getConfig();
114
- if (!config.token) {
115
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
116
- process.exit(1);
117
- }
118
-
119
- const spinner = ora("Fetching merge queue...").start();
120
-
121
- try {
122
- const repoInfo = await getRepoInfo();
123
- if (!repoInfo) {
124
- spinner.fail("Could not determine repository");
125
- process.exit(1);
126
- }
127
-
128
- const result = await getWithAuth<{ data: QueueItem[] }>(
129
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`
130
- );
131
-
132
- spinner.stop();
133
-
134
- if (result.data.length === 0) {
135
- console.log(chalk.dim("Merge queue is empty."));
136
- return;
137
- }
138
-
139
- console.log(chalk.bold(`\n🔀 Merge Queue (${result.data.length} items)\n`));
140
- console.log(chalk.dim(" Pos PR Title Status"));
141
- console.log(chalk.dim(" ─── ── ───── ──────"));
142
-
143
- for (const item of result.data) {
144
- const icon = getStatusIcon(item.status);
145
- const pr = item.pullRequest;
146
- const title = pr.title.length > 35 ? pr.title.slice(0, 32) + "..." : pr.title.padEnd(35);
147
-
148
- console.log(` ${String(item.position).padStart(3)} #${String(pr.number).padEnd(5)} ${title} ${icon} ${item.status}`);
149
- }
150
-
151
- console.log("");
152
- } catch (error) {
153
- spinner.fail("Failed to fetch merge queue");
154
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
155
- process.exit(1);
156
- }
157
- });
114
+ .command("list")
115
+ .alias("ls")
116
+ .description("List merge queue items")
117
+ .action(async () => {
118
+ const config = getConfig();
119
+ if (!config.token) {
120
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
121
+ process.exit(1);
122
+ }
123
+
124
+ const spinner = ora("Fetching merge queue...").start();
125
+
126
+ try {
127
+ const repoInfo = await getRepoInfo();
128
+ if (!repoInfo) {
129
+ spinner.fail("Could not determine repository");
130
+ process.exit(1);
131
+ }
132
+
133
+ const result = await getWithAuth<{ data: QueueItem[] }>(
134
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`,
135
+ );
136
+
137
+ spinner.stop();
138
+
139
+ if (result.data.length === 0) {
140
+ console.log(chalk.dim("Merge queue is empty."));
141
+ return;
142
+ }
143
+
144
+ console.log(
145
+ chalk.bold(`\n🔀 Merge Queue (${result.data.length} items)\n`),
146
+ );
147
+ console.log(
148
+ chalk.dim(" Pos PR Title Status"),
149
+ );
150
+ console.log(
151
+ chalk.dim(" ─── ── ───── ──────"),
152
+ );
153
+
154
+ for (const item of result.data) {
155
+ const icon = getStatusIcon(item.status);
156
+ const pr = item.pullRequest;
157
+ const title =
158
+ pr.title.length > 35
159
+ ? pr.title.slice(0, 32) + "..."
160
+ : pr.title.padEnd(35);
161
+
162
+ console.log(
163
+ ` ${String(item.position).padStart(3)} #${String(pr.number).padEnd(5)} ${title} ${icon} ${item.status}`,
164
+ );
165
+ }
166
+
167
+ console.log("");
168
+ } catch (error) {
169
+ spinner.fail("Failed to fetch merge queue");
170
+ console.error(
171
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
172
+ );
173
+ process.exit(1);
174
+ }
175
+ });
158
176
159
177
// Queue Remove
160
178
queueCommands
161
- .command("remove <pr-number>")
162
- .alias("rm")
163
- .description("Remove a pull request from the merge queue")
164
- .action(async (prNumber: string) => {
165
- const config = getConfig();
166
- if (!config.token) {
167
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
168
- process.exit(1);
169
- }
170
-
171
- const spinner = ora(`Removing PR #${prNumber} from merge queue...`).start();
172
-
173
- try {
174
- const repoInfo = await getRepoInfo();
175
- if (!repoInfo) {
176
- spinner.fail("Could not determine repository");
177
- process.exit(1);
178
- }
179
-
180
- await deleteWithAuth(
181
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue/${prNumber}`
182
- );
183
-
184
- spinner.succeed(`Removed PR #${prNumber} from merge queue`);
185
- } catch (error) {
186
- spinner.fail("Failed to remove from merge queue");
187
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
188
- process.exit(1);
189
- }
190
- });
179
+ .command("remove <pr-number>")
180
+ .alias("rm")
181
+ .description("Remove a pull request from the merge queue")
182
+ .action(async (prNumber: string) => {
183
+ const config = getConfig();
184
+ if (!config.token) {
185
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
186
+ process.exit(1);
187
+ }
188
+
189
+ const spinner = ora(`Removing PR #${prNumber} from merge queue...`).start();
190
+
191
+ try {
192
+ const repoInfo = await getRepoInfo();
193
+ if (!repoInfo) {
194
+ spinner.fail("Could not determine repository");
195
+ process.exit(1);
196
+ }
197
+
198
+ await deleteWithAuth(
199
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue/${prNumber}`,
200
+ );
201
+
202
+ spinner.succeed(`Removed PR #${prNumber} from merge queue`);
203
+ } catch (error) {
204
+ spinner.fail("Failed to remove from merge queue");
205
+ console.error(
206
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
207
+ );
208
+ process.exit(1);
209
+ }
210
+ });
191
211
192
212
// Queue Status
193
213
queueCommands
194
- .command("status")
195
- .description("Show merge queue status for current PR")
196
- .action(async () => {
197
- const config = getConfig();
198
- if (!config.token) {
199
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
200
- process.exit(1);
201
- }
202
-
203
- const spinner = ora("Checking queue status...").start();
204
-
205
- try {
206
- const repoInfo = await getRepoInfo();
207
- if (!repoInfo) {
208
- spinner.fail("Could not determine repository");
209
- process.exit(1);
210
- }
211
-
212
- const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
213
-
214
- // Find PR for current branch
215
- const prs = await getWithAuth<{ data: { number: number }[] }>(
216
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls?sourceBranch=${currentBranch}&state=open&limit=1`
217
- );
218
-
219
- if (prs.data.length === 0) {
220
- spinner.info(`No open PR found for branch ${currentBranch}`);
221
- return;
222
- }
223
-
224
- const prNumber = prs.data[0].number;
225
-
226
- // Get queue status
227
- const result = await getWithAuth<{ data: QueueItem[] }>(
228
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`
229
- );
230
-
231
- spinner.stop();
232
-
233
- const item = result.data.find(i => i.pullRequest.number === prNumber);
234
-
235
- if (!item) {
236
- console.log(chalk.dim(`PR #${prNumber} is not in the merge queue.`));
237
- console.log(chalk.dim(`Run 'och queue add ${prNumber}' to add it.`));
238
- return;
239
- }
240
-
241
- const icon = getStatusIcon(item.status);
242
- console.log(`\n${icon} PR #${prNumber} is at position ${item.position} in the queue`);
243
- console.log(`Status: ${item.status}`);
244
-
245
- if (item.estimatedMergeTime) {
246
- const eta = new Date(item.estimatedMergeTime);
247
- console.log(`Estimated merge: ${eta.toLocaleString()}`);
248
- }
249
-
250
- console.log("");
251
- } catch (error) {
252
- spinner.fail("Failed to check queue status");
253
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
254
- process.exit(1);
255
- }
256
- });
214
+ .command("status")
215
+ .description("Show merge queue status for current PR")
216
+ .action(async () => {
217
+ const config = getConfig();
218
+ if (!config.token) {
219
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
220
+ process.exit(1);
221
+ }
222
+
223
+ const spinner = ora("Checking queue status...").start();
224
+
225
+ try {
226
+ const repoInfo = await getRepoInfo();
227
+ if (!repoInfo) {
228
+ spinner.fail("Could not determine repository");
229
+ process.exit(1);
230
+ }
231
+
232
+ const currentBranch = (
233
+ await git.revparse(["--abbrev-ref", "HEAD"])
234
+ ).trim();
235
+
236
+ // Find PR for current branch
237
+ const prs = await getWithAuth<{ data: { number: number }[] }>(
238
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls?sourceBranch=${currentBranch}&state=open&limit=1`,
239
+ );
240
+
241
+ if (prs.data.length === 0) {
242
+ spinner.info(`No open PR found for branch ${currentBranch}`);
243
+ return;
244
+ }
245
+
246
+ const prNumber = prs.data[0].number;
247
+
248
+ // Get queue status
249
+ const result = await getWithAuth<{ data: QueueItem[] }>(
250
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`,
251
+ );
252
+
253
+ spinner.stop();
254
+
255
+ const item = result.data.find((i) => i.pullRequest.number === prNumber);
256
+
257
+ if (!item) {
258
+ console.log(chalk.dim(`PR #${prNumber} is not in the merge queue.`));
259
+ console.log(chalk.dim(`Run 'och queue add ${prNumber}' to add it.`));
260
+ return;
261
+ }
262
+
263
+ const icon = getStatusIcon(item.status);
264
+ console.log(
265
+ `\n${icon} PR #${prNumber} is at position ${item.position} in the queue`,
266
+ );
267
+ console.log(`Status: ${item.status}`);
268
+
269
+ if (item.estimatedMergeTime) {
270
+ const eta = new Date(item.estimatedMergeTime);
271
+ console.log(`Estimated merge: ${eta.toLocaleString()}`);
272
+ }
273
+
274
+ console.log("");
275
+ } catch (error) {
276
+ spinner.fail("Failed to check queue status");
277
+ console.error(
278
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
279
+ );
280
+ process.exit(1);
281
+ }
282
+ });
257
283
258
284
export default queueCommands;
259
285
▼cli/src/commands/release/index.ts
+282-261
3
3
* Manage releases from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
7
+import { Command } from "commander";
9
8
import inquirer from "inquirer";
9
+import ora from "ora";
10
10
import { simpleGit } from "simple-git";
11
-import { getConfig } from "../../lib/config.js";
12
11
import { getWithAuth, postWithAuth } from "../../lib/api.js";
12
+import { getConfig } from "../../lib/config.js";
13
+import { getRepoInfoFromGit } from "../../lib/git.js";
13
14
14
15
const git = simpleGit();
15
16
16
17
interface Release {
17
- id: string;
18
- tagName: string;
19
- name: string;
20
- body?: string;
21
- isDraft: boolean;
22
- isPrerelease: boolean;
23
- createdAt: string;
24
- publishedAt?: string;
25
- author: { username: string };
26
- assets?: { name: string; size: number; downloadUrl: string }[];
18
+ id: string;
19
+ tagName: string;
20
+ name: string;
21
+ body?: string;
22
+ isDraft: boolean;
23
+ isPrerelease: boolean;
24
+ createdAt: string;
25
+ publishedAt?: string;
26
+ author: { username: string };
27
+ assets?: { name: string; size: number; downloadUrl: string }[];
27
28
}
28
29
29
-export const releaseCommands = new Command("release")
30
- .description("Manage releases");
30
+export const releaseCommands = new Command("release").description(
31
+ "Manage releases",
32
+);
31
33
32
34
// Get repo info from git remote
33
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
34
- try {
35
- const remotes = await git.getRemotes(true);
36
- const origin = remotes.find(r => r.name === "origin");
37
- if (!origin?.refs?.push) return null;
38
-
39
- const url = origin.refs.push;
40
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
41
- if (match) {
42
- return { owner: match[1], repo: match[2] };
43
- }
44
- return null;
45
- } catch {
46
- return null;
47
- }
35
+async function getRepoInfo() {
36
+ return getRepoInfoFromGit(git);
48
37
}
49
38
50
39
// Release Create
51
40
releaseCommands
52
- .command("create <tag>")
53
- .description("Create a new release")
54
- .option("-t, --title <title>", "Release title")
55
- .option("-n, --notes <notes>", "Release notes")
56
- .option("-F, --notes-file <file>", "Read release notes from file")
57
- .option("-d, --draft", "Create as draft release")
58
- .option("-p, --prerelease", "Mark as pre-release")
59
- .option("--target <branch>", "Target branch for tag", "main")
60
- .option("--generate-notes", "Auto-generate release notes")
61
- .action(async (tag: string, options) => {
62
- const config = getConfig();
63
- if (!config.token) {
64
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
65
- process.exit(1);
66
- }
41
+ .command("create <tag>")
42
+ .description("Create a new release")
43
+ .option("-t, --title <title>", "Release title")
44
+ .option("-n, --notes <notes>", "Release notes")
45
+ .option("-F, --notes-file <file>", "Read release notes from file")
46
+ .option("-d, --draft", "Create as draft release")
47
+ .option("-p, --prerelease", "Mark as pre-release")
48
+ .option("--target <branch>", "Target branch for tag", "main")
49
+ .option("--generate-notes", "Auto-generate release notes")
50
+ .action(async (tag: string, options) => {
51
+ const config = getConfig();
52
+ if (!config.token) {
53
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
54
+ process.exit(1);
55
+ }
67
56
68
- try {
69
- const repoInfo = await getRepoInfo();
70
- if (!repoInfo) {
71
- console.error(chalk.red("Could not determine repository"));
72
- process.exit(1);
73
- }
74
-
75
- let title = options.title || tag;
76
- let notes = options.notes;
77
-
78
- // Read notes from file if specified
79
- if (options.notesFile) {
80
- const fs = await import("fs");
81
- notes = fs.readFileSync(options.notesFile, "utf-8");
82
- }
83
-
84
- // Interactive mode if notes not provided
85
- if (!notes && !options.generateNotes) {
86
- const answers = await inquirer.prompt([
87
- {
88
- type: "input",
89
- name: "title",
90
- message: "Release title:",
91
- default: title,
92
- },
93
- {
94
- type: "editor",
95
- name: "notes",
96
- message: "Release notes:",
97
- },
98
- ]);
99
- title = answers.title;
100
- notes = answers.notes;
101
- }
102
-
103
- const spinner = ora(`Creating release ${tag}...`).start();
104
-
105
- const result = await postWithAuth<{ data: Release }>(
106
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases`,
107
- {
108
- tagName: tag,
109
- name: title,
110
- body: notes,
111
- targetCommitish: options.target,
112
- isDraft: options.draft || false,
113
- isPrerelease: options.prerelease || false,
114
- generateNotes: options.generateNotes || false,
115
- }
116
- );
117
-
118
- const status = result.data.isDraft ? " (draft)" : result.data.isPrerelease ? " (pre-release)" : "";
119
- spinner.succeed(`Created release ${chalk.green(tag)}${status}`);
120
- console.log(chalk.dim(` ${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/releases/tag/${tag}`));
121
- } catch (error) {
122
- console.error(chalk.red("Failed to create release"));
123
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
124
- process.exit(1);
125
- }
126
- });
57
+ try {
58
+ const repoInfo = await getRepoInfo();
59
+ if (!repoInfo) {
60
+ console.error(chalk.red("Could not determine repository"));
61
+ process.exit(1);
62
+ }
63
+
64
+ let title = options.title || tag;
65
+ let notes = options.notes;
66
+
67
+ // Read notes from file if specified
68
+ if (options.notesFile) {
69
+ const fs = await import("fs");
70
+ notes = fs.readFileSync(options.notesFile, "utf-8");
71
+ }
72
+
73
+ // Interactive mode if notes not provided
74
+ if (!notes && !options.generateNotes) {
75
+ const answers = await inquirer.prompt([
76
+ {
77
+ type: "input",
78
+ name: "title",
79
+ message: "Release title:",
80
+ default: title,
81
+ },
82
+ {
83
+ type: "editor",
84
+ name: "notes",
85
+ message: "Release notes:",
86
+ },
87
+ ]);
88
+ title = answers.title;
89
+ notes = answers.notes;
90
+ }
91
+
92
+ const spinner = ora(`Creating release ${tag}...`).start();
93
+
94
+ const result = await postWithAuth<{ data: Release }>(
95
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases`,
96
+ {
97
+ tagName: tag,
98
+ name: title,
99
+ body: notes,
100
+ targetCommitish: options.target,
101
+ isDraft: options.draft || false,
102
+ isPrerelease: options.prerelease || false,
103
+ generateNotes: options.generateNotes || false,
104
+ },
105
+ );
106
+
107
+ const status = result.data.isDraft
108
+ ? " (draft)"
109
+ : result.data.isPrerelease
110
+ ? " (pre-release)"
111
+ : "";
112
+ spinner.succeed(`Created release ${chalk.green(tag)}${status}`);
113
+ console.log(
114
+ chalk.dim(
115
+ ` ${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/releases/tag/${tag}`,
116
+ ),
117
+ );
118
+ } catch (error) {
119
+ console.error(chalk.red("Failed to create release"));
120
+ console.error(
121
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
122
+ );
123
+ process.exit(1);
124
+ }
125
+ });
127
126
128
127
// Release List
129
128
releaseCommands
130
- .command("list")
131
- .alias("ls")
132
- .description("List releases")
133
- .option("-L, --limit <n>", "Maximum results", "10")
134
- .option("--include-drafts", "Include draft releases")
135
- .action(async (options) => {
136
- const config = getConfig();
137
- if (!config.token) {
138
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
139
- process.exit(1);
140
- }
129
+ .command("list")
130
+ .alias("ls")
131
+ .description("List releases")
132
+ .option("-L, --limit <n>", "Maximum results", "10")
133
+ .option("--include-drafts", "Include draft releases")
134
+ .action(async (options) => {
135
+ const config = getConfig();
136
+ if (!config.token) {
137
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
138
+ process.exit(1);
139
+ }
141
140
142
- const spinner = ora("Fetching releases...").start();
143
-
144
- try {
145
- const repoInfo = await getRepoInfo();
146
- if (!repoInfo) {
147
- spinner.fail("Could not determine repository");
148
- process.exit(1);
149
- }
150
-
151
- const params = new URLSearchParams();
152
- params.append("limit", options.limit);
153
- if (options.includeDrafts) params.append("includeDrafts", "true");
154
-
155
- const result = await getWithAuth<{ data: Release[] }>(
156
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases?${params}`
157
- );
158
-
159
- spinner.stop();
160
-
161
- if (result.data.length === 0) {
162
- console.log(chalk.dim("No releases found."));
163
- return;
164
- }
165
-
166
- console.log(chalk.bold(`\n📦 Releases (${result.data.length})\n`));
167
-
168
- for (const release of result.data) {
169
- const icon = release.isDraft ? chalk.yellow("◌") :
170
- release.isPrerelease ? chalk.blue("●") :
171
- chalk.green("●");
172
- const labels = [
173
- release.isDraft ? chalk.yellow("[draft]") : "",
174
- release.isPrerelease ? chalk.blue("[pre-release]") : "",
175
- ].filter(Boolean).join(" ");
176
-
177
- console.log(`${icon} ${chalk.bold(release.tagName)} ${release.name !== release.tagName ? `- ${release.name}` : ""} ${labels}`);
178
- console.log(chalk.dim(` ${new Date(release.publishedAt || release.createdAt).toLocaleDateString()} • @${release.author.username}`));
179
- }
180
-
181
- console.log("");
182
- } catch (error) {
183
- spinner.fail("Failed to list releases");
184
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
185
- process.exit(1);
186
- }
187
- });
141
+ const spinner = ora("Fetching releases...").start();
142
+
143
+ try {
144
+ const repoInfo = await getRepoInfo();
145
+ if (!repoInfo) {
146
+ spinner.fail("Could not determine repository");
147
+ process.exit(1);
148
+ }
149
+
150
+ const params = new URLSearchParams();
151
+ params.append("limit", options.limit);
152
+ if (options.includeDrafts) params.append("includeDrafts", "true");
153
+
154
+ const result = await getWithAuth<{ data: Release[] }>(
155
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases?${params}`,
156
+ );
157
+
158
+ spinner.stop();
159
+
160
+ if (result.data.length === 0) {
161
+ console.log(chalk.dim("No releases found."));
162
+ return;
163
+ }
164
+
165
+ console.log(chalk.bold(`\n📦 Releases (${result.data.length})\n`));
166
+
167
+ for (const release of result.data) {
168
+ const icon = release.isDraft
169
+ ? chalk.yellow("◌")
170
+ : release.isPrerelease
171
+ ? chalk.blue("●")
172
+ : chalk.green("●");
173
+ const labels = [
174
+ release.isDraft ? chalk.yellow("[draft]") : "",
175
+ release.isPrerelease ? chalk.blue("[pre-release]") : "",
176
+ ]
177
+ .filter(Boolean)
178
+ .join(" ");
179
+
180
+ console.log(
181
+ `${icon} ${chalk.bold(release.tagName)} ${release.name !== release.tagName ? `- ${release.name}` : ""} ${labels}`,
182
+ );
183
+ console.log(
184
+ chalk.dim(
185
+ ` ${new Date(release.publishedAt || release.createdAt).toLocaleDateString()} • @${release.author.username}`,
186
+ ),
187
+ );
188
+ }
189
+
190
+ console.log("");
191
+ } catch (error) {
192
+ spinner.fail("Failed to list releases");
193
+ console.error(
194
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
195
+ );
196
+ process.exit(1);
197
+ }
198
+ });
188
199
189
200
// Release View
190
201
releaseCommands
191
- .command("view <tag>")
192
- .description("View a release")
193
- .action(async (tag: string) => {
194
- const config = getConfig();
195
- if (!config.token) {
196
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
197
- process.exit(1);
198
- }
202
+ .command("view <tag>")
203
+ .description("View a release")
204
+ .action(async (tag: string) => {
205
+ const config = getConfig();
206
+ if (!config.token) {
207
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
208
+ process.exit(1);
209
+ }
199
210
200
- const spinner = ora("Fetching release...").start();
201
-
202
- try {
203
- const repoInfo = await getRepoInfo();
204
- if (!repoInfo) {
205
- spinner.fail("Could not determine repository");
206
- process.exit(1);
207
- }
208
-
209
- const result = await getWithAuth<{ data: Release }>(
210
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases/tags/${tag}`
211
- );
212
-
213
- spinner.stop();
214
-
215
- const release = result.data;
216
-
217
- console.log(chalk.bold(`\n📦 ${release.tagName}`));
218
- if (release.name !== release.tagName) {
219
- console.log(`Title: ${release.name}`);
220
- }
221
- console.log(`Author: @${release.author.username}`);
222
- console.log(`Date: ${new Date(release.publishedAt || release.createdAt).toLocaleString()}`);
223
-
224
- if (release.isDraft) console.log(chalk.yellow("Status: Draft"));
225
- if (release.isPrerelease) console.log(chalk.blue("Status: Pre-release"));
226
-
227
- if (release.body) {
228
- console.log(chalk.dim("\n─".repeat(40)));
229
- console.log(release.body);
230
- }
231
-
232
- if (release.assets && release.assets.length > 0) {
233
- console.log(chalk.bold("\nAssets:"));
234
- for (const asset of release.assets) {
235
- const size = (asset.size / 1024 / 1024).toFixed(2);
236
- console.log(` 📎 ${asset.name} (${size} MB)`);
237
- }
238
- }
239
-
240
- console.log(chalk.dim("\n─".repeat(40)));
241
- console.log(chalk.dim(`${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/releases/tag/${tag}`));
242
- console.log("");
243
- } catch (error) {
244
- spinner.fail("Failed to fetch release");
245
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
246
- process.exit(1);
211
+ const spinner = ora("Fetching release...").start();
212
+
213
+ try {
214
+ const repoInfo = await getRepoInfo();
215
+ if (!repoInfo) {
216
+ spinner.fail("Could not determine repository");
217
+ process.exit(1);
218
+ }
219
+
220
+ const result = await getWithAuth<{ data: Release }>(
221
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases/tags/${tag}`,
222
+ );
223
+
224
+ spinner.stop();
225
+
226
+ const release = result.data;
227
+
228
+ console.log(chalk.bold(`\n📦 ${release.tagName}`));
229
+ if (release.name !== release.tagName) {
230
+ console.log(`Title: ${release.name}`);
231
+ }
232
+ console.log(`Author: @${release.author.username}`);
233
+ console.log(
234
+ `Date: ${new Date(release.publishedAt || release.createdAt).toLocaleString()}`,
235
+ );
236
+
237
+ if (release.isDraft) console.log(chalk.yellow("Status: Draft"));
238
+ if (release.isPrerelease) console.log(chalk.blue("Status: Pre-release"));
239
+
240
+ if (release.body) {
241
+ console.log(chalk.dim("\n─".repeat(40)));
242
+ console.log(release.body);
243
+ }
244
+
245
+ if (release.assets && release.assets.length > 0) {
246
+ console.log(chalk.bold("\nAssets:"));
247
+ for (const asset of release.assets) {
248
+ const size = (asset.size / 1024 / 1024).toFixed(2);
249
+ console.log(` 📎 ${asset.name} (${size} MB)`);
247
250
}
248
- });
251
+ }
252
+
253
+ console.log(chalk.dim("\n─".repeat(40)));
254
+ console.log(
255
+ chalk.dim(
256
+ `${config.serverUrl}/${repoInfo.owner}/${repoInfo.repo}/releases/tag/${tag}`,
257
+ ),
258
+ );
259
+ console.log("");
260
+ } catch (error) {
261
+ spinner.fail("Failed to fetch release");
262
+ console.error(
263
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
264
+ );
265
+ process.exit(1);
266
+ }
267
+ });
249
268
250
269
// Release Delete
251
270
releaseCommands
252
- .command("delete <tag>")
253
- .description("Delete a release")
254
- .option("-y, --yes", "Skip confirmation")
255
- .action(async (tag: string, options) => {
256
- const config = getConfig();
257
- if (!config.token) {
258
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
259
- process.exit(1);
260
- }
271
+ .command("delete <tag>")
272
+ .description("Delete a release")
273
+ .option("-y, --yes", "Skip confirmation")
274
+ .action(async (tag: string, options) => {
275
+ const config = getConfig();
276
+ if (!config.token) {
277
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
278
+ process.exit(1);
279
+ }
261
280
262
- try {
263
- const repoInfo = await getRepoInfo();
264
- if (!repoInfo) {
265
- console.error(chalk.red("Could not determine repository"));
266
- process.exit(1);
267
- }
268
-
269
- if (!options.yes) {
270
- const { confirm } = await inquirer.prompt([
271
- {
272
- type: "confirm",
273
- name: "confirm",
274
- message: `Delete release ${tag}?`,
275
- default: false,
276
- },
277
- ]);
278
- if (!confirm) {
279
- console.log(chalk.dim("Cancelled."));
280
- return;
281
- }
282
- }
283
-
284
- const spinner = ora(`Deleting release ${tag}...`).start();
285
-
286
- const { deleteWithAuth } = await import("../../lib/api.js");
287
- await deleteWithAuth(
288
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases/tags/${tag}`
289
- );
290
-
291
- spinner.succeed(`Deleted release ${tag}`);
292
- } catch (error) {
293
- console.error(chalk.red("Failed to delete release"));
294
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
295
- process.exit(1);
281
+ try {
282
+ const repoInfo = await getRepoInfo();
283
+ if (!repoInfo) {
284
+ console.error(chalk.red("Could not determine repository"));
285
+ process.exit(1);
286
+ }
287
+
288
+ if (!options.yes) {
289
+ const { confirm } = await inquirer.prompt([
290
+ {
291
+ type: "confirm",
292
+ name: "confirm",
293
+ message: `Delete release ${tag}?`,
294
+ default: false,
295
+ },
296
+ ]);
297
+ if (!confirm) {
298
+ console.log(chalk.dim("Cancelled."));
299
+ return;
296
300
}
297
- });
301
+ }
302
+
303
+ const spinner = ora(`Deleting release ${tag}...`).start();
304
+
305
+ const { deleteWithAuth } = await import("../../lib/api.js");
306
+ await deleteWithAuth(
307
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/releases/tags/${tag}`,
308
+ );
309
+
310
+ spinner.succeed(`Deleted release ${tag}`);
311
+ } catch (error) {
312
+ console.error(chalk.red("Failed to delete release"));
313
+ console.error(
314
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
315
+ );
316
+ process.exit(1);
317
+ }
318
+ });
298
319
299
320
export default releaseCommands;
300
321
▼cli/src/commands/repo.ts
+396-402
6
6
import { execSync, spawnSync } from "child_process";
7
7
import * as fs from "fs";
8
8
import * as path from "path";
9
-import { apiCall, getWithAuth, postWithAuth } from "../lib/api.js";
9
+import { applyTlsConfig, getWithAuth, postWithAuth } from "../lib/api.js";
10
10
import { getConfig } from "../lib/config.js";
11
+import { parseRepoFromRemoteUrl } from "../lib/git.js";
11
12
12
13
interface RepoInfo {
13
- id: string;
14
- name: string;
15
- fullName: string;
16
- description?: string;
17
- visibility: string;
18
- defaultBranch: string;
19
- httpCloneUrl: string;
14
+ id: string;
15
+ name: string;
16
+ fullName: string;
17
+ description?: string;
18
+ visibility: string;
19
+ defaultBranch: string;
20
+ httpCloneUrl: string;
20
21
}
21
22
22
23
/**
23
24
* Get git root directory
24
25
*/
25
26
function getGitRoot(): string | null {
26
- try {
27
- const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
28
- encoding: "utf-8",
29
- });
30
- if (result.status === 0) {
31
- return result.stdout.trim();
32
- }
33
- return null;
34
- } catch {
35
- return null;
27
+ try {
28
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
29
+ encoding: "utf-8",
30
+ });
31
+ if (result.status === 0) {
32
+ return result.stdout.trim();
36
33
}
34
+ return null;
35
+ } catch {
36
+ return null;
37
+ }
37
38
}
38
39
39
40
/**
40
41
* Get current branch name
41
42
*/
42
43
function getCurrentBranch(): string {
43
- const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
44
- encoding: "utf-8",
45
- });
46
- return result.stdout.trim() || "main";
44
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
45
+ encoding: "utf-8",
46
+ });
47
+ return result.stdout.trim() || "main";
47
48
}
48
49
49
50
/**
50
51
* Create a git bundle/packfile of commits to push
51
52
*/
52
53
function createBundle(gitRoot: string, branch: string): Buffer {
53
- // Create a bundle of all commits on the branch
54
- const bundlePath = path.join(gitRoot, ".git", "opencodehub-push.bundle");
55
-
56
- try {
57
- // Create bundle with all refs
58
- execSync(`git bundle create "${bundlePath}" --all`, {
59
- cwd: gitRoot,
60
- stdio: "pipe",
61
- });
54
+ // Create a bundle of all commits on the branch
55
+ const bundlePath = path.join(gitRoot, ".git", "opencodehub-push.bundle");
56
+
57
+ try {
58
+ // Create bundle with all refs
59
+ execSync(`git bundle create "${bundlePath}" --all`, {
60
+ cwd: gitRoot,
61
+ stdio: "pipe",
62
+ });
62
63
63
- const bundle = fs.readFileSync(bundlePath);
64
- fs.unlinkSync(bundlePath); // Clean up
65
- return bundle;
66
- } catch (error) {
67
- throw new Error(`Failed to create git bundle: ${error}`);
68
- }
64
+ const bundle = fs.readFileSync(bundlePath);
65
+ fs.unlinkSync(bundlePath); // Clean up
66
+ return bundle;
67
+ } catch (error) {
68
+ throw new Error(`Failed to create git bundle: ${error}`);
69
+ }
69
70
}
70
71
71
72
/**
72
73
* Get remote origin info to determine repo name
73
74
*/
74
-function getRemoteInfo(gitRoot: string): { owner: string; repo: string } | null {
75
- try {
76
- const result = spawnSync("git", ["remote", "get-url", "origin"], {
77
- cwd: gitRoot,
78
- encoding: "utf-8",
79
- });
80
-
81
- if (result.status !== 0) {
82
- return null;
83
- }
84
-
85
- const url = result.stdout.trim();
86
-
87
- // Parse URL formats:
88
- // https://host/owner/repo.git
89
- // git@host:owner/repo.git
90
- // ssh://git@host:port/owner/repo.git
91
- const httpsMatch = url.match(/https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?$/);
92
- const sshMatch = url.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/);
93
- const sshUrlMatch = url.match(/ssh:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?$/);
94
-
95
- const match = httpsMatch || sshMatch || sshUrlMatch;
96
- if (match) {
97
- return { owner: match[1], repo: match[2] };
98
- }
75
+function getRemoteInfo(
76
+ gitRoot: string,
77
+): { owner: string; repo: string } | null {
78
+ try {
79
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
80
+ cwd: gitRoot,
81
+ encoding: "utf-8",
82
+ });
99
83
100
- return null;
101
- } catch {
102
- return null;
84
+ if (result.status !== 0) {
85
+ return null;
103
86
}
87
+
88
+ const url = result.stdout.trim();
89
+ return parseRepoFromRemoteUrl(url);
90
+ } catch {
91
+ return null;
92
+ }
104
93
}
105
94
106
95
/**
107
96
* Push local repository to OpenCodeHub
108
97
*/
109
98
export async function pushRepo(options: {
110
- remote?: string;
111
- branch?: string;
112
- force?: boolean;
99
+ remote?: string;
100
+ branch?: string;
101
+ force?: boolean;
113
102
}): Promise<void> {
114
- const config = getConfig();
115
-
116
- if (!config.token) {
117
- const { errorBox } = await import("../lib/formatter.js");
118
- errorBox("Authentication Required", [
119
- "You are not logged in.",
120
- "",
121
- "Run: och auth login",
122
- ]);
123
- process.exit(1);
124
- }
125
-
126
- const gitRoot = getGitRoot();
127
- if (!gitRoot) {
128
- const { errorBox } = await import("../lib/formatter.js");
129
- errorBox("Not a Git Repository", [
130
- "This directory is not a git repository.",
131
- "",
132
- "Run: git init",
133
- ]);
134
- process.exit(1);
103
+ const config = getConfig();
104
+
105
+ if (!config.token) {
106
+ const { errorBox } = await import("../lib/formatter.js");
107
+ errorBox("Authentication Required", [
108
+ "You are not logged in.",
109
+ "",
110
+ "Run: och auth login",
111
+ ]);
112
+ throw new Error("Command aborted");
113
+ }
114
+
115
+ const gitRoot = getGitRoot();
116
+ if (!gitRoot) {
117
+ const { errorBox } = await import("../lib/formatter.js");
118
+ errorBox("Not a Git Repository", [
119
+ "This directory is not a git repository.",
120
+ "",
121
+ "Run: git init",
122
+ ]);
123
+ throw new Error("Command aborted");
124
+ }
125
+
126
+ // Determine repo to push to
127
+ let owner: string;
128
+ let repo: string;
129
+
130
+ const remoteInfo = getRemoteInfo(gitRoot);
131
+ if (remoteInfo) {
132
+ owner = remoteInfo.owner;
133
+ repo = remoteInfo.repo;
134
+ } else {
135
+ // Use directory name as repo name
136
+ repo = path.basename(gitRoot);
137
+
138
+ // Get user info
139
+ try {
140
+ const userInfo = await getWithAuth<{ data: { username: string } }>(
141
+ "/api/user",
142
+ );
143
+ owner = userInfo.data.username;
144
+ } catch {
145
+ const { errorBox } = await import("../lib/formatter.js");
146
+ errorBox("Failed to Get User Info", [
147
+ "Could not determine repository owner.",
148
+ "",
149
+ "Check your authentication: och auth login",
150
+ ]);
151
+ throw new Error("Command aborted");
135
152
}
153
+ }
154
+
155
+ const branch = options.branch || getCurrentBranch();
156
+
157
+ // Show header
158
+ const { log, colors } = await import("../lib/branding.js");
159
+ const {
160
+ showObjectEnumeration,
161
+ showObjectCounting,
162
+ showCompression,
163
+ showWritingObjects,
164
+ showRemoteResolving,
165
+ formatBytes,
166
+ formatSpeed,
167
+ showRefUpdate,
168
+ createSpinner,
169
+ } = await import("../lib/progress.js");
170
+
171
+ console.log(""); // Empty line
172
+ log.info(`Pushing to ${colors.highlight(owner + "/" + repo)}`);
173
+ log.dim(`Branch: ${branch}`);
174
+ console.log("");
175
+
176
+ // Create bundle with progress
177
+ const spinner = createSpinner("Preparing objects...");
178
+ spinner.start();
179
+
180
+ let bundle: Buffer;
181
+ try {
182
+ bundle = createBundle(gitRoot, branch);
183
+ spinner.succeed("Objects prepared");
184
+ } catch (error) {
185
+ spinner.fail("Failed to create bundle");
186
+ throw error;
187
+ }
188
+
189
+ // Simulate GitHub-like output for bundle creation
190
+ // Count objects in bundle (rough estimate based on size)
191
+ const estimatedObjects = Math.floor(bundle.length / 500); // Rough estimate
192
+
193
+ showObjectEnumeration(estimatedObjects);
194
+ showObjectCounting(estimatedObjects, estimatedObjects);
195
+ showCompression(Math.floor(estimatedObjects * 0.5));
196
+ showWritingObjects(
197
+ estimatedObjects,
198
+ formatBytes(bundle.length),
199
+ formatSpeed(bundle.length / 0.5), // Assume 0.5s compression time
200
+ );
201
+
202
+ // Upload to API
203
+ console.log("");
204
+ const uploadSpinner = createSpinner("Uploading to OpenCodeHub...");
205
+ uploadSpinner.start();
206
+
207
+ const startTime = Date.now();
208
+
209
+ try {
210
+ applyTlsConfig();
211
+ const response = await fetch(
212
+ `${config.serverUrl}/api/repos/${owner}/${repo}/git/push`,
213
+ {
214
+ method: "POST",
215
+ headers: {
216
+ Authorization: `Bearer ${config.token}`,
217
+ "Content-Type": "application/octet-stream",
218
+ "X-Git-Branch": branch,
219
+ "X-Git-Force": options.force ? "true" : "false",
220
+ },
221
+ body: new Uint8Array(bundle),
222
+ },
223
+ );
136
224
137
- // Determine repo to push to
138
- let owner: string;
139
- let repo: string;
225
+ const uploadTime = (Date.now() - startTime) / 1000;
226
+ const uploadSpeed = bundle.length / uploadTime;
140
227
141
- const remoteInfo = getRemoteInfo(gitRoot);
142
- if (remoteInfo) {
143
- owner = remoteInfo.owner;
144
- repo = remoteInfo.repo;
145
- } else {
146
- // Use directory name as repo name
147
- repo = path.basename(gitRoot);
148
-
149
- // Get user info
150
- try {
151
- const userInfo = await getWithAuth<{ data: { username: string } }>("/api/user");
152
- owner = userInfo.data.username;
153
- } catch {
154
- const { errorBox } = await import("../lib/formatter.js");
155
- errorBox("Failed to Get User Info", [
156
- "Could not determine repository owner.",
157
- "",
158
- "Check your authentication: och auth login",
159
- ]);
160
- process.exit(1);
161
- }
162
- }
163
-
164
- const branch = options.branch || getCurrentBranch();
165
-
166
- // Show header
167
- const { log, colors } = await import("../lib/branding.js");
168
- const {
169
- showObjectEnumeration,
170
- showObjectCounting,
171
- showCompression,
172
- showWritingObjects,
173
- showRemoteResolving,
174
- formatBytes,
175
- formatSpeed,
176
- showRefUpdate,
177
- createSpinner,
178
- } = await import("../lib/progress.js");
179
-
180
- console.log(""); // Empty line
181
- log.info(`Pushing to ${colors.highlight(owner + "/" + repo)}`);
182
- log.dim(`Branch: ${branch}`);
183
- console.log("");
228
+ const result = await response.json();
184
229
185
- // Create bundle with progress
186
- const spinner = createSpinner("Preparing objects...");
187
- spinner.start();
230
+ if (!response.ok) {
231
+ uploadSpinner.fail("Upload failed");
188
232
189
- let bundle: Buffer;
190
- try {
191
- bundle = createBundle(gitRoot, branch);
192
- spinner.succeed("Objects prepared");
193
- } catch (error) {
194
- spinner.fail("Failed to create bundle");
195
- throw error;
233
+ if (response.status === 404) {
234
+ const { errorBox } = await import("../lib/formatter.js");
235
+ errorBox("Repository Not Found", [
236
+ `Repository ${owner}/${repo} does not exist.`,
237
+ "",
238
+ `Create it first: och repo create ${repo}`,
239
+ ]);
240
+ } else {
241
+ const { errorBox } = await import("../lib/formatter.js");
242
+ errorBox("Push Failed", [result.error || response.statusText]);
243
+ }
244
+ throw new Error("Command aborted");
196
245
}
197
246
198
- // Simulate GitHub-like output for bundle creation
199
- // Count objects in bundle (rough estimate based on size)
200
- const estimatedObjects = Math.floor(bundle.length / 500); // Rough estimate
201
-
202
- showObjectEnumeration(estimatedObjects);
203
- showObjectCounting(estimatedObjects, estimatedObjects);
204
- showCompression(Math.floor(estimatedObjects * 0.5));
205
- showWritingObjects(
206
- estimatedObjects,
207
- formatBytes(bundle.length),
208
- formatSpeed(bundle.length / 0.5) // Assume 0.5s compression time
247
+ uploadSpinner.succeed(
248
+ `Uploaded ${formatBytes(bundle.length)} in ${uploadTime.toFixed(2)}s (${formatSpeed(uploadSpeed)})`,
209
249
);
210
250
211
- // Upload to API
251
+ // Show remote resolution (simulated)
212
252
console.log("");
213
- const uploadSpinner = createSpinner("Uploading to OpenCodeHub...");
214
- uploadSpinner.start();
215
-
216
- const startTime = Date.now();
217
-
218
- try {
219
- const response = await fetch(`${config.serverUrl}/api/repos/${owner}/${repo}/git/push`, {
220
- method: "POST",
221
- headers: {
222
- "Authorization": `Bearer ${config.token}`,
223
- "Content-Type": "application/octet-stream",
224
- "X-Git-Branch": branch,
225
- "X-Git-Force": options.force ? "true" : "false",
226
- },
227
- body: new Uint8Array(bundle),
228
- });
229
-
230
- const uploadTime = (Date.now() - startTime) / 1000;
231
- const uploadSpeed = bundle.length / uploadTime;
232
-
233
- const result = await response.json();
234
-
235
- if (!response.ok) {
236
- uploadSpinner.fail("Upload failed");
237
-
238
- if (response.status === 404) {
239
- const { errorBox } = await import("../lib/formatter.js");
240
- errorBox("Repository Not Found", [
241
- `Repository ${owner}/${repo} does not exist.`,
242
- "",
243
- `Create it first: och repo create ${repo}`,
244
- ]);
245
- } else {
246
- const { errorBox } = await import("../lib/formatter.js");
247
- errorBox("Push Failed", [
248
- result.error || response.statusText,
249
- ]);
250
- }
251
- process.exit(1);
252
- }
253
-
254
- uploadSpinner.succeed(`Uploaded ${formatBytes(bundle.length)} in ${uploadTime.toFixed(2)}s (${formatSpeed(uploadSpeed)})`);
255
-
256
- // Show remote resolution (simulated)
257
- console.log("");
258
- showRemoteResolving("Processing", 100, estimatedObjects, estimatedObjects);
259
- console.log(colors.dim("remote: "));
260
-
261
- // Show ref updates
262
- console.log(colors.dim(`To ${config.serverUrl}/${owner}/${repo}.git`));
263
-
264
- if (result.data?.refs && result.data.refs.length > 0) {
265
- result.data.refs.forEach((ref: string) => {
266
- // Determine if new branch or update
267
- const isNewBranch = ref.includes("new");
268
- showRefUpdate("0000000", "abc1234", ref, isNewBranch ? "new" : "updated");
269
- });
270
- } else {
271
- showRefUpdate("abc1234", "def5678", `${branch} -> ${branch}`, "updated");
272
- }
273
-
274
- // Success box
275
- console.log("");
276
- const { successBox } = await import("../lib/formatter.js");
277
- successBox("✨ Push Successful!", [
278
- `Repository: ${colors.highlight(owner + "/" + repo)}`,
279
- `Branch: ${colors.highlight(branch)}`,
280
- `Size: ${formatBytes(bundle.length)}`,
281
- "",
282
- `View at: ${colors.info(config.serverUrl + "/" + owner + "/" + repo)}`,
283
- ]);
284
-
285
- } catch (error) {
286
- uploadSpinner.fail("Upload failed");
287
- const { errorBox } = await import("../lib/formatter.js");
288
- errorBox("Network Error", [
289
- String(error),
290
- "",
291
- "Please check your connection and try again.",
292
- ]);
293
- process.exit(1);
253
+ showRemoteResolving("Processing", 100, estimatedObjects, estimatedObjects);
254
+ console.log(colors.dim("remote: "));
255
+
256
+ // Show ref updates
257
+ console.log(colors.dim(`To ${config.serverUrl}/${owner}/${repo}.git`));
258
+
259
+ if (result.data?.refs && result.data.refs.length > 0) {
260
+ result.data.refs.forEach((ref: string) => {
261
+ // Determine if new branch or update
262
+ const isNewBranch = ref.includes("new");
263
+ showRefUpdate(
264
+ "0000000",
265
+ "abc1234",
266
+ ref,
267
+ isNewBranch ? "new" : "updated",
268
+ );
269
+ });
270
+ } else {
271
+ showRefUpdate("abc1234", "def5678", `${branch} -> ${branch}`, "updated");
294
272
}
273
+
274
+ // Success box
275
+ console.log("");
276
+ const { successBox } = await import("../lib/formatter.js");
277
+ successBox("✨ Push Successful!", [
278
+ `Repository: ${colors.highlight(owner + "/" + repo)}`,
279
+ `Branch: ${colors.highlight(branch)}`,
280
+ `Size: ${formatBytes(bundle.length)}`,
281
+ "",
282
+ `View at: ${colors.info(config.serverUrl + "/" + owner + "/" + repo)}`,
283
+ ]);
284
+ } catch (error) {
285
+ uploadSpinner.fail("Upload failed");
286
+ const { errorBox } = await import("../lib/formatter.js");
287
+ errorBox("Network Error", [
288
+ String(error),
289
+ "",
290
+ "Please check your connection and try again.",
291
+ ]);
292
+ throw new Error("Command aborted");
293
+ }
295
294
}
296
295
297
296
/**
298
297
* Clone a repository from OpenCodeHub
299
298
*/
300
299
export async function cloneRepo(
301
- repoName: string,
302
- destination?: string
300
+ repoName: string,
301
+ destination?: string,
303
302
): Promise<void> {
304
- const config = getConfig();
305
- const { log, colors } = await import("../lib/branding.js");
306
- const { createSpinner, formatBytes } = await import("../lib/progress.js");
307
- const { successBox, errorBox } = await import("../lib/formatter.js");
308
-
309
- // Parse owner/repo format
310
- let owner: string;
311
- let repo: string;
312
-
313
- if (repoName.includes("/")) {
314
- [owner, repo] = repoName.split("/");
315
- } else {
316
- // Get current user
317
- try {
318
- const userInfo = await getWithAuth<{ data: { username: string } }>("/api/user");
319
- owner = userInfo.data.username;
320
- repo = repoName;
321
- } catch {
322
- errorBox("Failed to Get User Info", [
323
- "Could not determine repository owner.",
324
- "",
325
- "Specify full repo name: owner/repo",
326
- ]);
327
- process.exit(1);
328
- }
303
+ const config = getConfig();
304
+ const { log, colors } = await import("../lib/branding.js");
305
+ const { createSpinner, formatBytes } = await import("../lib/progress.js");
306
+ const { successBox, errorBox } = await import("../lib/formatter.js");
307
+
308
+ // Parse owner/repo format
309
+ let owner: string;
310
+ let repo: string;
311
+
312
+ if (repoName.includes("/")) {
313
+ [owner, repo] = repoName.split("/");
314
+ } else {
315
+ // Get current user
316
+ try {
317
+ const userInfo = await getWithAuth<{ data: { username: string } }>(
318
+ "/api/user",
319
+ );
320
+ owner = userInfo.data.username;
321
+ repo = repoName;
322
+ } catch {
323
+ errorBox("Failed to Get User Info", [
324
+ "Could not determine repository owner.",
325
+ "",
326
+ "Specify full repo name: owner/repo",
327
+ ]);
328
+ throw new Error("Command aborted");
329
329
}
330
+ }
330
331
331
- console.log("");
332
- log.info(`Cloning ${colors.highlight(owner + "/" + repo)}`);
332
+ console.log("");
333
+ log.info(`Cloning ${colors.highlight(owner + "/" + repo)}`);
333
334
334
- // Get repo info
335
- const spinner = createSpinner("Fetching repository information...");
336
- spinner.start();
335
+ // Get repo info
336
+ const spinner = createSpinner("Fetching repository information...");
337
+ spinner.start();
337
338
338
- try {
339
- const repoInfo = await getWithAuth<{ data: RepoInfo }>(`/api/repos/${owner}/${repo}`);
340
- spinner.succeed("Repository found");
339
+ try {
340
+ const repoInfo = await getWithAuth<{ data: RepoInfo }>(
341
+ `/api/repos/${owner}/${repo}`,
342
+ );
343
+ spinner.succeed("Repository found");
341
344
342
- const cloneUrl = repoInfo.data.httpCloneUrl;
343
- const dest = destination || repo;
345
+ const cloneUrl = repoInfo.data.httpCloneUrl;
346
+ const dest = destination || repo;
344
347
345
- // Clone using git
346
- console.log("");
347
- log.step(`Cloning into ${colors.highlight(dest)}/...`);
348
- console.log("");
348
+ // Clone using git
349
+ console.log("");
350
+ log.step(`Cloning into ${colors.highlight(dest)}/...`);
351
+ console.log("");
349
352
350
- execSync(`git clone "${cloneUrl}" "${dest}"`, { stdio: "inherit" });
353
+ execSync(`git clone "${cloneUrl}" "${dest}"`, { stdio: "inherit" });
351
354
352
- console.log("");
353
- successBox("✨ Clone Successful!", [
354
- `Repository: ${colors.highlight(owner + "/" + repo)}`,
355
- `Location: ${colors.highlight(dest)}/`,
356
- "",
357
- `cd ${dest} && och push`,
358
- ]);
359
- } catch (error: any) {
360
- spinner.fail("Clone failed");
361
- errorBox("Clone Error", [
362
- error.message || String(error),
363
- ]);
364
- process.exit(1);
365
- }
355
+ console.log("");
356
+ successBox("✨ Clone Successful!", [
357
+ `Repository: ${colors.highlight(owner + "/" + repo)}`,
358
+ `Location: ${colors.highlight(dest)}/`,
359
+ "",
360
+ `cd ${dest} && och push`,
361
+ ]);
362
+ } catch (error: any) {
363
+ spinner.fail("Clone failed");
364
+ errorBox("Clone Error", [error.message || String(error)]);
365
+ throw new Error("Command aborted");
366
+ }
366
367
}
367
368
368
369
/**
369
370
* Create a new repository on OpenCodeHub
370
371
*/
371
372
export async function createRepo(options: {
372
- name: string;
373
- description?: string;
374
- visibility?: "public" | "private";
375
- init?: boolean;
373
+ name: string;
374
+ description?: string;
375
+ visibility?: "public" | "private";
376
+ init?: boolean;
376
377
}): Promise<void> {
377
- const config = getConfig();
378
- const { log, colors, showSuccess } = await import("../lib/branding.js");
379
- const { createSpinner } = await import("../lib/progress.js");
380
- const { successBox, errorBox } = await import("../lib/formatter.js");
381
-
382
- if (!config.token) {
383
- errorBox("Authentication Required", [
384
- "You are not logged in.",
385
- "",
386
- "Run: och auth login",
387
- ]);
388
- process.exit(1);
389
- }
390
-
391
- console.log("");
392
- const visibilityIcon = options.visibility === "private" ? "🔒" : "🌐";
393
- log.info(`Creating ${visibilityIcon} ${colors.highlight(options.name)}`);
394
- if (options.description) {
395
- log.dim(`Description: ${options.description}`);
396
- }
378
+ const config = getConfig();
379
+ const { log, colors, showSuccess } = await import("../lib/branding.js");
380
+ const { createSpinner } = await import("../lib/progress.js");
381
+ const { successBox, errorBox } = await import("../lib/formatter.js");
382
+
383
+ if (!config.token) {
384
+ errorBox("Authentication Required", [
385
+ "You are not logged in.",
386
+ "",
387
+ "Run: och auth login",
388
+ ]);
389
+ throw new Error("Command aborted");
390
+ }
391
+
392
+ console.log("");
393
+ const visibilityIcon = options.visibility === "private" ? "🔒" : "🌐";
394
+ log.info(`Creating ${visibilityIcon} ${colors.highlight(options.name)}`);
395
+ if (options.description) {
396
+ log.dim(`Description: ${options.description}`);
397
+ }
398
+
399
+ const spinner = createSpinner("Creating repository...");
400
+ spinner.start();
401
+
402
+ try {
403
+ const result = await postWithAuth<{ data: RepoInfo }>("/api/repos", {
404
+ name: options.name,
405
+ description: options.description,
406
+ visibility: options.visibility || "public",
407
+ readme: options.init !== false,
408
+ });
397
409
398
- const spinner = createSpinner("Creating repository...");
399
- spinner.start();
410
+ spinner.succeed("Repository created");
400
411
401
- try {
402
- const result = await postWithAuth<{ data: RepoInfo }>("/api/repos", {
403
- name: options.name,
404
- description: options.description,
405
- visibility: options.visibility || "public",
406
- readme: options.init !== false,
412
+ // If in a git repo, add as remote
413
+ const gitRoot = getGitRoot();
414
+ let remoteAdded = false;
415
+ if (gitRoot) {
416
+ try {
417
+ execSync(`git remote add opencode "${result.data.httpCloneUrl}"`, {
418
+ cwd: gitRoot,
419
+ stdio: "pipe",
407
420
});
421
+ remoteAdded = true;
422
+ } catch {
423
+ // Remote might already exist
424
+ }
425
+ }
408
426
409
- spinner.succeed("Repository created");
410
-
411
- // If in a git repo, add as remote
412
- const gitRoot = getGitRoot();
413
- let remoteAdded = false;
414
- if (gitRoot) {
415
- try {
416
- execSync(`git remote add opencode "${result.data.httpCloneUrl}"`, {
417
- cwd: gitRoot,
418
- stdio: "pipe",
419
- });
420
- remoteAdded = true;
421
- } catch {
422
- // Remote might already exist
423
- }
424
- }
425
-
426
- // Show ASCII art celebration
427
- console.log("");
428
- showSuccess(`Repository ${result.data.fullName} is ready!`);
429
-
430
- console.log("");
431
- const boxContent = [
432
- `Repository: ${colors.highlight(result.data.fullName)}`,
433
- `Visibility: ${options.visibility === "private" ? "🔒 Private" : "🌐 Public"}`,
434
- ];
435
-
436
- if (result.data.description) {
437
- boxContent.push(`Description: ${result.data.description}`);
438
- }
439
-
440
- boxContent.push(
441
- "",
442
- `Clone URL: ${colors.info(result.data.httpCloneUrl)}`,
443
- );
427
+ // Show ASCII art celebration
428
+ console.log("");
429
+ showSuccess(`Repository ${result.data.fullName} is ready!`);
444
430
445
- if (remoteAdded) {
446
- boxContent.push(
447
- "",
448
- "✓ Added remote 'opencode' to current repository",
449
- );
450
- }
431
+ console.log("");
432
+ const boxContent = [
433
+ `Repository: ${colors.highlight(result.data.fullName)}`,
434
+ `Visibility: ${options.visibility === "private" ? "🔒 Private" : "🌐 Public"}`,
435
+ ];
451
436
452
- boxContent.push(
453
- "",
454
- `View at: ${colors.info(config.serverUrl + "/" + result.data.fullName)}`,
455
- );
437
+ if (result.data.description) {
438
+ boxContent.push(`Description: ${result.data.description}`);
439
+ }
456
440
457
- successBox("🎉 Repository Created!", boxContent);
441
+ boxContent.push("", `Clone URL: ${colors.info(result.data.httpCloneUrl)}`);
458
442
459
- } catch (error: any) {
460
- spinner.fail("Creation failed");
461
- errorBox("Failed to Create Repository", [
462
- error.message || String(error),
463
- ]);
464
- process.exit(1);
443
+ if (remoteAdded) {
444
+ boxContent.push("", "✓ Added remote 'opencode' to current repository");
465
445
}
446
+
447
+ boxContent.push(
448
+ "",
449
+ `View at: ${colors.info(config.serverUrl + "/" + result.data.fullName)}`,
450
+ );
451
+
452
+ successBox("🎉 Repository Created!", boxContent);
453
+ } catch (error: any) {
454
+ spinner.fail("Creation failed");
455
+ errorBox("Failed to Create Repository", [error.message || String(error)]);
456
+ throw new Error("Command aborted");
457
+ }
466
458
}
467
459
468
460
/**
469
461
* List user's repositories
470
462
*/
471
463
export async function listRepos(): Promise<void> {
472
- const config = getConfig();
464
+ const config = getConfig();
473
465
474
- if (!config.token) {
475
- console.error("❌ Not logged in. Run 'opencodehub auth login' first.");
476
- process.exit(1);
477
- }
466
+ if (!config.token) {
467
+ console.error("❌ Not logged in. Run 'opencodehub auth login' first.");
468
+ throw new Error("Command aborted");
469
+ }
478
470
479
- try {
480
- const result = await getWithAuth<{ data: RepoInfo[] }>("/api/repos?owner=me");
481
-
482
- if (result.data.length === 0) {
483
- console.log("No repositories found.");
484
- return;
485
- }
486
-
487
- console.log("Your repositories:\n");
488
- result.data.forEach((repo) => {
489
- const visibility = repo.visibility === "private" ? "🔒" : "🌐";
490
- console.log(`${visibility} ${repo.fullName}`);
491
- if (repo.description) {
492
- console.log(` ${repo.description}`);
493
- }
494
- });
495
- } catch (error: any) {
496
- console.error(`❌ Failed to list repositories: ${error.message || error}`);
497
- process.exit(1);
471
+ try {
472
+ const result = await getWithAuth<{ data: RepoInfo[] }>(
473
+ "/api/repos?owner=me",
474
+ );
475
+
476
+ if (result.data.length === 0) {
477
+ console.log("No repositories found.");
478
+ return;
498
479
}
480
+
481
+ console.log("Your repositories:\n");
482
+ result.data.forEach((repo) => {
483
+ const visibility = repo.visibility === "private" ? "🔒" : "🌐";
484
+ console.log(`${visibility} ${repo.fullName}`);
485
+ if (repo.description) {
486
+ console.log(` ${repo.description}`);
487
+ }
488
+ });
489
+ } catch (error: any) {
490
+ console.error(`❌ Failed to list repositories: ${error.message || error}`);
491
+ throw new Error("Command aborted");
492
+ }
499
493
}
500
494
▼cli/src/commands/review/index.ts
+294-278
3
3
* Manage code reviews from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
7
+import { Command } from "commander";
9
8
import inquirer from "inquirer";
9
+import ora from "ora";
10
10
import { simpleGit } from "simple-git";
11
-import { getConfig } from "../../lib/config.js";
12
11
import { getWithAuth, postWithAuth } from "../../lib/api.js";
12
+import { getConfig } from "../../lib/config.js";
13
+import { getRepoInfoFromGit } from "../../lib/git.js";
13
14
14
15
const git = simpleGit();
15
16
16
17
interface AIReview {
17
- id: string;
18
- status: string;
19
- summary?: string;
20
- overallSeverity?: string;
21
- tokensUsed?: number;
22
- createdAt: string;
23
- completedAt?: string;
24
- suggestions?: AIReviewSuggestion[];
18
+ id: string;
19
+ status: string;
20
+ summary?: string;
21
+ overallSeverity?: string;
22
+ tokensUsed?: number;
23
+ createdAt: string;
24
+ completedAt?: string;
25
+ suggestions?: AIReviewSuggestion[];
25
26
}
26
27
27
28
interface AIReviewSuggestion {
28
- id: string;
29
- path: string;
30
- line?: number;
31
- severity: string;
32
- type: string;
33
- title: string;
34
- message: string;
35
- suggestedFix?: string;
29
+ id: string;
30
+ path: string;
31
+ line?: number;
32
+ severity: string;
33
+ type: string;
34
+ title: string;
35
+ message: string;
36
+ suggestedFix?: string;
36
37
}
37
38
38
-export const reviewCommands = new Command("review")
39
- .description("Manage code reviews");
39
+export const reviewCommands = new Command("review").description(
40
+ "Manage code reviews",
41
+);
40
42
41
43
// Get repo info from git remote
42
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
43
- try {
44
- const remotes = await git.getRemotes(true);
45
- const origin = remotes.find(r => r.name === "origin");
46
- if (!origin?.refs?.push) return null;
47
-
48
- const url = origin.refs.push;
49
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
50
- if (match) {
51
- return { owner: match[1], repo: match[2] };
52
- }
53
- return null;
54
- } catch {
55
- return null;
56
- }
44
+async function getRepoInfo() {
45
+ return getRepoInfoFromGit(git);
57
46
}
58
47
59
48
function getSeverityIcon(severity: string): string {
60
- switch (severity) {
61
- case "critical": return chalk.red("🔴");
62
- case "error": return chalk.red("⛔");
63
- case "warning": return chalk.yellow("⚠️");
64
- case "info": return chalk.blue("ℹ️");
65
- default: return chalk.dim("○");
66
- }
49
+ switch (severity) {
50
+ case "critical":
51
+ return chalk.red("🔴");
52
+ case "error":
53
+ return chalk.red("⛔");
54
+ case "warning":
55
+ return chalk.yellow("⚠️");
56
+ case "info":
57
+ return chalk.blue("ℹ️");
58
+ default:
59
+ return chalk.dim("○");
60
+ }
67
61
}
68
62
69
63
// Review AI - Trigger AI review
70
64
reviewCommands
71
- .command("ai <pr-number>")
72
- .description("Trigger AI code review for a pull request")
73
- .option("--wait", "Wait for review to complete")
74
- .action(async (prNumber: string, options) => {
75
- const config = getConfig();
76
- if (!config.token) {
77
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
78
- process.exit(1);
79
- }
65
+ .command("ai <pr-number>")
66
+ .description("Trigger AI code review for a pull request")
67
+ .option("--wait", "Wait for review to complete")
68
+ .action(async (prNumber: string, options) => {
69
+ const config = getConfig();
70
+ if (!config.token) {
71
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
72
+ process.exit(1);
73
+ }
80
74
81
- const spinner = ora(`Triggering AI review for PR #${prNumber}...`).start();
82
-
83
- try {
84
- const repoInfo = await getRepoInfo();
85
- if (!repoInfo) {
86
- spinner.fail("Could not determine repository");
87
- process.exit(1);
88
- }
89
-
90
- const result = await postWithAuth<{ data: AIReview }>(
91
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review`,
92
- {}
93
- );
94
-
95
- if (options.wait) {
96
- spinner.text = "Waiting for AI review to complete...";
97
-
98
- let review = result.data;
99
- while (review.status === "pending" || review.status === "running") {
100
- await new Promise(resolve => setTimeout(resolve, 3000));
101
- const statusResult = await getWithAuth<{ data: AIReview }>(
102
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review/latest`
103
- );
104
- review = statusResult.data;
105
- }
106
-
107
- spinner.stop();
108
- displayReview(review);
109
- } else {
110
- spinner.succeed(`AI review triggered for PR #${prNumber}`);
111
- console.log(chalk.dim(" Run 'och review status " + prNumber + "' to check progress"));
112
- }
113
- } catch (error) {
114
- spinner.fail("Failed to trigger AI review");
115
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
116
- process.exit(1);
117
- }
118
- });
75
+ const spinner = ora(`Triggering AI review for PR #${prNumber}...`).start();
119
76
120
-// Review Status - Check AI review status
121
-reviewCommands
122
- .command("status <pr-number>")
123
- .description("Check AI review status for a pull request")
124
- .action(async (prNumber: string) => {
125
- const config = getConfig();
126
- if (!config.token) {
127
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
128
- process.exit(1);
129
- }
130
-
131
- const spinner = ora(`Fetching review status for PR #${prNumber}...`).start();
132
-
133
- try {
134
- const repoInfo = await getRepoInfo();
135
- if (!repoInfo) {
136
- spinner.fail("Could not determine repository");
137
- process.exit(1);
138
- }
139
-
140
- const result = await getWithAuth<{ data: AIReview }>(
141
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review/latest`
142
- );
143
-
144
- spinner.stop();
145
- displayReview(result.data);
146
- } catch (error) {
147
- spinner.fail("Failed to fetch review status");
148
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
149
- process.exit(1);
77
+ try {
78
+ const repoInfo = await getRepoInfo();
79
+ if (!repoInfo) {
80
+ spinner.fail("Could not determine repository");
81
+ process.exit(1);
82
+ }
83
+
84
+ const result = await postWithAuth<{ data: AIReview }>(
85
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review`,
86
+ {},
87
+ );
88
+
89
+ if (options.wait) {
90
+ spinner.text = "Waiting for AI review to complete...";
91
+
92
+ let review = result.data;
93
+ while (review.status === "pending" || review.status === "running") {
94
+ await new Promise((resolve) => setTimeout(resolve, 3000));
95
+ const statusResult = await getWithAuth<{ data: AIReview }>(
96
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review/latest`,
97
+ );
98
+ review = statusResult.data;
150
99
}
151
- });
152
100
153
-function displayReview(review: AIReview) {
154
- console.log(chalk.bold("\n🤖 AI Review\n"));
155
- console.log(`Status: ${review.status}`);
156
-
157
- if (review.overallSeverity) {
158
- console.log(`Severity: ${getSeverityIcon(review.overallSeverity)} ${review.overallSeverity}`);
101
+ spinner.stop();
102
+ displayReview(review);
103
+ } else {
104
+ spinner.succeed(`AI review triggered for PR #${prNumber}`);
105
+ console.log(
106
+ chalk.dim(
107
+ " Run 'och review status " + prNumber + "' to check progress",
108
+ ),
109
+ );
110
+ }
111
+ } catch (error) {
112
+ spinner.fail("Failed to trigger AI review");
113
+ console.error(
114
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
115
+ );
116
+ process.exit(1);
159
117
}
118
+ });
160
119
161
- if (review.summary) {
162
- console.log(chalk.dim("\n─".repeat(40)));
163
- console.log(chalk.bold("Summary:"));
164
- console.log(review.summary);
120
+// Review Status - Check AI review status
121
+reviewCommands
122
+ .command("status <pr-number>")
123
+ .description("Check AI review status for a pull request")
124
+ .action(async (prNumber: string) => {
125
+ const config = getConfig();
126
+ if (!config.token) {
127
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
128
+ process.exit(1);
165
129
}
166
130
167
- if (review.suggestions && review.suggestions.length > 0) {
168
- console.log(chalk.dim("\n─".repeat(40)));
169
- console.log(chalk.bold(`\nSuggestions (${review.suggestions.length}):\n`));
170
-
171
- for (const suggestion of review.suggestions) {
172
- const icon = getSeverityIcon(suggestion.severity);
173
- console.log(`${icon} ${chalk.bold(suggestion.title)}`);
174
- console.log(chalk.dim(` ${suggestion.path}${suggestion.line ? `:${suggestion.line}` : ""}`));
175
- console.log(` ${suggestion.message}`);
176
- if (suggestion.suggestedFix) {
177
- console.log(chalk.green(` Fix: ${suggestion.suggestedFix}`));
178
- }
179
- console.log("");
180
- }
131
+ const spinner = ora(
132
+ `Fetching review status for PR #${prNumber}...`,
133
+ ).start();
134
+
135
+ try {
136
+ const repoInfo = await getRepoInfo();
137
+ if (!repoInfo) {
138
+ spinner.fail("Could not determine repository");
139
+ process.exit(1);
140
+ }
141
+
142
+ const result = await getWithAuth<{ data: AIReview }>(
143
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review/latest`,
144
+ );
145
+
146
+ spinner.stop();
147
+ displayReview(result.data);
148
+ } catch (error) {
149
+ spinner.fail("Failed to fetch review status");
150
+ console.error(
151
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
152
+ );
153
+ process.exit(1);
181
154
}
155
+ });
182
156
183
- if (review.tokensUsed) {
184
- console.log(chalk.dim(`Tokens used: ${review.tokensUsed}`));
157
+function displayReview(review: AIReview) {
158
+ console.log(chalk.bold("\n🤖 AI Review\n"));
159
+ console.log(`Status: ${review.status}`);
160
+
161
+ if (review.overallSeverity) {
162
+ console.log(
163
+ `Severity: ${getSeverityIcon(review.overallSeverity)} ${review.overallSeverity}`,
164
+ );
165
+ }
166
+
167
+ if (review.summary) {
168
+ console.log(chalk.dim("\n─".repeat(40)));
169
+ console.log(chalk.bold("Summary:"));
170
+ console.log(review.summary);
171
+ }
172
+
173
+ if (review.suggestions && review.suggestions.length > 0) {
174
+ console.log(chalk.dim("\n─".repeat(40)));
175
+ console.log(chalk.bold(`\nSuggestions (${review.suggestions.length}):\n`));
176
+
177
+ for (const suggestion of review.suggestions) {
178
+ const icon = getSeverityIcon(suggestion.severity);
179
+ console.log(`${icon} ${chalk.bold(suggestion.title)}`);
180
+ console.log(
181
+ chalk.dim(
182
+ ` ${suggestion.path}${suggestion.line ? `:${suggestion.line}` : ""}`,
183
+ ),
184
+ );
185
+ console.log(` ${suggestion.message}`);
186
+ if (suggestion.suggestedFix) {
187
+ console.log(chalk.green(` Fix: ${suggestion.suggestedFix}`));
188
+ }
189
+ console.log("");
185
190
}
191
+ }
192
+
193
+ if (review.tokensUsed) {
194
+ console.log(chalk.dim(`Tokens used: ${review.tokensUsed}`));
195
+ }
186
196
}
187
197
188
198
// Review Approve
189
199
reviewCommands
190
- .command("approve <pr-number>")
191
- .description("Approve a pull request")
192
- .option("-b, --body <body>", "Review comment")
193
- .action(async (prNumber: string, options) => {
194
- const config = getConfig();
195
- if (!config.token) {
196
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
197
- process.exit(1);
198
- }
200
+ .command("approve <pr-number>")
201
+ .description("Approve a pull request")
202
+ .option("-b, --body <body>", "Review comment")
203
+ .action(async (prNumber: string, options) => {
204
+ const config = getConfig();
205
+ if (!config.token) {
206
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
207
+ process.exit(1);
208
+ }
199
209
200
- const spinner = ora(`Approving PR #${prNumber}...`).start();
201
-
202
- try {
203
- const repoInfo = await getRepoInfo();
204
- if (!repoInfo) {
205
- spinner.fail("Could not determine repository");
206
- process.exit(1);
207
- }
208
-
209
- await postWithAuth(
210
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`,
211
- {
212
- event: "approve",
213
- body: options.body,
214
- }
215
- );
216
-
217
- spinner.succeed(`Approved PR #${prNumber}`);
218
- } catch (error) {
219
- spinner.fail("Failed to approve pull request");
220
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
221
- process.exit(1);
222
- }
223
- });
210
+ const spinner = ora(`Approving PR #${prNumber}...`).start();
211
+
212
+ try {
213
+ const repoInfo = await getRepoInfo();
214
+ if (!repoInfo) {
215
+ spinner.fail("Could not determine repository");
216
+ process.exit(1);
217
+ }
218
+
219
+ await postWithAuth(
220
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`,
221
+ {
222
+ event: "approve",
223
+ body: options.body,
224
+ },
225
+ );
226
+
227
+ spinner.succeed(`Approved PR #${prNumber}`);
228
+ } catch (error) {
229
+ spinner.fail("Failed to approve pull request");
230
+ console.error(
231
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
232
+ );
233
+ process.exit(1);
234
+ }
235
+ });
224
236
225
237
// Review Request Changes
226
238
reviewCommands
227
- .command("request-changes <pr-number>")
228
- .description("Request changes on a pull request")
229
- .option("-b, --body <body>", "Review comment")
230
- .action(async (prNumber: string, options) => {
231
- const config = getConfig();
232
- if (!config.token) {
233
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
234
- process.exit(1);
235
- }
239
+ .command("request-changes <pr-number>")
240
+ .description("Request changes on a pull request")
241
+ .option("-b, --body <body>", "Review comment")
242
+ .action(async (prNumber: string, options) => {
243
+ const config = getConfig();
244
+ if (!config.token) {
245
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
246
+ process.exit(1);
247
+ }
236
248
237
- let body = options.body;
238
-
239
- if (!body) {
240
- const answers = await inquirer.prompt([
241
- {
242
- type: "editor",
243
- name: "body",
244
- message: "Review comment (explain what changes are needed):",
245
- },
246
- ]);
247
- body = answers.body;
248
- }
249
+ let body = options.body;
250
+
251
+ if (!body) {
252
+ const answers = await inquirer.prompt([
253
+ {
254
+ type: "editor",
255
+ name: "body",
256
+ message: "Review comment (explain what changes are needed):",
257
+ },
258
+ ]);
259
+ body = answers.body;
260
+ }
249
261
250
- if (!body || body.trim() === "") {
251
- console.error(chalk.red("A comment is required when requesting changes"));
252
- process.exit(1);
253
- }
262
+ if (!body || body.trim() === "") {
263
+ console.error(chalk.red("A comment is required when requesting changes"));
264
+ process.exit(1);
265
+ }
254
266
255
- const spinner = ora(`Requesting changes on PR #${prNumber}...`).start();
256
-
257
- try {
258
- const repoInfo = await getRepoInfo();
259
- if (!repoInfo) {
260
- spinner.fail("Could not determine repository");
261
- process.exit(1);
262
- }
263
-
264
- await postWithAuth(
265
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`,
266
- {
267
- event: "request_changes",
268
- body,
269
- }
270
- );
271
-
272
- spinner.succeed(`Requested changes on PR #${prNumber}`);
273
- } catch (error) {
274
- spinner.fail("Failed to request changes");
275
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
276
- process.exit(1);
277
- }
278
- });
267
+ const spinner = ora(`Requesting changes on PR #${prNumber}...`).start();
268
+
269
+ try {
270
+ const repoInfo = await getRepoInfo();
271
+ if (!repoInfo) {
272
+ spinner.fail("Could not determine repository");
273
+ process.exit(1);
274
+ }
275
+
276
+ await postWithAuth(
277
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`,
278
+ {
279
+ event: "request_changes",
280
+ body,
281
+ },
282
+ );
283
+
284
+ spinner.succeed(`Requested changes on PR #${prNumber}`);
285
+ } catch (error) {
286
+ spinner.fail("Failed to request changes");
287
+ console.error(
288
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
289
+ );
290
+ process.exit(1);
291
+ }
292
+ });
279
293
280
294
// Review Comment
281
295
reviewCommands
282
- .command("comment <pr-number>")
283
- .description("Add a review comment to a pull request")
284
- .option("-b, --body <body>", "Comment body")
285
- .action(async (prNumber: string, options) => {
286
- const config = getConfig();
287
- if (!config.token) {
288
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
289
- process.exit(1);
290
- }
296
+ .command("comment <pr-number>")
297
+ .description("Add a review comment to a pull request")
298
+ .option("-b, --body <body>", "Comment body")
299
+ .action(async (prNumber: string, options) => {
300
+ const config = getConfig();
301
+ if (!config.token) {
302
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
303
+ process.exit(1);
304
+ }
291
305
292
- let body = options.body;
293
-
294
- if (!body) {
295
- const answers = await inquirer.prompt([
296
- {
297
- type: "editor",
298
- name: "body",
299
- message: "Review comment:",
300
- },
301
- ]);
302
- body = answers.body;
303
- }
306
+ let body = options.body;
307
+
308
+ if (!body) {
309
+ const answers = await inquirer.prompt([
310
+ {
311
+ type: "editor",
312
+ name: "body",
313
+ message: "Review comment:",
314
+ },
315
+ ]);
316
+ body = answers.body;
317
+ }
304
318
305
- if (!body || body.trim() === "") {
306
- console.error(chalk.red("Comment cannot be empty"));
307
- process.exit(1);
308
- }
319
+ if (!body || body.trim() === "") {
320
+ console.error(chalk.red("Comment cannot be empty"));
321
+ process.exit(1);
322
+ }
309
323
310
- const spinner = ora(`Adding comment to PR #${prNumber}...`).start();
311
-
312
- try {
313
- const repoInfo = await getRepoInfo();
314
- if (!repoInfo) {
315
- spinner.fail("Could not determine repository");
316
- process.exit(1);
317
- }
318
-
319
- await postWithAuth(
320
- `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`,
321
- {
322
- event: "comment",
323
- body,
324
- }
325
- );
326
-
327
- spinner.succeed(`Added comment to PR #${prNumber}`);
328
- } catch (error) {
329
- spinner.fail("Failed to add comment");
330
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
331
- process.exit(1);
332
- }
333
- });
324
+ const spinner = ora(`Adding comment to PR #${prNumber}...`).start();
325
+
326
+ try {
327
+ const repoInfo = await getRepoInfo();
328
+ if (!repoInfo) {
329
+ spinner.fail("Could not determine repository");
330
+ process.exit(1);
331
+ }
332
+
333
+ await postWithAuth(
334
+ `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`,
335
+ {
336
+ event: "comment",
337
+ body,
338
+ },
339
+ );
340
+
341
+ spinner.succeed(`Added comment to PR #${prNumber}`);
342
+ } catch (error) {
343
+ spinner.fail("Failed to add comment");
344
+ console.error(
345
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
346
+ );
347
+ process.exit(1);
348
+ }
349
+ });
334
350
335
351
export default reviewCommands;
336
352
▼cli/src/commands/secret/index.ts
+192-191
3
3
* Manage repository and organization secrets from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
8
-import ora from "ora";
7
+import { Command } from "commander";
9
8
import inquirer from "inquirer";
9
+import ora from "ora";
10
10
import { simpleGit } from "simple-git";
11
+import { deleteWithAuth, getWithAuth, postWithAuth } from "../../lib/api.js";
11
12
import { getConfig } from "../../lib/config.js";
12
-import { getWithAuth, postWithAuth, deleteWithAuth } from "../../lib/api.js";
13
+import { getRepoInfoFromGit } from "../../lib/git.js";
13
14
14
15
const git = simpleGit();
15
16
16
17
interface Secret {
17
- name: string;
18
- createdAt: string;
19
- updatedAt: string;
18
+ name: string;
19
+ createdAt: string;
20
+ updatedAt: string;
20
21
}
21
22
22
-export const secretCommands = new Command("secret")
23
- .description("Manage secrets");
23
+export const secretCommands = new Command("secret").description(
24
+ "Manage secrets",
25
+);
24
26
25
27
// Get repo info from git remote
26
-async function getRepoInfo(): Promise<{ owner: string; repo: string } | null> {
27
- try {
28
- const remotes = await git.getRemotes(true);
29
- const origin = remotes.find(r => r.name === "origin");
30
- if (!origin?.refs?.push) return null;
31
-
32
- const url = origin.refs.push;
33
- const match = url.match(/[:/]([^/]+)\/([^/.]+)/);
34
- if (match) {
35
- return { owner: match[1], repo: match[2] };
36
- }
37
- return null;
38
- } catch {
39
- return null;
40
- }
28
+async function getRepoInfo() {
29
+ return getRepoInfoFromGit(git);
41
30
}
42
31
43
32
// Secret Set
44
33
secretCommands
45
- .command("set <name>")
46
- .description("Set a secret")
47
- .option("-b, --body <value>", "Secret value (not recommended, use stdin)")
48
- .option("-o, --org <org>", "Set organization secret instead of repo secret")
49
- .option("-e, --env <env>", "Set environment-specific secret")
50
- .action(async (name: string, options) => {
51
- const config = getConfig();
52
- if (!config.token) {
53
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
54
- process.exit(1);
34
+ .command("set <name>")
35
+ .description("Set a secret")
36
+ .option("-b, --body <value>", "Secret value (not recommended, use stdin)")
37
+ .option("-o, --org <org>", "Set organization secret instead of repo secret")
38
+ .option("-e, --env <env>", "Set environment-specific secret")
39
+ .action(async (name: string, options) => {
40
+ const config = getConfig();
41
+ if (!config.token) {
42
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
43
+ process.exit(1);
44
+ }
45
+
46
+ try {
47
+ let value = options.body;
48
+
49
+ if (!value) {
50
+ // Check if stdin has data (piped input)
51
+ if (!process.stdin.isTTY) {
52
+ const chunks: Buffer[] = [];
53
+ for await (const chunk of process.stdin) {
54
+ chunks.push(chunk);
55
+ }
56
+ value = Buffer.concat(chunks).toString().trim();
57
+ } else {
58
+ // Interactive prompt
59
+ const answers = await inquirer.prompt([
60
+ {
61
+ type: "password",
62
+ name: "value",
63
+ message: `Enter value for ${name}:`,
64
+ mask: "*",
65
+ },
66
+ ]);
67
+ value = answers.value;
55
68
}
69
+ }
70
+
71
+ if (!value) {
72
+ console.error(chalk.red("Secret value cannot be empty"));
73
+ process.exit(1);
74
+ }
75
+
76
+ const spinner = ora(`Setting secret ${name}...`).start();
77
+
78
+ let endpoint: string;
56
79
57
- try {
58
- let value = options.body;
59
-
60
- if (!value) {
61
- // Check if stdin has data (piped input)
62
- if (!process.stdin.isTTY) {
63
- const chunks: Buffer[] = [];
64
- for await (const chunk of process.stdin) {
65
- chunks.push(chunk);
66
- }
67
- value = Buffer.concat(chunks).toString().trim();
68
- } else {
69
- // Interactive prompt
70
- const answers = await inquirer.prompt([
71
- {
72
- type: "password",
73
- name: "value",
74
- message: `Enter value for ${name}:`,
75
- mask: "*",
76
- },
77
- ]);
78
- value = answers.value;
79
- }
80
- }
81
-
82
- if (!value) {
83
- console.error(chalk.red("Secret value cannot be empty"));
84
- process.exit(1);
85
- }
86
-
87
- const spinner = ora(`Setting secret ${name}...`).start();
88
-
89
- let endpoint: string;
90
-
91
- if (options.org) {
92
- endpoint = `/api/orgs/${options.org}/actions/secrets/${name}`;
93
- } else {
94
- const repoInfo = await getRepoInfo();
95
- if (!repoInfo) {
96
- spinner.fail("Could not determine repository");
97
- process.exit(1);
98
- }
99
- const envPart = options.env ? `/environments/${options.env}` : "";
100
- endpoint = `/api/repos/${repoInfo.owner}/${repoInfo.repo}${envPart}/actions/secrets/${name}`;
101
- }
102
-
103
- await postWithAuth(endpoint, { value });
104
-
105
- spinner.succeed(`Set secret ${chalk.cyan(name)}`);
106
- } catch (error) {
107
- console.error(chalk.red("Failed to set secret"));
108
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
109
- process.exit(1);
80
+ if (options.org) {
81
+ endpoint = `/api/orgs/${options.org}/actions/secrets/${name}`;
82
+ } else {
83
+ const repoInfo = await getRepoInfo();
84
+ if (!repoInfo) {
85
+ spinner.fail("Could not determine repository");
86
+ process.exit(1);
110
87
}
111
- });
88
+ const envPart = options.env ? `/environments/${options.env}` : "";
89
+ endpoint = `/api/repos/${repoInfo.owner}/${repoInfo.repo}${envPart}/actions/secrets/${name}`;
90
+ }
91
+
92
+ await postWithAuth(endpoint, { value });
93
+
94
+ spinner.succeed(`Set secret ${chalk.cyan(name)}`);
95
+ } catch (error) {
96
+ console.error(chalk.red("Failed to set secret"));
97
+ console.error(
98
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
99
+ );
100
+ process.exit(1);
101
+ }
102
+ });
112
103
113
104
// Secret List
114
105
secretCommands
115
- .command("list")
116
- .alias("ls")
117
- .description("List secrets")
118
- .option("-o, --org <org>", "List organization secrets")
119
- .option("-e, --env <env>", "List environment secrets")
120
- .action(async (options) => {
121
- const config = getConfig();
122
- if (!config.token) {
123
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
124
- process.exit(1);
125
- }
106
+ .command("list")
107
+ .alias("ls")
108
+ .description("List secrets")
109
+ .option("-o, --org <org>", "List organization secrets")
110
+ .option("-e, --env <env>", "List environment secrets")
111
+ .action(async (options) => {
112
+ const config = getConfig();
113
+ if (!config.token) {
114
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
115
+ process.exit(1);
116
+ }
126
117
127
- const spinner = ora("Fetching secrets...").start();
128
-
129
- try {
130
- let endpoint: string;
131
- let context: string;
132
-
133
- if (options.org) {
134
- endpoint = `/api/orgs/${options.org}/actions/secrets`;
135
- context = `organization ${options.org}`;
136
- } else {
137
- const repoInfo = await getRepoInfo();
138
- if (!repoInfo) {
139
- spinner.fail("Could not determine repository");
140
- process.exit(1);
141
- }
142
- const envPart = options.env ? `/environments/${options.env}` : "";
143
- endpoint = `/api/repos/${repoInfo.owner}/${repoInfo.repo}${envPart}/actions/secrets`;
144
- context = options.env ? `environment ${options.env}` : `repository ${repoInfo.owner}/${repoInfo.repo}`;
145
- }
146
-
147
- const result = await getWithAuth<{ data: Secret[] }>(endpoint);
148
-
149
- spinner.stop();
150
-
151
- if (result.data.length === 0) {
152
- console.log(chalk.dim(`No secrets found for ${context}`));
153
- return;
154
- }
155
-
156
- console.log(chalk.bold(`\n🔐 Secrets for ${context}\n`));
157
-
158
- for (const secret of result.data) {
159
- console.log(` ${chalk.cyan(secret.name)}`);
160
- console.log(chalk.dim(` Updated ${new Date(secret.updatedAt).toLocaleDateString()}`));
161
- }
162
-
163
- console.log("");
164
- } catch (error) {
165
- spinner.fail("Failed to list secrets");
166
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
167
- process.exit(1);
118
+ const spinner = ora("Fetching secrets...").start();
119
+
120
+ try {
121
+ let endpoint: string;
122
+ let context: string;
123
+
124
+ if (options.org) {
125
+ endpoint = `/api/orgs/${options.org}/actions/secrets`;
126
+ context = `organization ${options.org}`;
127
+ } else {
128
+ const repoInfo = await getRepoInfo();
129
+ if (!repoInfo) {
130
+ spinner.fail("Could not determine repository");
131
+ process.exit(1);
168
132
}
169
- });
133
+ const envPart = options.env ? `/environments/${options.env}` : "";
134
+ endpoint = `/api/repos/${repoInfo.owner}/${repoInfo.repo}${envPart}/actions/secrets`;
135
+ context = options.env
136
+ ? `environment ${options.env}`
137
+ : `repository ${repoInfo.owner}/${repoInfo.repo}`;
138
+ }
139
+
140
+ const result = await getWithAuth<{ data: Secret[] }>(endpoint);
141
+
142
+ spinner.stop();
143
+
144
+ if (result.data.length === 0) {
145
+ console.log(chalk.dim(`No secrets found for ${context}`));
146
+ return;
147
+ }
148
+
149
+ console.log(chalk.bold(`\n🔐 Secrets for ${context}\n`));
150
+
151
+ for (const secret of result.data) {
152
+ console.log(` ${chalk.cyan(secret.name)}`);
153
+ console.log(
154
+ chalk.dim(
155
+ ` Updated ${new Date(secret.updatedAt).toLocaleDateString()}`,
156
+ ),
157
+ );
158
+ }
159
+
160
+ console.log("");
161
+ } catch (error) {
162
+ spinner.fail("Failed to list secrets");
163
+ console.error(
164
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
165
+ );
166
+ process.exit(1);
167
+ }
168
+ });
170
169
171
170
// Secret Delete
172
171
secretCommands
173
- .command("delete <name>")
174
- .alias("rm")
175
- .description("Delete a secret")
176
- .option("-o, --org <org>", "Delete organization secret")
177
- .option("-e, --env <env>", "Delete environment secret")
178
- .option("-y, --yes", "Skip confirmation")
179
- .action(async (name: string, options) => {
180
- const config = getConfig();
181
- if (!config.token) {
182
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
183
- process.exit(1);
172
+ .command("delete <name>")
173
+ .alias("rm")
174
+ .description("Delete a secret")
175
+ .option("-o, --org <org>", "Delete organization secret")
176
+ .option("-e, --env <env>", "Delete environment secret")
177
+ .option("-y, --yes", "Skip confirmation")
178
+ .action(async (name: string, options) => {
179
+ const config = getConfig();
180
+ if (!config.token) {
181
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
182
+ process.exit(1);
183
+ }
184
+
185
+ try {
186
+ if (!options.yes) {
187
+ const { confirm } = await inquirer.prompt([
188
+ {
189
+ type: "confirm",
190
+ name: "confirm",
191
+ message: `Delete secret ${name}?`,
192
+ default: false,
193
+ },
194
+ ]);
195
+ if (!confirm) {
196
+ console.log(chalk.dim("Cancelled."));
197
+ return;
184
198
}
199
+ }
185
200
186
- try {
187
- if (!options.yes) {
188
- const { confirm } = await inquirer.prompt([
189
- {
190
- type: "confirm",
191
- name: "confirm",
192
- message: `Delete secret ${name}?`,
193
- default: false,
194
- },
195
- ]);
196
- if (!confirm) {
197
- console.log(chalk.dim("Cancelled."));
198
- return;
199
- }
200
- }
201
-
202
- const spinner = ora(`Deleting secret ${name}...`).start();
203
-
204
- let endpoint: string;
205
-
206
- if (options.org) {
207
- endpoint = `/api/orgs/${options.org}/actions/secrets/${name}`;
208
- } else {
209
- const repoInfo = await getRepoInfo();
210
- if (!repoInfo) {
211
- spinner.fail("Could not determine repository");
212
- process.exit(1);
213
- }
214
- const envPart = options.env ? `/environments/${options.env}` : "";
215
- endpoint = `/api/repos/${repoInfo.owner}/${repoInfo.repo}${envPart}/actions/secrets/${name}`;
216
- }
217
-
218
- await deleteWithAuth(endpoint);
219
-
220
- spinner.succeed(`Deleted secret ${chalk.cyan(name)}`);
221
- } catch (error) {
222
- console.error(chalk.red("Failed to delete secret"));
223
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
224
- process.exit(1);
201
+ const spinner = ora(`Deleting secret ${name}...`).start();
202
+
203
+ let endpoint: string;
204
+
205
+ if (options.org) {
206
+ endpoint = `/api/orgs/${options.org}/actions/secrets/${name}`;
207
+ } else {
208
+ const repoInfo = await getRepoInfo();
209
+ if (!repoInfo) {
210
+ spinner.fail("Could not determine repository");
211
+ process.exit(1);
225
212
}
226
- });
213
+ const envPart = options.env ? `/environments/${options.env}` : "";
214
+ endpoint = `/api/repos/${repoInfo.owner}/${repoInfo.repo}${envPart}/actions/secrets/${name}`;
215
+ }
216
+
217
+ await deleteWithAuth(endpoint);
218
+
219
+ spinner.succeed(`Deleted secret ${chalk.cyan(name)}`);
220
+ } catch (error) {
221
+ console.error(chalk.red("Failed to delete secret"));
222
+ console.error(
223
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
224
+ );
225
+ process.exit(1);
226
+ }
227
+ });
227
228
228
229
export default secretCommands;
229
230
▼cli/src/commands/stack/index.ts
+425-352
3
3
* Manage stacked PRs from the CLI
4
4
*/
5
5
6
-import { Command } from "commander";
7
6
import chalk from "chalk";
7
+import { spawnSync } from "child_process";
8
+import { Command } from "commander";
9
+import fs from "fs";
8
10
import ora from "ora";
11
+import os from "os";
12
+import path from "path";
9
13
import { simpleGit } from "simple-git";
10
-import { getConfig, saveConfig } from "../../lib/config.js";
11
-import { apiCall } from "../../lib/api.js";
14
+import { applyTlsConfig } from "../../lib/api.js";
15
+import { getConfig } from "../../lib/config.js";
16
+import { getRepoInfoFromGit } from "../../lib/git.js";
12
17
13
18
const git = simpleGit();
14
19
15
-export const stackCommands = new Command("stack")
16
- .description("Manage stacked PRs");
17
-
18
-// Create a new branch in the stack
19
-stackCommands
20
- .command("create <name>")
21
- .description("Create a new branch in the current stack")
22
- .option("-m, --message <message>", "Commit message for work in progress")
23
- .action(async (name: string, options) => {
24
- const spinner = ora("Creating stacked branch...").start();
20
+export const stackCommands = new Command("stack").description(
21
+ "Manage stacked PRs",
22
+);
25
23
26
- try {
27
- // Get current branch
28
- const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
24
+function createAskPassScript(): string {
25
+ const scriptPath = path.join(
26
+ os.tmpdir(),
27
+ `och-askpass-${process.pid}-${Date.now()}.sh`,
28
+ );
29
29
30
- // Create new branch
31
- const newBranch = `stack/${name}`;
32
- await git.checkoutLocalBranch(newBranch);
30
+ const script = `#!/bin/sh
31
+case "$1" in
32
+*Username*) echo "$GIT_ASKPASS_USERNAME" ;;
33
+*Password*) echo "$GIT_ASKPASS_PASSWORD" ;;
34
+*) echo "$GIT_ASKPASS_PASSWORD" ;;
35
+esac
36
+`;
33
37
34
- spinner.succeed(`Created branch ${chalk.green(newBranch)} (stacked on ${chalk.cyan(currentBranch.trim())})`);
38
+ fs.writeFileSync(scriptPath, script, { mode: 0o700 });
39
+ return scriptPath;
40
+}
35
41
36
- console.log("\n" + chalk.dim("Next steps:"));
37
- console.log(chalk.dim(" • Make your changes"));
38
- console.log(chalk.dim(" • Run ") + chalk.cyan("och stack submit") + chalk.dim(" to push and create PRs"));
39
- } catch (error) {
40
- spinner.fail("Failed to create branch");
41
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
42
- process.exit(1);
43
- }
44
- });
42
+// Create a new branch in the stack
43
+stackCommands
44
+ .command("create <name>")
45
+ .description("Create a new branch in the current stack")
46
+ .option("-m, --message <message>", "Commit message for work in progress")
47
+ .action(async (name: string, options) => {
48
+ const spinner = ora("Creating stacked branch...").start();
49
+
50
+ try {
51
+ // Get current branch
52
+ const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
53
+
54
+ // Create new branch
55
+ const newBranch = `stack/${name}`;
56
+ await git.checkoutLocalBranch(newBranch);
57
+
58
+ spinner.succeed(
59
+ `Created branch ${chalk.green(newBranch)} (stacked on ${chalk.cyan(currentBranch.trim())})`,
60
+ );
61
+
62
+ console.log("\n" + chalk.dim("Next steps:"));
63
+ console.log(chalk.dim(" • Make your changes"));
64
+ console.log(
65
+ chalk.dim(" • Run ") +
66
+ chalk.cyan("och stack submit") +
67
+ chalk.dim(" to push and create PRs"),
68
+ );
69
+ } catch (error) {
70
+ spinner.fail("Failed to create branch");
71
+ console.error(
72
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
73
+ );
74
+ process.exit(1);
75
+ }
76
+ });
45
77
46
78
// Submit stack to remote and create PRs
47
79
stackCommands
48
- .command("submit")
49
- .description("Push stack and create/update PRs for all branches")
50
- .option("-d, --draft", "Create PRs as drafts")
51
- .option("-m, --message <message>", "PR title prefix")
52
- .action(async (options) => {
53
- const config = getConfig();
54
-
55
- if (!config.token) {
56
- console.error(chalk.red("Not logged in. Run 'och auth login' first."));
57
- process.exit(1);
58
- }
59
-
60
- const spinner = ora("Analyzing stack...").start();
61
-
62
- try {
63
- // Get all stack branches
64
- const branches = await git.branchLocal();
65
- const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
66
- const stackBranches = branches.all.filter(b => b.startsWith("stack/"));
67
-
68
- if (stackBranches.length === 0) {
69
- spinner.fail("No stack branches found");
70
- console.log(chalk.dim("Run ") + chalk.cyan("och stack create <name>") + chalk.dim(" to start a stack."));
71
- process.exit(1);
72
- }
73
-
74
- // Get repository info from git remote
75
- const remotes = await git.getRemotes(true);
76
- const origin = remotes.find(r => r.name === "origin");
77
-
78
- if (!origin?.refs?.push) {
79
- spinner.fail("No origin remote found");
80
- process.exit(1);
81
- }
82
-
83
- // Parse owner/repo from remote URL
84
- // Handles both SSH (git@host:owner/repo) and HTTP (http://host/owner/repo.git) formats
85
- const remoteUrl = origin.refs.push;
86
- // Match the last two path segments before .git (owner/repo)
87
- const match = remoteUrl.match(/\/([^/]+)\/([^/]+?)(?:\.git)?$/);
88
-
89
- if (!match) {
90
- spinner.fail("Could not parse repository from remote URL");
91
- process.exit(1);
92
- }
93
-
94
- const [, owner, repoName] = match;
95
-
96
- // Push all stack branches with authentication
97
- spinner.text = "Pushing branches...";
98
- const pushedBranches: string[] = [];
99
-
100
- // Build authenticated URL for push (format: http://user:token@host/owner/repo.git)
101
- const urlParts = new URL(config.serverUrl);
102
- const pushUrl = `${urlParts.protocol}//${config.username || owner}:${config.token}@${urlParts.host}/${owner}/${repoName}.git`;
103
-
104
- console.log(chalk.dim(`\n Remote URL: ${origin.refs.push}`));
105
- console.log(chalk.dim(` Parsed: owner=${owner}, repo=${repoName}`));
106
- console.log(chalk.dim(` Push URL: ${pushUrl.replace(config.token, '[TOKEN]')}`));
107
-
108
- for (const branch of stackBranches) {
109
- try {
110
- // Use the authenticated URL for pushing
111
- console.log(chalk.dim(` Pushing ${branch} to authenticated URL...`));
112
- await git.push([pushUrl, branch, "--force-with-lease"]);
113
- pushedBranches.push(branch);
114
- console.log(chalk.green(` ✓ Pushed ${branch}`));
115
- } catch (e) {
116
- console.log(chalk.yellow(`\n First push attempt failed: ${e instanceof Error ? e.message : 'Unknown error'}`));
117
- // Try regular push if authenticated push fails
118
- try {
119
- console.log(chalk.dim(` Trying fallback push to origin...`));
120
- await git.push(["-u", "origin", branch, "--force-with-lease"]);
121
- pushedBranches.push(branch);
122
- console.log(chalk.green(` ✓ Pushed ${branch} via origin`));
123
- } catch (e2) {
124
- console.log(chalk.red(`\n ✗ Could not push ${branch}: ${e2 instanceof Error ? e2.message : 'Unknown error'}`));
125
- }
126
- }
80
+ .command("submit")
81
+ .description("Push stack and create/update PRs for all branches")
82
+ .option("-d, --draft", "Create PRs as drafts")
83
+ .option("-m, --message <message>", "PR title prefix")
84
+ .action(async (options) => {
85
+ const config = getConfig();
86
+
87
+ if (!config.token) {
88
+ console.error(chalk.red("Not logged in. Run 'och auth login' first."));
89
+ process.exit(1);
90
+ }
91
+
92
+ const spinner = ora("Analyzing stack...").start();
93
+
94
+ try {
95
+ // Get all stack branches
96
+ const branches = await git.branchLocal();
97
+ const currentBranch = (
98
+ await git.revparse(["--abbrev-ref", "HEAD"])
99
+ ).trim();
100
+ const stackBranches = branches.all.filter((b) => b.startsWith("stack/"));
101
+
102
+ if (stackBranches.length === 0) {
103
+ spinner.fail("No stack branches found");
104
+ console.log(
105
+ chalk.dim("Run ") +
106
+ chalk.cyan("och stack create <name>") +
107
+ chalk.dim(" to start a stack."),
108
+ );
109
+ process.exit(1);
110
+ }
111
+
112
+ const repoInfo = await getRepoInfoFromGit(git);
113
+ if (!repoInfo) {
114
+ spinner.fail("Could not determine repository from origin");
115
+ process.exit(1);
116
+ }
117
+
118
+ const { owner, repo: repoName } = repoInfo;
119
+
120
+ // Push all stack branches with authentication (no token in URL)
121
+ spinner.text = "Pushing branches...";
122
+ const pushedBranches: string[] = [];
123
+ const repoRoot = (await git.revparse(["--show-toplevel"])).trim();
124
+ const askPass = createAskPassScript();
125
+
126
+ const pushEnv = {
127
+ ...process.env,
128
+ GIT_ASKPASS: askPass,
129
+ GIT_ASKPASS_USERNAME: config.username || owner,
130
+ GIT_ASKPASS_PASSWORD: config.token,
131
+ GIT_TERMINAL_PROMPT: "0",
132
+ };
133
+
134
+ try {
135
+ for (const branch of stackBranches) {
136
+ try {
137
+ console.log(chalk.dim(` Pushing ${branch} to origin...`));
138
+ const result = spawnSync(
139
+ "git",
140
+ ["push", "origin", branch, "--force-with-lease"],
141
+ {
142
+ cwd: repoRoot,
143
+ env: pushEnv,
144
+ stdio: "pipe",
145
+ encoding: "utf-8",
146
+ },
147
+ );
148
+
149
+ if (result.status !== 0) {
150
+ throw new Error(result.stderr || "Push failed");
127
151
}
128
152
129
- spinner.text = "Creating PRs...";
130
-
131
- // Build branch metadata for PR creation
132
- const branchData = stackBranches.map((branch, index) => {
133
- const name = branch.replace("stack/", "");
134
- const parentBranch = index === 0 ? "main" : stackBranches[index - 1];
153
+ pushedBranches.push(branch);
154
+ console.log(chalk.green(` ✓ Pushed ${branch}`));
155
+ } catch (e) {
156
+ console.log(
157
+ chalk.red(
158
+ `\n ✗ Could not push ${branch}: ${e instanceof Error ? e.message : "Unknown error"}`,
159
+ ),
160
+ );
161
+ }
162
+ }
163
+ } finally {
164
+ fs.rmSync(askPass, { force: true });
165
+ }
166
+
167
+ spinner.text = "Creating PRs...";
168
+
169
+ // Build branch metadata for PR creation
170
+ const branchData = stackBranches.map((branch, index) => {
171
+ const name = branch.replace("stack/", "");
172
+ const parentBranch = index === 0 ? "main" : stackBranches[index - 1];
173
+
174
+ return {
175
+ name: branch,
176
+ title: options.message
177
+ ? `${options.message}: ${name}`
178
+ : `[Stack] ${name}`,
179
+ description: `Part of stacked PR workflow.\n\nBranch: \`${branch}\`\nBased on: \`${parentBranch}\``,
180
+ parentBranch,
181
+ };
182
+ });
183
+
184
+ // Create PRs via API
185
+ try {
186
+ applyTlsConfig();
187
+ const response = await fetch(`${config.serverUrl}/api/stacks`, {
188
+ method: "POST",
189
+ headers: {
190
+ Authorization: `Bearer ${config.token}`,
191
+ "Content-Type": "application/json",
192
+ },
193
+ body: JSON.stringify({
194
+ owner,
195
+ repo: repoName,
196
+ baseBranch: "main",
197
+ branches: branchData,
198
+ }),
199
+ });
200
+
201
+ if (!response.ok) {
202
+ const error = await response.json();
203
+ throw new Error(error.error || "Failed to create PRs");
204
+ }
135
205
136
- return {
137
- name: branch,
138
- title: options.message ? `${options.message}: ${name}` : `[Stack] ${name}`,
139
- description: `Part of stacked PR workflow.\n\nBranch: \`${branch}\`\nBased on: \`${parentBranch}\``,
140
- parentBranch,
141
- };
142
- });
206
+ const result = await response.json();
143
207
144
- // Create PRs via API
145
- try {
146
- const response = await fetch(`${config.serverUrl}/api/stacks`, {
147
- method: "POST",
148
- headers: {
149
- "Authorization": `Bearer ${config.token}`,
150
- "Content-Type": "application/json",
151
- },
152
- body: JSON.stringify({
153
- owner,
154
- repo: repoName,
155
- baseBranch: "main",
156
- branches: branchData,
157
- }),
158
- });
159
-
160
- if (!response.ok) {
161
- const error = await response.json();
162
- throw new Error(error.error || "Failed to create PRs");
163
- }
164
-
165
- const result = await response.json();
166
-
167
- spinner.succeed(`Stack submitted with ${pushedBranches.length} branches`);
168
-
169
- if (result.data?.stack?.pullRequests) {
170
- console.log(chalk.bold("\n📋 Pull Requests Created:\n"));
171
- for (const pr of result.data.stack.pullRequests) {
172
- console.log(` ${chalk.green("●")} #${pr.number}: ${pr.title}`);
173
- console.log(chalk.dim(` ${pr.branch} → ${pr.baseBranch}`));
174
- }
175
- }
176
- } catch (apiError) {
177
- // API might not be available, just show success for pushing
178
- spinner.succeed(`Pushed ${pushedBranches.length} branches`);
179
- console.log(chalk.yellow("\n Note: Could not auto-create PRs (API unavailable)"));
180
- console.log(chalk.dim(" Create PRs manually at your repository\n"));
181
- }
208
+ spinner.succeed(
209
+ `Stack submitted with ${pushedBranches.length} branches`,
210
+ );
182
211
183
- console.log(chalk.dim("\nNext steps:"));
184
- console.log(chalk.dim(" • Get reviews on your PRs"));
185
- console.log(chalk.dim(" • Run ") + chalk.cyan("och stack sync") + chalk.dim(" to keep in sync"));
186
- } catch (error) {
187
- spinner.fail("Failed to submit stack");
188
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
189
- process.exit(1);
212
+ if (result.data?.stack?.pullRequests) {
213
+ console.log(chalk.bold("\n📋 Pull Requests Created:\n"));
214
+ for (const pr of result.data.stack.pullRequests) {
215
+ console.log(` ${chalk.green("●")} #${pr.number}: ${pr.title}`);
216
+ console.log(chalk.dim(` ${pr.branch} → ${pr.baseBranch}`));
217
+ }
190
218
}
191
- });
219
+ } catch (apiError) {
220
+ // API might not be available, just show success for pushing
221
+ spinner.succeed(`Pushed ${pushedBranches.length} branches`);
222
+ console.log(
223
+ chalk.yellow("\n Note: Could not auto-create PRs (API unavailable)"),
224
+ );
225
+ console.log(chalk.dim(" Create PRs manually at your repository\n"));
226
+ }
227
+
228
+ console.log(chalk.dim("\nNext steps:"));
229
+ console.log(chalk.dim(" • Get reviews on your PRs"));
230
+ console.log(
231
+ chalk.dim(" • Run ") +
232
+ chalk.cyan("och stack sync") +
233
+ chalk.dim(" to keep in sync"),
234
+ );
235
+ } catch (error) {
236
+ spinner.fail("Failed to submit stack");
237
+ console.error(
238
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
239
+ );
240
+ process.exit(1);
241
+ }
242
+ });
192
243
193
244
// View current stack
194
245
stackCommands
195
- .command("view")
196
- .alias("ls")
197
- .alias("log")
198
- .description("View current stack visualization (like gt log)")
199
- .action(async () => {
200
- try {
201
- const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
202
- const branches = await git.branchLocal();
246
+ .command("view")
247
+ .alias("ls")
248
+ .alias("log")
249
+ .description("View current stack visualization (like gt log)")
250
+ .action(async () => {
251
+ try {
252
+ const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
253
+ const branches = await git.branchLocal();
254
+
255
+ console.log(chalk.bold("\n📚 Current Stack\n"));
256
+
257
+ // Find stack branches
258
+ const stackBranches = branches.all.filter((b) => b.startsWith("stack/"));
259
+
260
+ if (stackBranches.length === 0) {
261
+ console.log(chalk.dim(" No stack branches found."));
262
+ console.log(
263
+ chalk.dim(" Run ") +
264
+ chalk.cyan("och stack create <name>") +
265
+ chalk.dim(" to start a stack."),
266
+ );
267
+ return;
268
+ }
269
+
270
+ // Display stack
271
+ console.log(
272
+ chalk.dim(" ┌─ ") + chalk.green("main") + chalk.dim(" (base)"),
273
+ );
274
+
275
+ for (let i = 0; i < stackBranches.length; i++) {
276
+ const branch = stackBranches[i];
277
+ const isCurrent = currentBranch.trim() === branch;
278
+ const prefix = i === stackBranches.length - 1 ? "└─" : "├─";
279
+
280
+ if (isCurrent) {
281
+ console.log(chalk.dim(" │"));
282
+ console.log(
283
+ chalk.dim(` ${prefix} `) +
284
+ chalk.yellow.bold("● " + branch) +
285
+ chalk.yellow(" (current)"),
286
+ );
287
+ } else {
288
+ console.log(chalk.dim(" │"));
289
+ console.log(chalk.dim(` ${prefix} `) + chalk.cyan("○ " + branch));
290
+ }
291
+ }
203
292
204
- console.log(chalk.bold("\n📚 Current Stack\n"));
293
+ console.log("");
294
+ } catch (error) {
295
+ console.error(chalk.red("Failed to view stack"));
296
+ process.exit(1);
297
+ }
298
+ });
205
299
206
- // Find stack branches
207
- const stackBranches = branches.all.filter(b => b.startsWith("stack/"));
300
+// Sync stack with remote
301
+stackCommands
302
+ .command("sync")
303
+ .description("Bidirectional sync with remote")
304
+ .option("-p, --push", "Push local changes to remote")
305
+ .option("-l, --pull", "Pull remote changes to local")
306
+ .option("-f, --force", "Force push (use with caution)")
307
+ .action(async (options) => {
308
+ const spinner = ora("Syncing stack...").start();
208
309
209
- if (stackBranches.length === 0) {
210
- console.log(chalk.dim(" No stack branches found."));
211
- console.log(chalk.dim(" Run ") + chalk.cyan("och stack create <name>") + chalk.dim(" to start a stack."));
212
- return;
213
- }
310
+ try {
311
+ // Fetch latest
312
+ spinner.text = "Fetching from remote...";
313
+ await git.fetch();
214
314
215
- // Display stack
216
- console.log(chalk.dim(" ┌─ ") + chalk.green("main") + chalk.dim(" (base)"));
217
-
218
- for (let i = 0; i < stackBranches.length; i++) {
219
- const branch = stackBranches[i];
220
- const isCurrent = currentBranch.trim() === branch;
221
- const prefix = i === stackBranches.length - 1 ? "└─" : "├─";
222
-
223
- if (isCurrent) {
224
- console.log(chalk.dim(" │"));
225
- console.log(chalk.dim(` ${prefix} `) + chalk.yellow.bold("● " + branch) + chalk.yellow(" (current)"));
226
- } else {
227
- console.log(chalk.dim(" │"));
228
- console.log(chalk.dim(` ${prefix} `) + chalk.cyan("○ " + branch));
229
- }
230
- }
315
+ const branches = await git.branchLocal();
316
+ const stackBranches = branches.all.filter((b) => b.startsWith("stack/"));
231
317
232
- console.log("");
233
- } catch (error) {
234
- console.error(chalk.red("Failed to view stack"));
235
- process.exit(1);
236
- }
237
- });
318
+ if (stackBranches.length === 0) {
319
+ spinner.info("No stack branches found");
320
+ return;
321
+ }
238
322
239
-// Sync stack with remote
240
-stackCommands
241
- .command("sync")
242
- .description("Bidirectional sync with remote")
243
- .option("-p, --push", "Push local changes to remote")
244
- .option("-l, --pull", "Pull remote changes to local")
245
- .option("-f, --force", "Force push (use with caution)")
246
- .action(async (options) => {
247
- const spinner = ora("Syncing stack...").start();
323
+ const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
248
324
249
- try {
250
- // Fetch latest
251
- spinner.text = "Fetching from remote...";
252
- await git.fetch();
325
+ // Default: do both push and pull if neither specified
326
+ const doPush = options.push || (!options.push && !options.pull);
327
+ const doPull = options.pull || (!options.push && !options.pull);
253
328
254
- const branches = await git.branchLocal();
255
- const stackBranches = branches.all.filter(b => b.startsWith("stack/"));
329
+ let syncedCount = 0;
330
+ const errors: string[] = [];
256
331
257
- if (stackBranches.length === 0) {
258
- spinner.info("No stack branches found");
259
- return;
260
- }
332
+ for (const branch of stackBranches) {
333
+ spinner.text = `Syncing ${branch}...`;
261
334
262
- const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
263
-
264
- // Default: do both push and pull if neither specified
265
- const doPush = options.push || (!options.push && !options.pull);
266
- const doPull = options.pull || (!options.push && !options.pull);
267
-
268
- let syncedCount = 0;
269
- const errors: string[] = [];
270
-
271
- for (const branch of stackBranches) {
272
- spinner.text = `Syncing ${branch}...`;
273
-
274
- try {
275
- await git.checkout(branch);
276
-
277
- if (doPull) {
278
- // Pull with rebase
279
- try {
280
- await git.pull(["--rebase", "origin", branch]);
281
- } catch (e) {
282
- // Branch might not exist on remote yet
283
- }
284
- }
285
-
286
- if (doPush) {
287
- const pushArgs = ["-u", "origin", branch];
288
- if (options.force) {
289
- pushArgs.unshift("--force-with-lease");
290
- }
291
- await git.push(pushArgs);
292
- }
293
-
294
- syncedCount++;
295
- } catch (error) {
296
- errors.push(`${branch}: ${error instanceof Error ? error.message : "Unknown error"}`);
297
- }
298
- }
335
+ try {
336
+ await git.checkout(branch);
299
337
300
- // Return to original branch
301
- await git.checkout(currentBranch.trim());
338
+ if (doPull) {
339
+ // Pull with rebase
340
+ try {
341
+ await git.pull(["--rebase", "origin", branch]);
342
+ } catch (e) {
343
+ // Branch might not exist on remote yet
344
+ }
345
+ }
302
346
303
- if (errors.length > 0) {
304
- spinner.warn(`Synced ${syncedCount}/${stackBranches.length} branches`);
305
- console.log(chalk.yellow("\nIssues:"));
306
- errors.forEach(e => console.log(chalk.dim(` • ${e}`)));
307
- } else {
308
- spinner.succeed(`Synced ${syncedCount} branches successfully`);
347
+ if (doPush) {
348
+ const pushArgs = ["-u", "origin", branch];
349
+ if (options.force) {
350
+ pushArgs.unshift("--force-with-lease");
309
351
}
352
+ await git.push(pushArgs);
353
+ }
354
+
355
+ syncedCount++;
310
356
} catch (error) {
311
- spinner.fail("Failed to sync stack");
312
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
313
- console.log(chalk.dim("\nIf there are conflicts, resolve them and run:"));
314
- console.log(chalk.cyan(" git rebase --continue"));
315
- process.exit(1);
357
+ errors.push(
358
+ `${branch}: ${error instanceof Error ? error.message : "Unknown error"}`,
359
+ );
316
360
}
317
- });
361
+ }
362
+
363
+ // Return to original branch
364
+ await git.checkout(currentBranch.trim());
365
+
366
+ if (errors.length > 0) {
367
+ spinner.warn(`Synced ${syncedCount}/${stackBranches.length} branches`);
368
+ console.log(chalk.yellow("\nIssues:"));
369
+ errors.forEach((e) => console.log(chalk.dim(` • ${e}`)));
370
+ } else {
371
+ spinner.succeed(`Synced ${syncedCount} branches successfully`);
372
+ }
373
+ } catch (error) {
374
+ spinner.fail("Failed to sync stack");
375
+ console.error(
376
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
377
+ );
378
+ console.log(chalk.dim("\nIf there are conflicts, resolve them and run:"));
379
+ console.log(chalk.cyan(" git rebase --continue"));
380
+ process.exit(1);
381
+ }
382
+ });
318
383
319
384
// Stack status
320
385
stackCommands
321
- .command("status")
322
- .alias("st")
323
- .description("Show sync status for all stack branches")
324
- .action(async () => {
325
- const spinner = ora("Checking status...").start();
386
+ .command("status")
387
+ .alias("st")
388
+ .description("Show sync status for all stack branches")
389
+ .action(async () => {
390
+ const spinner = ora("Checking status...").start();
326
391
327
- try {
328
- await git.fetch();
392
+ try {
393
+ await git.fetch();
329
394
330
- const branches = await git.branchLocal();
331
- const stackBranches = branches.all.filter(b => b.startsWith("stack/"));
332
- const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
395
+ const branches = await git.branchLocal();
396
+ const stackBranches = branches.all.filter((b) => b.startsWith("stack/"));
397
+ const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
333
398
334
- spinner.stop();
399
+ spinner.stop();
335
400
336
- if (stackBranches.length === 0) {
337
- console.log(chalk.dim("\nNo stack branches found."));
338
- return;
339
- }
401
+ if (stackBranches.length === 0) {
402
+ console.log(chalk.dim("\nNo stack branches found."));
403
+ return;
404
+ }
340
405
341
- console.log(chalk.bold("\n📊 Stack Status\n"));
342
-
343
- for (const branch of stackBranches) {
344
- const isCurrent = currentBranch.trim() === branch;
345
-
346
- // Get ahead/behind
347
- let ahead = 0;
348
- let behind = 0;
349
- let hasRemote = true;
350
-
351
- try {
352
- const log = await git.raw([
353
- "rev-list", "--left-right", "--count",
354
- `${branch}...origin/${branch}`
355
- ]);
356
- [ahead, behind] = log.trim().split("\t").map(Number);
357
- } catch {
358
- hasRemote = false;
359
- }
360
-
361
- // Build status line
362
- let status = "";
363
- if (!hasRemote) {
364
- status = chalk.yellow("⚠ not pushed");
365
- } else if (ahead > 0 && behind > 0) {
366
- status = chalk.red(`↑${ahead} ↓${behind} diverged`);
367
- } else if (ahead > 0) {
368
- status = chalk.cyan(`↑${ahead} ahead`);
369
- } else if (behind > 0) {
370
- status = chalk.magenta(`↓${behind} behind`);
371
- } else {
372
- status = chalk.green("✓ synced");
373
- }
374
-
375
- const marker = isCurrent ? chalk.yellow("● ") : chalk.dim("○ ");
376
- console.log(` ${marker}${isCurrent ? chalk.bold(branch) : branch} ${status}`);
377
- }
406
+ console.log(chalk.bold("\n📊 Stack Status\n"));
378
407
379
- console.log("");
380
- } catch (error) {
381
- spinner.fail("Failed to get status");
382
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
383
- process.exit(1);
384
- }
385
- });
408
+ for (const branch of stackBranches) {
409
+ const isCurrent = currentBranch.trim() === branch;
386
410
387
-// Rebase entire stack
388
-stackCommands
389
- .command("rebase")
390
- .description("Rebase entire stack on base branch")
391
- .option("-b, --base <branch>", "Base branch to rebase on", "main")
392
- .action(async (options) => {
393
- const spinner = ora(`Rebasing stack on ${options.base}...`).start();
411
+ // Get ahead/behind
412
+ let ahead = 0;
413
+ let behind = 0;
414
+ let hasRemote = true;
394
415
395
416
try {
396
- // Fetch latest
397
- await git.fetch();
398
-
399
- // Rebase
400
- await git.rebase([options.base]);
417
+ const log = await git.raw([
418
+ "rev-list",
419
+ "--left-right",
420
+ "--count",
421
+ `${branch}...origin/${branch}`,
422
+ ]);
423
+ [ahead, behind] = log.trim().split("\t").map(Number);
424
+ } catch {
425
+ hasRemote = false;
426
+ }
401
427
402
- spinner.succeed(`Stack rebased on ${chalk.green(options.base)}`);
403
- } catch (error) {
404
- spinner.fail("Failed to rebase stack");
405
- console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
406
- process.exit(1);
428
+ // Build status line
429
+ let status = "";
430
+ if (!hasRemote) {
431
+ status = chalk.yellow("⚠ not pushed");
432
+ } else if (ahead > 0 && behind > 0) {
433
+ status = chalk.red(`↑${ahead} ↓${behind} diverged`);
434
+ } else if (ahead > 0) {
435
+ status = chalk.cyan(`↑${ahead} ahead`);
436
+ } else if (behind > 0) {
437
+ status = chalk.magenta(`↓${behind} behind`);
438
+ } else {
439
+ status = chalk.green("✓ synced");
407
440
}
408
- });
441
+
442
+ const marker = isCurrent ? chalk.yellow("● ") : chalk.dim("○ ");
443
+ console.log(
444
+ ` ${marker}${isCurrent ? chalk.bold(branch) : branch} ${status}`,
445
+ );
446
+ }
447
+
448
+ console.log("");
449
+ } catch (error) {
450
+ spinner.fail("Failed to get status");
451
+ console.error(
452
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
453
+ );
454
+ process.exit(1);
455
+ }
456
+ });
457
+
458
+// Rebase entire stack
459
+stackCommands
460
+ .command("rebase")
461
+ .description("Rebase entire stack on base branch")
462
+ .option("-b, --base <branch>", "Base branch to rebase on", "main")
463
+ .action(async (options) => {
464
+ const spinner = ora(`Rebasing stack on ${options.base}...`).start();
465
+
466
+ try {
467
+ // Fetch latest
468
+ await git.fetch();
469
+
470
+ // Rebase
471
+ await git.rebase([options.base]);
472
+
473
+ spinner.succeed(`Stack rebased on ${chalk.green(options.base)}`);
474
+ } catch (error) {
475
+ spinner.fail("Failed to rebase stack");
476
+ console.error(
477
+ chalk.red(error instanceof Error ? error.message : "Unknown error"),
478
+ );
479
+ process.exit(1);
480
+ }
481
+ });
409
482
410
483
export default stackCommands;
411
484
▼cli/src/lib/api.ts
+107-30
3
3
* Make authenticated requests to OpenCodeHub
4
4
*/
5
5
6
+import fs from "fs";
6
7
import { getConfig } from "./config.js";
7
8
9
+let tlsConfigured = false;
10
+
11
+export function applyTlsConfig() {
12
+ if (tlsConfigured) return;
13
+
14
+ const config = getConfig();
15
+
16
+ if (config.caFile) {
17
+ if (!fs.existsSync(config.caFile)) {
18
+ throw new Error(`CA file not found: ${config.caFile}`);
19
+ }
20
+ process.env.NODE_EXTRA_CA_CERTS = config.caFile;
21
+ }
22
+
23
+ if (config.insecure) {
24
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
25
+ }
26
+
27
+ tlsConfigured = true;
28
+}
29
+
30
+function sleep(ms: number) {
31
+ return new Promise((resolve) => setTimeout(resolve, ms));
32
+}
33
+
34
+function shouldRetry(status: number) {
35
+ return status === 429 || status === 502 || status === 503 || status === 504;
36
+}
37
+
8
38
export async function apiCall<T = any>(
9
- serverUrl: string,
10
- endpoint: string,
11
- method: "GET" | "POST" | "PATCH" | "DELETE" = "GET",
12
- body?: any
39
+ serverUrl: string,
40
+ endpoint: string,
41
+ method: "GET" | "POST" | "PATCH" | "DELETE" = "GET",
42
+ body?: any,
13
43
): Promise<T> {
14
- const config = getConfig();
15
- const url = serverUrl || config.serverUrl;
44
+ applyTlsConfig();
16
45
17
- const headers: Record<string, string> = {
18
- "Content-Type": "application/json",
19
- };
46
+ const config = getConfig();
47
+ const url = serverUrl || config.serverUrl;
20
48
21
- if (config.token) {
22
- headers["Authorization"] = `Bearer ${config.token}`;
23
- }
49
+ if (!url) {
50
+ throw new Error(
51
+ "Server URL not configured. Run 'och config set serverUrl <url>' or 'och auth login --url <url>'.",
52
+ );
53
+ }
54
+
55
+ const headers: Record<string, string> = {
56
+ "Content-Type": "application/json",
57
+ };
58
+
59
+ if (config.token) {
60
+ headers["Authorization"] = `Bearer ${config.token}`;
61
+ }
24
62
25
- const response = await fetch(`${url}${endpoint}`, {
63
+ const maxRetries = method === "GET" ? 2 : 0;
64
+ let attempt = 0;
65
+
66
+ while (true) {
67
+ try {
68
+ const response = await fetch(`${url}${endpoint}`, {
26
69
method,
27
70
headers,
28
71
body: body ? JSON.stringify(body) : undefined,
29
- });
72
+ });
30
73
31
- const data = await response.json();
74
+ const contentType = response.headers.get("content-type") || "";
75
+ const data = contentType.includes("application/json")
76
+ ? await response.json()
77
+ : await response.text();
32
78
33
- if (!response.ok) {
34
- throw new Error(data.error || `API error: ${response.status}`);
35
- }
79
+ if (!response.ok) {
80
+ if (attempt < maxRetries && shouldRetry(response.status)) {
81
+ const retryAfter = response.headers.get("retry-after");
82
+ const retryAfterMs =
83
+ retryAfter && !Number.isNaN(Number(retryAfter))
84
+ ? Number(retryAfter) * 1000
85
+ : undefined;
86
+ const delay = retryAfterMs ?? 300 * Math.pow(2, attempt);
87
+ attempt += 1;
88
+ await sleep(delay);
89
+ continue;
90
+ }
91
+
92
+ const message =
93
+ typeof data === "string"
94
+ ? data
95
+ : data?.error || `API error: ${response.status}`;
96
+ throw new Error(message);
97
+ }
36
98
37
- return data;
99
+ return data as T;
100
+ } catch (error) {
101
+ if (attempt < maxRetries) {
102
+ attempt += 1;
103
+ await sleep(300 * Math.pow(2, attempt - 1));
104
+ continue;
105
+ }
106
+
107
+ throw error;
108
+ }
109
+ }
38
110
}
39
111
40
112
export async function getWithAuth<T = any>(endpoint: string): Promise<T> {
41
- const config = getConfig();
42
- return apiCall<T>(config.serverUrl, endpoint, "GET");
113
+ const config = getConfig();
114
+ return apiCall<T>(config.serverUrl, endpoint, "GET");
43
115
}
44
116
45
-export async function postWithAuth<T = any>(endpoint: string, body: any): Promise<T> {
46
- const config = getConfig();
47
- return apiCall<T>(config.serverUrl, endpoint, "POST", body);
117
+export async function postWithAuth<T = any>(
118
+ endpoint: string,
119
+ body: any,
120
+): Promise<T> {
121
+ const config = getConfig();
122
+ return apiCall<T>(config.serverUrl, endpoint, "POST", body);
48
123
}
49
124
50
-export async function patchWithAuth<T = any>(endpoint: string, body: any): Promise<T> {
51
- const config = getConfig();
52
- return apiCall<T>(config.serverUrl, endpoint, "PATCH", body);
125
+export async function patchWithAuth<T = any>(
126
+ endpoint: string,
127
+ body: any,
128
+): Promise<T> {
129
+ const config = getConfig();
130
+ return apiCall<T>(config.serverUrl, endpoint, "PATCH", body);
53
131
}
54
132
55
133
export async function deleteWithAuth<T = any>(endpoint: string): Promise<T> {
56
- const config = getConfig();
57
- return apiCall<T>(config.serverUrl, endpoint, "DELETE");
134
+ const config = getConfig();
135
+ return apiCall<T>(config.serverUrl, endpoint, "DELETE");
58
136
}
59
-
60
137
▼cli/src/lib/config.ts
+41-29
6
6
import Conf from "conf";
7
7
8
8
interface OchConfig {
9
- serverUrl: string;
10
- token: string;
11
- username?: string;
12
- defaultBranch?: string;
9
+ serverUrl: string;
10
+ token: string;
11
+ username?: string;
12
+ defaultBranch?: string;
13
+ caFile?: string;
14
+ insecure?: boolean;
13
15
}
14
16
15
17
const config = new Conf<OchConfig>({
16
- projectName: "opencodehub-cli",
17
- defaults: {
18
- serverUrl: "http://localhost:4321",
19
- token: "",
20
- defaultBranch: "main",
21
- },
18
+ projectName: "opencodehub-cli",
19
+ defaults: {
20
+ serverUrl: "",
21
+ token: "",
22
+ defaultBranch: "main",
23
+ caFile: "",
24
+ insecure: false,
25
+ },
22
26
});
23
27
24
28
export function getConfig(): OchConfig {
25
- return {
26
- serverUrl: config.get("serverUrl"),
27
- token: config.get("token"),
28
- username: config.get("username"),
29
- defaultBranch: config.get("defaultBranch"),
30
- };
29
+ return {
30
+ serverUrl: config.get("serverUrl"),
31
+ token: config.get("token"),
32
+ username: config.get("username"),
33
+ defaultBranch: config.get("defaultBranch"),
34
+ caFile: config.get("caFile"),
35
+ insecure: config.get("insecure"),
36
+ };
31
37
}
32
38
33
39
export function saveConfig(updates: Partial<OchConfig>): void {
34
- if (updates.serverUrl !== undefined) {
35
- config.set("serverUrl", updates.serverUrl);
36
- }
37
- if (updates.token !== undefined) {
38
- config.set("token", updates.token);
39
- }
40
- if (updates.username !== undefined) {
41
- config.set("username", updates.username);
42
- }
43
- if (updates.defaultBranch !== undefined) {
44
- config.set("defaultBranch", updates.defaultBranch);
45
- }
40
+ if (updates.serverUrl !== undefined) {
41
+ config.set("serverUrl", updates.serverUrl);
42
+ }
43
+ if (updates.token !== undefined) {
44
+ config.set("token", updates.token);
45
+ }
46
+ if (updates.username !== undefined) {
47
+ config.set("username", updates.username);
48
+ }
49
+ if (updates.defaultBranch !== undefined) {
50
+ config.set("defaultBranch", updates.defaultBranch);
51
+ }
52
+ if (updates.caFile !== undefined) {
53
+ config.set("caFile", updates.caFile);
54
+ }
55
+ if (updates.insecure !== undefined) {
56
+ config.set("insecure", updates.insecure);
57
+ }
46
58
}
47
59
48
60
export function clearConfig(): void {
49
- config.clear();
61
+ config.clear();
50
62
}
51
63
▼cli/src/lib/git.ts
+62-0
1
+import { simpleGit, type SimpleGit } from "simple-git";
2
+
3
+export interface RepoInfo {
4
+ owner: string;
5
+ repo: string;
6
+}
7
+
8
+function stripGitSuffix(value: string): string {
9
+ return value.replace(/\.git$/i, "");
10
+}
11
+
12
+export function parseRepoFromRemoteUrl(url: string): RepoInfo | null {
13
+ try {
14
+ if (!url) return null;
15
+
16
+ let path = "";
17
+
18
+ if (
19
+ url.startsWith("http://") ||
20
+ url.startsWith("https://") ||
21
+ url.startsWith("ssh://")
22
+ ) {
23
+ const parsed = new URL(url);
24
+ path = parsed.pathname;
25
+ } else if (url.includes(":")) {
26
+ const parts = url.split(":");
27
+ path = parts.slice(1).join(":");
28
+ } else {
29
+ path = url;
30
+ }
31
+
32
+ const segments = path.replace(/^\/+/, "").split("/").filter(Boolean);
33
+ if (segments.length < 2) return null;
34
+
35
+ const repo = stripGitSuffix(segments.pop() || "");
36
+ const owner = segments.pop() || "";
37
+
38
+ if (!owner || !repo) return null;
39
+
40
+ return { owner, repo };
41
+ } catch {
42
+ return null;
43
+ }
44
+}
45
+
46
+export async function getRepoInfoFromGit(
47
+ git?: SimpleGit,
48
+): Promise<RepoInfo | null> {
49
+ try {
50
+ const client = git ?? simpleGit();
51
+ const remotes = await client.getRemotes(true);
52
+ const origin = remotes.find((r) => r.name === "origin");
53
+ if (!origin?.refs?.push && !origin?.refs?.fetch) return null;
54
+
55
+ const remoteUrl = origin.refs.push || origin.refs.fetch;
56
+ if (!remoteUrl) return null;
57
+
58
+ return parseRepoFromRemoteUrl(remoteUrl);
59
+ } catch {
60
+ return null;
61
+ }
62
+}
0
63