Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

131 lines · 6.3 KBRaw History
1import { list, positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js";
2import { CliError } from "../io.js";
3import { ago, json, table, truncate } from "../output.js";
4import { bodyFrom, bodyOpts, type CommentJson, type IssueJson } from "./pr.js";
5
6const issueState = (c: Ctx["c"], i: IssueJson) => (i.state === "open" ? c.green("open") : c.dim(i.state_reason === "not_planned" ? "not planned" : "closed"));
7
8const 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
33const 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
61const 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
84const 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
104function 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
131export const issueGroup: Group = { name: "issue", summary: "Work with issues", commands: [create, listCmd, view, comment, setState("closed"), setState("open")] };