416b898
commit 416b898
19 files changed
+4784 -4069
Showing 19 changed files+4784-4069
cli/bin/och.tscli/src/commands/api/index.tscli/src/commands/auth.tscli/src/commands/automate/index.tscli/src/commands/ci/index.tscli/src/commands/config/index.tscli/src/commands/insights/index.tscli/src/commands/issue/index.tscli/src/commands/metrics/index.tscli/src/commands/pr/index.tscli/src/commands/queue/index.tscli/src/commands/release/index.tscli/src/commands/repo.tscli/src/commands/review/index.tscli/src/commands/secret/index.tscli/src/commands/stack/index.tscli/src/lib/api.tscli/src/lib/config.tscli/src/lib/git.ts
▼cli/bin/och.ts
+293-241
| @@ -4,87 +4,99 @@ | ||
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,48 +143,64 @@ program.addCommand(insightsCommand); | ||
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,172 +225,196 @@ program.addCommand(apiCommands); | ||
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,7 +423,7 @@ _och_completions() { | ||
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,7 +457,7 @@ _och() { | ||
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,16 +482,16 @@ complete -c och -n "__fish_use_subcommand" -a "sync" -d "Sync with remote" | ||
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,5 +501,5 @@ program.parse(process.argv); | ||
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,154 +3,184 @@ | ||
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,166 +3,221 @@ | ||
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,303 +3,333 @@ | ||
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,348 +3,377 @@ | ||
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,158 +3,288 @@ | ||
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,259 +3,285 @@ | ||
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,368 +3,394 @@ | ||
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,203 +3,211 @@ | ||
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,435 +3,482 @@ | ||
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,256 +3,282 @@ | ||
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,297 +3,318 @@ | ||
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,494 +6,488 @@ | ||
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,333 +3,349 @@ | ||
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,226 +3,227 @@ | ||
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,408 +3,481 @@ | ||
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,57 +3,134 @@ | ||
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,45 +6,57 @@ | ||
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
| @@ -0,0 +1,62 @@ | ||
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 | |