| 1 | import { list, positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; |
| 2 | import { CliError } from "../io.js"; |
| 3 | import { ago, json, table, truncate } from "../output.js"; |
| 4 | import { bodyFrom, bodyOpts, type CommentJson, type IssueJson } from "./pr.js"; |
| 5 | |
| 6 | const issueState = (c: Ctx["c"], i: IssueJson) => (i.state === "open" ? c.green("open") : c.dim(i.state_reason === "not_planned" ? "not planned" : "closed")); |
| 7 | |
| 8 | const create: Command = { |
| 9 | name: "issue create", |
| 10 | summary: "Open an issue", |
| 11 | options: { |
| 12 | title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" }, |
| 13 | ...bodyOpts, |
| 14 | label: { type: "string", short: "l", multiple: true, desc: "Add a label", arg: "NAME" }, |
| 15 | assignee: { type: "string", short: "a", multiple: true, desc: "Assign a login", arg: "LOGIN" }, |
| 16 | }, |
| 17 | repo: true, |
| 18 | json: true, |
| 19 | maxArgs: 0, |
| 20 | async run(ctx, o) { |
| 21 | const title = str(o, "title"); |
| 22 | if (!title) throw new CliError("--title is required", 2); |
| 23 | const repo = await ctx.repo(); |
| 24 | const labels = list(o, "label"); |
| 25 | const assignees = list(o, "assignee"); |
| 26 | const i = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/issues`, { title, body: await bodyFrom(ctx, o), labels: labels.length ? labels : undefined, assignees: assignees.length ? assignees : undefined }); |
| 27 | if (ctx.json) return json(ctx.io, i); |
| 28 | ctx.out(`${ctx.c.green("✓")} Opened issue #${i.number} ${i.title}`); |
| 29 | ctx.out(i.html_url); |
| 30 | }, |
| 31 | }; |
| 32 | |
| 33 | const listCmd: Command = { |
| 34 | name: "issue list", |
| 35 | summary: "List issues", |
| 36 | options: { |
| 37 | state: { type: "string", short: "s", desc: "open, closed or all (default: open)", arg: "STATE" }, |
| 38 | label: { type: "string", short: "l", desc: "Only issues with this label", arg: "NAME" }, |
| 39 | author: { type: "string", short: "A", desc: "Only issues opened by this login", arg: "LOGIN" }, |
| 40 | search: { type: "string", short: "S", desc: "Search text", arg: "QUERY" }, |
| 41 | limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, |
| 42 | }, |
| 43 | repo: true, |
| 44 | json: true, |
| 45 | maxArgs: 0, |
| 46 | async run(ctx, o) { |
| 47 | const repo = await ctx.repo(); |
| 48 | const state = str(o, "state") ?? "open"; |
| 49 | if (!["open", "closed", "all"].includes(state)) throw new CliError("--state must be open, closed or all", 2); |
| 50 | const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; |
| 51 | const items = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/issues`, { state, labels: str(o, "label"), creator: str(o, "author"), q: str(o, "search"), per_page: Math.min(100, limit + 20) }); |
| 52 | // The issues endpoint can include pull requests (as on GitHub); show only issues. |
| 53 | const issues = items.filter((i) => !i.pull_request).slice(0, limit); |
| 54 | if (ctx.json) return json(ctx.io, issues); |
| 55 | if (!issues.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}issues in ${repo.owner}/${repo.name}.`); |
| 56 | const c = ctx.c; |
| 57 | ctx.io.stdout.write(table(issues.map((i) => [c.bold(`#${i.number}`), truncate(i.title, 60), issueState(c, i), c.cyan(i.labels.map((l) => l.name).join(", ")), c.dim(i.user.login), c.dim(ago(i.updated_at))]))); |
| 58 | }, |
| 59 | }; |
| 60 | |
| 61 | const view: Command = { |
| 62 | name: "issue view", |
| 63 | summary: "Show an issue and its comments", |
| 64 | args: "<number>", |
| 65 | repo: true, |
| 66 | json: true, |
| 67 | minArgs: 1, |
| 68 | maxArgs: 1, |
| 69 | async run(ctx, _o, [num]) { |
| 70 | const n = positiveInt(num, "issue number"); |
| 71 | const repo = await ctx.repo(); |
| 72 | const api = await ctx.api(); |
| 73 | const [i, comments] = await Promise.all([api.get<IssueJson>(`${repoPath(repo)}/issues/${n}`), api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`)]); |
| 74 | if (ctx.json) return json(ctx.io, { ...i, comment_list: comments }); |
| 75 | const c = ctx.c; |
| 76 | ctx.out(`${c.bold(i.title)} ${c.dim(`#${i.number}`)}`); |
| 77 | ctx.out(`${issueState(c, i)} · opened by ${i.user.login} ${ago(i.created_at)}${i.labels.length ? ` · ${c.cyan(i.labels.map((l) => l.name).join(", "))}` : ""}${i.assignees.length ? ` · assigned to ${i.assignees.map((a) => a.login).join(", ")}` : ""}`); |
| 78 | if (i.body?.trim()) ctx.out(`\n${i.body.trim()}`); |
| 79 | for (const cm of comments) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`); |
| 80 | ctx.out(`\n${c.dim(i.html_url)}`); |
| 81 | }, |
| 82 | }; |
| 83 | |
| 84 | const comment: Command = { |
| 85 | name: "issue comment", |
| 86 | summary: "Comment on an issue", |
| 87 | args: "<number>", |
| 88 | options: bodyOpts, |
| 89 | repo: true, |
| 90 | json: true, |
| 91 | minArgs: 1, |
| 92 | maxArgs: 1, |
| 93 | async run(ctx, o, [num]) { |
| 94 | const n = positiveInt(num, "issue number"); |
| 95 | const body = await bodyFrom(ctx, o, true); |
| 96 | if (!body?.trim()) throw new CliError("--body is required", 2); |
| 97 | const repo = await ctx.repo(); |
| 98 | const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); |
| 99 | if (ctx.json) return json(ctx.io, r); |
| 100 | ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); |
| 101 | }, |
| 102 | }; |
| 103 | |
| 104 | function setState(state: "open" | "closed"): Command { |
| 105 | return { |
| 106 | name: state === "closed" ? "issue close" : "issue reopen", |
| 107 | summary: state === "closed" ? "Close an issue" : "Reopen an issue", |
| 108 | args: "<number>", |
| 109 | options: { |
| 110 | ...(state === "closed" ? { reason: { type: "string" as const, short: "r", desc: "completed (default) or not_planned", arg: "REASON" } } : {}), |
| 111 | comment: { type: "string", short: "c", desc: "Leave a comment too", arg: "TEXT" }, |
| 112 | }, |
| 113 | repo: true, |
| 114 | json: true, |
| 115 | minArgs: 1, |
| 116 | maxArgs: 1, |
| 117 | async run(ctx, o, [num]) { |
| 118 | const n = positiveInt(num, "issue number"); |
| 119 | const reason = str(o, "reason")?.replace(/[\s-]+/g, "_"); |
| 120 | if (reason && !["completed", "not_planned"].includes(reason)) throw new CliError("--reason must be completed or not_planned", 2); |
| 121 | const repo = await ctx.repo(); |
| 122 | const api = await ctx.authedApi(); |
| 123 | if (str(o, "comment")) await api.post(`${repoPath(repo)}/issues/${n}/comments`, { body: str(o, "comment") }); |
| 124 | const i = await api.patch<IssueJson>(`${repoPath(repo)}/issues/${n}`, { state, state_reason: state === "closed" ? (reason ?? "completed") : undefined }); |
| 125 | if (ctx.json) return json(ctx.io, i); |
| 126 | ctx.out(`${ctx.c.green("✓")} ${state === "closed" ? "Closed" : "Reopened"} #${n} ${i.title}`); |
| 127 | }, |
| 128 | }; |
| 129 | } |
| 130 | |
| 131 | export const issueGroup: Group = { name: "issue", summary: "Work with issues", commands: [create, listCmd, view, comment, setState("closed"), setState("open")] }; |