gh-axi 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/dist/src/commands/issue.d.ts +2 -1
- package/dist/src/commands/issue.js +363 -19
- package/dist/src/commands/issue.js.map +1 -1
- package/dist/src/errors.js +15 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/suggestions.d.ts +1 -1
- package/dist/src/suggestions.js +71 -80
- package/dist/src/suggestions.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ hooks. Those hooks invoke `gh-axi` directly from the packaged production build.
|
|
|
30
30
|
```bash
|
|
31
31
|
gh-axi # dashboard - live state, no args needed
|
|
32
32
|
gh-axi issue list # list issues in current repo
|
|
33
|
+
gh-axi issue subissue list 16 # list sub-issues for issue #16
|
|
33
34
|
gh-axi pr view 42 # view pull request #42
|
|
34
35
|
gh-axi run list -R owner/repo # list workflow runs for a specific repo
|
|
35
36
|
gh-axi run view 123456 --job 789012 # inspect a single job within a run
|
|
@@ -38,17 +39,17 @@ gh-axi run view --job 789012 --log-failed # show failed log lines for one job
|
|
|
38
39
|
|
|
39
40
|
### Commands
|
|
40
41
|
|
|
41
|
-
| Command | Description
|
|
42
|
-
| ---------- |
|
|
43
|
-
| `issue` | Issues — list, view, create, edit, close, reopen, comment |
|
|
44
|
-
| `pr` | Pull requests — list, view, create, merge, review, checks
|
|
45
|
-
| `run` | Workflow runs — list, view, rerun, cancel, watch
|
|
46
|
-
| `workflow` | Workflows — list, view, run, enable, disable
|
|
47
|
-
| `release` | Releases — list, view, create, edit, delete
|
|
48
|
-
| `repo` | Repositories — list, view, create, edit, clone, fork
|
|
49
|
-
| `label` | Labels — list, create, edit, delete
|
|
50
|
-
| `search` | Search issues, PRs, repos, commits, code
|
|
51
|
-
| `api` | Raw GitHub API access
|
|
42
|
+
| Command | Description |
|
|
43
|
+
| ---------- | ------------------------------------------------------------------- |
|
|
44
|
+
| `issue` | Issues — list, view, create, edit, close, reopen, comment, subissue |
|
|
45
|
+
| `pr` | Pull requests — list, view, create, merge, review, checks |
|
|
46
|
+
| `run` | Workflow runs — list, view, rerun, cancel, watch |
|
|
47
|
+
| `workflow` | Workflows — list, view, run, enable, disable |
|
|
48
|
+
| `release` | Releases — list, view, create, edit, delete |
|
|
49
|
+
| `repo` | Repositories — list, view, create, edit, clone, fork |
|
|
50
|
+
| `label` | Labels — list, create, edit, delete |
|
|
51
|
+
| `search` | Search issues, PRs, repos, commits, code |
|
|
52
|
+
| `api` | Raw GitHub API access |
|
|
52
53
|
|
|
53
54
|
### Global flags
|
|
54
55
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { RepoContext } from "../context.js";
|
|
2
|
-
export declare const ISSUE_HELP = "usage: gh-axi issue <subcommand> [flags]\nsubcommands[
|
|
2
|
+
export declare const ISSUE_HELP = "usage: gh-axi issue <subcommand> [flags]\nsubcommands[14]:\n list, view <number>, create, edit <number>, close <number>, reopen <number>, comment <number>, delete <number>, lock <number>, unlock <number>, pin <number>, unpin <number>, transfer <number>, subissue <add|remove|list>\nflags{list}:\n --state <open|closed|all>, --label <name>, --assignee <login>, --author <login>, --milestone <name>, --sort <created|updated|comments>, --limit <n> (default 30), --fields <a,b,c>\nflags{view}:\n --comments, --full (show complete body without truncation)\nflags{create}:\n --title <text> (required), --body <text>, --assignee <login>, --label <name>, --milestone <name>, --type <name>\nflags{edit}:\n --title, --body, --add-label, --remove-label, --add-assignee, --remove-assignee, --milestone, --type <name>, --no-type\nflags{close}:\n --reason <completed|not_planned>, --comment <text>\nflags{comment}:\n --body <text> (required)\nflags{transfer}:\n --to-repo <owner/name> (required)\nsubissue:\n add <parent> <child> [<child> ...], remove <parent> <child>, list <parent>\nexamples:\n gh-axi issue list --state closed --label bug\n gh-axi issue view 42 --comments\n gh-axi issue create --title \"Fix login\" --body \"Steps to reproduce...\"\n gh-axi issue close 42 --reason completed\n gh-axi issue transfer 42 -R source/repo --to-repo dest/repo\n gh-axi issue subissue add 16 20 101 125\n gh-axi issue subissue list 16";
|
|
3
|
+
export declare const SUBISSUE_HELP = "usage: gh-axi issue subissue <add|remove|list> <parent> [child...]\nsubcommands[3]:\n add <parent> <child> [<child> ...], remove <parent> <child>, list <parent>\nexamples:\n gh-axi issue subissue add 16 20 101 125\n gh-axi issue subissue remove 16 101\n gh-axi issue subissue list 16";
|
|
3
4
|
export declare function issueCommand(args: string[], ctx?: RepoContext): Promise<string>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ghJson, ghExec, ghRaw } from "../gh.js";
|
|
2
|
-
import { AxiError } from "../errors.js";
|
|
2
|
+
import { AxiError, mapGhError } from "../errors.js";
|
|
3
3
|
import { getSuggestions } from "../suggestions.js";
|
|
4
|
-
import { getFlag, hasFlag, getPositional, requireNumber, takeFlag, } from "../args.js";
|
|
4
|
+
import { getFlag, hasFlag, getPositional, requireNumber, takeFlag, takeBoolFlag, } from "../args.js";
|
|
5
5
|
import { truncateBody } from "../body.js";
|
|
6
6
|
import { parseFields } from "../fields.js";
|
|
7
7
|
import { formatCountLine } from "../format.js";
|
|
@@ -10,28 +10,39 @@ import { field, pluck, joinArray, relativeTime, lower, custom, renderList, rende
|
|
|
10
10
|
// Help
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
export const ISSUE_HELP = `usage: gh-axi issue <subcommand> [flags]
|
|
13
|
-
subcommands[
|
|
14
|
-
list, view <number>, create, edit <number>, close <number>, reopen <number>, comment <number>, delete <number>, lock <number>, unlock <number>, pin <number>, unpin <number>, transfer <number>
|
|
13
|
+
subcommands[14]:
|
|
14
|
+
list, view <number>, create, edit <number>, close <number>, reopen <number>, comment <number>, delete <number>, lock <number>, unlock <number>, pin <number>, unpin <number>, transfer <number>, subissue <add|remove|list>
|
|
15
15
|
flags{list}:
|
|
16
16
|
--state <open|closed|all>, --label <name>, --assignee <login>, --author <login>, --milestone <name>, --sort <created|updated|comments>, --limit <n> (default 30), --fields <a,b,c>
|
|
17
17
|
flags{view}:
|
|
18
18
|
--comments, --full (show complete body without truncation)
|
|
19
19
|
flags{create}:
|
|
20
|
-
--title <text> (required), --body <text>, --assignee <login>, --label <name>, --milestone <name>
|
|
20
|
+
--title <text> (required), --body <text>, --assignee <login>, --label <name>, --milestone <name>, --type <name>
|
|
21
21
|
flags{edit}:
|
|
22
|
-
--title, --body, --add-label, --remove-label, --add-assignee, --remove-assignee, --milestone
|
|
22
|
+
--title, --body, --add-label, --remove-label, --add-assignee, --remove-assignee, --milestone, --type <name>, --no-type
|
|
23
23
|
flags{close}:
|
|
24
24
|
--reason <completed|not_planned>, --comment <text>
|
|
25
25
|
flags{comment}:
|
|
26
26
|
--body <text> (required)
|
|
27
27
|
flags{transfer}:
|
|
28
28
|
--to-repo <owner/name> (required)
|
|
29
|
+
subissue:
|
|
30
|
+
add <parent> <child> [<child> ...], remove <parent> <child>, list <parent>
|
|
29
31
|
examples:
|
|
30
32
|
gh-axi issue list --state closed --label bug
|
|
31
33
|
gh-axi issue view 42 --comments
|
|
32
34
|
gh-axi issue create --title "Fix login" --body "Steps to reproduce..."
|
|
33
35
|
gh-axi issue close 42 --reason completed
|
|
34
|
-
gh-axi issue transfer 42 -R source/repo --to-repo dest/repo
|
|
36
|
+
gh-axi issue transfer 42 -R source/repo --to-repo dest/repo
|
|
37
|
+
gh-axi issue subissue add 16 20 101 125
|
|
38
|
+
gh-axi issue subissue list 16`;
|
|
39
|
+
export const SUBISSUE_HELP = `usage: gh-axi issue subissue <add|remove|list> <parent> [child...]
|
|
40
|
+
subcommands[3]:
|
|
41
|
+
add <parent> <child> [<child> ...], remove <parent> <child>, list <parent>
|
|
42
|
+
examples:
|
|
43
|
+
gh-axi issue subissue add 16 20 101 125
|
|
44
|
+
gh-axi issue subissue remove 16 101
|
|
45
|
+
gh-axi issue subissue list 16`;
|
|
35
46
|
// ---------------------------------------------------------------------------
|
|
36
47
|
// Field schemas
|
|
37
48
|
// ---------------------------------------------------------------------------
|
|
@@ -42,17 +53,31 @@ const listSchema = [
|
|
|
42
53
|
pluck("author", "login", "author"),
|
|
43
54
|
relativeTime("createdAt", "created"),
|
|
44
55
|
];
|
|
56
|
+
const issueTypeField = custom("type", (item) => {
|
|
57
|
+
const it = item.issueType;
|
|
58
|
+
if (it && typeof it === "object") {
|
|
59
|
+
const name = it.name;
|
|
60
|
+
if (typeof name === "string" && name.length > 0)
|
|
61
|
+
return name;
|
|
62
|
+
}
|
|
63
|
+
return "none";
|
|
64
|
+
});
|
|
45
65
|
const viewSchema = [
|
|
46
66
|
field("number"),
|
|
47
67
|
field("title"),
|
|
48
68
|
lower("state"),
|
|
49
69
|
pluck("author", "login", "author"),
|
|
50
70
|
relativeTime("createdAt", "created"),
|
|
71
|
+
issueTypeField,
|
|
51
72
|
custom("body", (item) => truncateBody(item.body, 500)),
|
|
52
73
|
];
|
|
74
|
+
const viewSchemaWithoutType = viewSchema.filter((f) => f !== issueTypeField);
|
|
53
75
|
const viewSchemaFull = viewSchema.map((f) => "as" in f && f.as === "body"
|
|
54
76
|
? custom("body", (item) => typeof item.body === "string" ? item.body : "")
|
|
55
77
|
: f);
|
|
78
|
+
const viewSchemaFullWithoutType = viewSchemaWithoutType.map((f) => "as" in f && f.as === "body"
|
|
79
|
+
? custom("body", (item) => typeof item.body === "string" ? item.body : "")
|
|
80
|
+
: f);
|
|
56
81
|
const createResultSchema = [
|
|
57
82
|
field("number"),
|
|
58
83
|
field("title"),
|
|
@@ -179,18 +204,132 @@ async function viewIssue(args, ctx) {
|
|
|
179
204
|
const num = requireNumber(getPositional(args, 1), "issue");
|
|
180
205
|
const withComments = hasFlag(args, "--comments");
|
|
181
206
|
const full = hasFlag(args, "--full");
|
|
182
|
-
const
|
|
207
|
+
const baseFields = "number,title,state,author,createdAt,body" +
|
|
183
208
|
(withComments ? ",comments" : "");
|
|
209
|
+
const fields = baseFields + ",issueType";
|
|
184
210
|
const ghArgs = ["issue", "view", String(num), "--json", fields];
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
211
|
+
let item;
|
|
212
|
+
let supportsIssueType = true;
|
|
213
|
+
try {
|
|
214
|
+
item = await ghJson(ghArgs, ctx);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
if (e instanceof AxiError && /issueType/i.test(e.message)) {
|
|
218
|
+
supportsIssueType = false;
|
|
219
|
+
item = await ghJson(["issue", "view", String(num), "--json", baseFields], ctx);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const baseSchema = supportsIssueType
|
|
226
|
+
? full
|
|
227
|
+
? viewSchemaFull
|
|
228
|
+
: viewSchema
|
|
229
|
+
: full
|
|
230
|
+
? viewSchemaFullWithoutType
|
|
231
|
+
: viewSchemaWithoutType;
|
|
232
|
+
// Best-effort augmentation with sub-issue relationships. The sub-issues API
|
|
233
|
+
// is only available via GraphQL and requires repo context; failures here
|
|
234
|
+
// should not block the primary view output.
|
|
235
|
+
let parentNum = null;
|
|
236
|
+
let childNums = [];
|
|
237
|
+
if (ctx) {
|
|
238
|
+
try {
|
|
239
|
+
const rel = await fetchSubIssueRelationships(num, ctx);
|
|
240
|
+
parentNum = rel.parent;
|
|
241
|
+
childNums = rel.subIssues;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Sub-issues are a preview feature on some repos; ignore failures.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const schema = [...baseSchema];
|
|
248
|
+
const augmented = { ...item };
|
|
249
|
+
if (childNums.length > 0) {
|
|
250
|
+
augmented._subissues = childNums.map((n) => `#${n}`);
|
|
251
|
+
schema.push(custom("subissues", (it) => it._subissues));
|
|
252
|
+
}
|
|
253
|
+
if (parentNum != null) {
|
|
254
|
+
augmented._parent = `#${parentNum}`;
|
|
255
|
+
schema.push(custom("parent", (it) => it._parent));
|
|
256
|
+
}
|
|
257
|
+
const blocks = [renderDetail("issue", augmented, schema)];
|
|
189
258
|
if (withComments && Array.isArray(item.comments)) {
|
|
190
259
|
blocks.push(renderList("comments", item.comments, commentResultSchema.filter((d) => "key" in d ? d.key !== "number" : true)));
|
|
191
260
|
}
|
|
192
261
|
return renderOutput(blocks);
|
|
193
262
|
}
|
|
263
|
+
function getOptionalRequiredFlag(args, name) {
|
|
264
|
+
if (!hasFlag(args, name))
|
|
265
|
+
return undefined;
|
|
266
|
+
const value = getFlag(args, name);
|
|
267
|
+
if (value === undefined || value.trim() === "" || value.startsWith("--")) {
|
|
268
|
+
throw new AxiError(`${name} requires a value`, "VALIDATION_ERROR");
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
async function getOwnerName(ctx) {
|
|
273
|
+
if (ctx)
|
|
274
|
+
return { owner: ctx.owner, name: ctx.name };
|
|
275
|
+
const repo = await ghJson([
|
|
276
|
+
"repo",
|
|
277
|
+
"view",
|
|
278
|
+
"--json",
|
|
279
|
+
"owner,name",
|
|
280
|
+
]);
|
|
281
|
+
return { owner: repo.owner.login, name: repo.name };
|
|
282
|
+
}
|
|
283
|
+
async function resolveIssueType(typeName, ctx) {
|
|
284
|
+
const { owner, name } = await getOwnerName(ctx);
|
|
285
|
+
const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){issueTypes(first:25){nodes{id name}}}}";
|
|
286
|
+
const result = await ghRaw([
|
|
287
|
+
"api",
|
|
288
|
+
"graphql",
|
|
289
|
+
"-f",
|
|
290
|
+
`owner=${owner}`,
|
|
291
|
+
"-f",
|
|
292
|
+
`name=${name}`,
|
|
293
|
+
"-f",
|
|
294
|
+
`query=${query}`,
|
|
295
|
+
]);
|
|
296
|
+
if (result.exitCode !== 0) {
|
|
297
|
+
throw mapGhError(result.stderr, result.exitCode);
|
|
298
|
+
}
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = JSON.parse(result.stdout);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
throw new AxiError("Unable to resolve issue types from GitHub", "UNKNOWN");
|
|
305
|
+
}
|
|
306
|
+
const nodes = parsed?.data?.repository?.issueTypes?.nodes;
|
|
307
|
+
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
308
|
+
throw new AxiError(`Issue types are not configured for this repository. Enable them in repo settings before using --type.`, "VALIDATION_ERROR");
|
|
309
|
+
}
|
|
310
|
+
const wanted = typeName.toLowerCase();
|
|
311
|
+
const match = nodes.find((n) => typeof n?.name === "string" && n.name.toLowerCase() === wanted);
|
|
312
|
+
if (!match) {
|
|
313
|
+
const available = nodes
|
|
314
|
+
.map((n) => n?.name)
|
|
315
|
+
.filter((s) => typeof s === "string");
|
|
316
|
+
throw new AxiError(`Unknown issue type "${typeName}". Available types: ${available.join(", ")}`, "VALIDATION_ERROR");
|
|
317
|
+
}
|
|
318
|
+
return { id: match.id, name: match.name };
|
|
319
|
+
}
|
|
320
|
+
async function applyIssueType(issueNodeId, typeId) {
|
|
321
|
+
const mutation = typeId === null
|
|
322
|
+
? `mutation($id:ID!){updateIssue(input:{id:$id,issueTypeId:null}){issue{id}}}`
|
|
323
|
+
: `mutation($id:ID!,$typeId:ID!){updateIssue(input:{id:$id,issueTypeId:$typeId}){issue{id}}}`;
|
|
324
|
+
const args = ["api", "graphql", "-f", `id=${issueNodeId}`];
|
|
325
|
+
if (typeId !== null)
|
|
326
|
+
args.push("-f", `typeId=${typeId}`);
|
|
327
|
+
args.push("-f", `query=${mutation}`);
|
|
328
|
+
const result = await ghRaw(args);
|
|
329
|
+
if (result.exitCode !== 0) {
|
|
330
|
+
throw mapGhError(result.stderr, result.exitCode);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
194
333
|
async function createIssue(args, ctx) {
|
|
195
334
|
const title = getFlag(args, "--title");
|
|
196
335
|
if (!title)
|
|
@@ -200,6 +339,12 @@ async function createIssue(args, ctx) {
|
|
|
200
339
|
const label = getFlag(args, "--label");
|
|
201
340
|
const milestone = getFlag(args, "--milestone");
|
|
202
341
|
const project = getFlag(args, "--project");
|
|
342
|
+
const typeName = getOptionalRequiredFlag(args, "--type");
|
|
343
|
+
// Resolve type up front so an invalid value fails before creating the issue.
|
|
344
|
+
let resolvedType;
|
|
345
|
+
if (typeName) {
|
|
346
|
+
resolvedType = await resolveIssueType(typeName, ctx);
|
|
347
|
+
}
|
|
203
348
|
const ghArgs = ["issue", "create", "--title", title];
|
|
204
349
|
if (body)
|
|
205
350
|
ghArgs.push("--body", body);
|
|
@@ -218,9 +363,19 @@ async function createIssue(args, ctx) {
|
|
|
218
363
|
const url = urlMatch ? urlMatch[0] : output.trim();
|
|
219
364
|
const numMatch = url.match(/\/issues\/(\d+)/);
|
|
220
365
|
const num = numMatch ? parseInt(numMatch[1], 10) : 0;
|
|
221
|
-
// Fetch the created issue for structured output
|
|
222
|
-
const item = await ghJson(["issue", "view", String(num), "--json", "number,title,state,url"], ctx);
|
|
223
|
-
|
|
366
|
+
// Fetch the created issue for structured output; include id for type mutation
|
|
367
|
+
const item = await ghJson(["issue", "view", String(num), "--json", "number,title,state,url,id"], ctx);
|
|
368
|
+
if (resolvedType) {
|
|
369
|
+
const issueNodeId = item.id;
|
|
370
|
+
if (typeof issueNodeId === "string" && issueNodeId.length > 0) {
|
|
371
|
+
await applyIssueType(issueNodeId, resolvedType.id);
|
|
372
|
+
}
|
|
373
|
+
item.issueType = { name: resolvedType.name };
|
|
374
|
+
}
|
|
375
|
+
const schema = resolvedType
|
|
376
|
+
? [...createResultSchema, issueTypeField]
|
|
377
|
+
: createResultSchema;
|
|
378
|
+
const blocks = [renderDetail("issue", item, schema)];
|
|
224
379
|
const help = getSuggestions({
|
|
225
380
|
domain: "issue",
|
|
226
381
|
action: "create",
|
|
@@ -239,6 +394,14 @@ async function editIssue(args, ctx) {
|
|
|
239
394
|
const addAssignee = getFlag(args, "--add-assignee");
|
|
240
395
|
const removeAssignee = getFlag(args, "--remove-assignee");
|
|
241
396
|
const milestone = getFlag(args, "--milestone");
|
|
397
|
+
const clearType = takeBoolFlag(args, "--no-type");
|
|
398
|
+
const typeName = getOptionalRequiredFlag(args, "--type");
|
|
399
|
+
const clearTypeFlag = clearType;
|
|
400
|
+
// Resolve type up front so an invalid value fails before mutating the issue.
|
|
401
|
+
let resolvedType;
|
|
402
|
+
if (typeName) {
|
|
403
|
+
resolvedType = await resolveIssueType(typeName, ctx);
|
|
404
|
+
}
|
|
242
405
|
const ghArgs = ["issue", "edit", String(num)];
|
|
243
406
|
if (title)
|
|
244
407
|
ghArgs.push("--title", title);
|
|
@@ -254,16 +417,30 @@ async function editIssue(args, ctx) {
|
|
|
254
417
|
ghArgs.push("--remove-assignee", removeAssignee);
|
|
255
418
|
if (milestone)
|
|
256
419
|
ghArgs.push("--milestone", milestone);
|
|
257
|
-
|
|
258
|
-
//
|
|
420
|
+
// Only call `gh issue edit` if there is a non-type field to update; otherwise
|
|
421
|
+
// calling with just the issue number errors out.
|
|
422
|
+
if (ghArgs.length > 3) {
|
|
423
|
+
await ghExec(ghArgs, ctx);
|
|
424
|
+
}
|
|
425
|
+
// Fetch updated issue (include id for type mutation)
|
|
259
426
|
const item = await ghJson([
|
|
260
427
|
"issue",
|
|
261
428
|
"view",
|
|
262
429
|
String(num),
|
|
263
430
|
"--json",
|
|
264
|
-
"number,title,state,labels,assignees",
|
|
431
|
+
"number,title,state,labels,assignees,id",
|
|
265
432
|
], ctx);
|
|
266
|
-
|
|
433
|
+
if (resolvedType || clearTypeFlag) {
|
|
434
|
+
const issueNodeId = item.id;
|
|
435
|
+
if (typeof issueNodeId === "string" && issueNodeId.length > 0) {
|
|
436
|
+
await applyIssueType(issueNodeId, resolvedType ? resolvedType.id : null);
|
|
437
|
+
}
|
|
438
|
+
item.issueType = resolvedType ? { name: resolvedType.name } : null;
|
|
439
|
+
}
|
|
440
|
+
const schema = resolvedType || clearTypeFlag
|
|
441
|
+
? [...editResultSchema, issueTypeField]
|
|
442
|
+
: editResultSchema;
|
|
443
|
+
const blocks = [renderDetail("issue", item, schema)];
|
|
267
444
|
const help = getSuggestions({
|
|
268
445
|
domain: "issue",
|
|
269
446
|
action: "edit",
|
|
@@ -573,11 +750,178 @@ async function transferIssue(args, ctx) {
|
|
|
573
750
|
blocks.push(renderHelp(help));
|
|
574
751
|
return renderOutput(blocks);
|
|
575
752
|
}
|
|
753
|
+
function requireRepo(ctx) {
|
|
754
|
+
if (!ctx) {
|
|
755
|
+
throw new AxiError("Could not determine repository — pass --repo <owner/name> or run inside a git checkout", "VALIDATION_ERROR");
|
|
756
|
+
}
|
|
757
|
+
return ctx;
|
|
758
|
+
}
|
|
759
|
+
async function gqlRequest(query, ctx) {
|
|
760
|
+
// Don't pass ctx through to ghJson — `gh api graphql` ignores --repo, and
|
|
761
|
+
// passing it produces a deprecation warning. The owner/name are baked into
|
|
762
|
+
// the query string instead.
|
|
763
|
+
void ctx;
|
|
764
|
+
const data = await ghJson([
|
|
765
|
+
"api",
|
|
766
|
+
"graphql",
|
|
767
|
+
"-f",
|
|
768
|
+
`query=${query}`,
|
|
769
|
+
]);
|
|
770
|
+
return data.data;
|
|
771
|
+
}
|
|
772
|
+
async function resolveIssueIds(parent, children, ctx) {
|
|
773
|
+
const childFields = children
|
|
774
|
+
.map((n, i) => `c${i}: issue(number: ${n}) { id number }`)
|
|
775
|
+
.join(" ");
|
|
776
|
+
const query = `query { repository(owner: "${ctx.owner}", name: "${ctx.name}") { parent: issue(number: ${parent}) { id number } ${childFields} } }`;
|
|
777
|
+
const result = await gqlRequest(query, ctx);
|
|
778
|
+
const repo = result.repository ?? {};
|
|
779
|
+
const parentNode = repo.parent;
|
|
780
|
+
if (!parentNode) {
|
|
781
|
+
throw new AxiError(`Issue #${parent} not found in ${ctx.nwo}`, "NOT_FOUND");
|
|
782
|
+
}
|
|
783
|
+
const childNodes = [];
|
|
784
|
+
for (let i = 0; i < children.length; i++) {
|
|
785
|
+
const node = repo[`c${i}`];
|
|
786
|
+
if (!node) {
|
|
787
|
+
throw new AxiError(`Issue #${children[i]} not found in ${ctx.nwo}`, "NOT_FOUND");
|
|
788
|
+
}
|
|
789
|
+
childNodes.push(node);
|
|
790
|
+
}
|
|
791
|
+
return { parent: parentNode, children: childNodes };
|
|
792
|
+
}
|
|
793
|
+
async function subissueAdd(args, ctx) {
|
|
794
|
+
const repo = requireRepo(ctx);
|
|
795
|
+
const parentRaw = args[2];
|
|
796
|
+
const childRaw = args.slice(3).filter((a) => !a.startsWith("--"));
|
|
797
|
+
const parentNum = requireNumber(parentRaw, "parent");
|
|
798
|
+
if (childRaw.length === 0) {
|
|
799
|
+
throw new AxiError("subissue add requires at least one child issue number", "VALIDATION_ERROR");
|
|
800
|
+
}
|
|
801
|
+
const childNums = childRaw.map((r) => requireNumber(r, "child"));
|
|
802
|
+
const { parent, children } = await resolveIssueIds(parentNum, childNums, repo);
|
|
803
|
+
const addedNumbers = [];
|
|
804
|
+
for (const child of children) {
|
|
805
|
+
const mutation = `mutation { addSubIssue(input: { issueId: "${parent.id}", subIssueId: "${child.id}" }) { subIssue { number } } }`;
|
|
806
|
+
let result;
|
|
807
|
+
try {
|
|
808
|
+
result = await gqlRequest(mutation, repo);
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
if (addedNumbers.length === 0)
|
|
812
|
+
throw error;
|
|
813
|
+
const added = addedNumbers.map((n) => `#${n}`).join(", ");
|
|
814
|
+
if (error instanceof AxiError) {
|
|
815
|
+
throw new AxiError(`${error.message}\nAdded before failure: ${added}`, error.code);
|
|
816
|
+
}
|
|
817
|
+
throw new AxiError(`Failed to add sub-issue #${child.number}\nAdded before failure: ${added}`, "UNKNOWN");
|
|
818
|
+
}
|
|
819
|
+
const r = result.addSubIssue;
|
|
820
|
+
if (r?.subIssue?.number != null)
|
|
821
|
+
addedNumbers.push(r.subIssue.number);
|
|
822
|
+
else
|
|
823
|
+
addedNumbers.push(child.number);
|
|
824
|
+
}
|
|
825
|
+
const item = {
|
|
826
|
+
parent: `#${parent.number}`,
|
|
827
|
+
added: addedNumbers.map((n) => `#${n}`),
|
|
828
|
+
};
|
|
829
|
+
const blocks = [
|
|
830
|
+
renderDetail("subissue_add", item, [
|
|
831
|
+
field("parent"),
|
|
832
|
+
custom("added", (it) => it.added),
|
|
833
|
+
]),
|
|
834
|
+
];
|
|
835
|
+
blocks.push(renderHelp([
|
|
836
|
+
`Run \`gh-axi issue view ${parent.number}\` to see the parent with its sub-issues`,
|
|
837
|
+
]));
|
|
838
|
+
return renderOutput(blocks);
|
|
839
|
+
}
|
|
840
|
+
async function subissueRemove(args, ctx) {
|
|
841
|
+
const repo = requireRepo(ctx);
|
|
842
|
+
const parentRaw = args[2];
|
|
843
|
+
const childRaw = args[3];
|
|
844
|
+
const parentNum = requireNumber(parentRaw, "parent");
|
|
845
|
+
if (!childRaw) {
|
|
846
|
+
throw new AxiError("subissue remove requires a child issue number", "VALIDATION_ERROR");
|
|
847
|
+
}
|
|
848
|
+
const childNum = requireNumber(childRaw, "child");
|
|
849
|
+
const { parent, children } = await resolveIssueIds(parentNum, [childNum], repo);
|
|
850
|
+
const child = children[0];
|
|
851
|
+
const mutation = `mutation { removeSubIssue(input: { issueId: "${parent.id}", subIssueId: "${child.id}" }) { issue { number } } }`;
|
|
852
|
+
await gqlRequest(mutation, repo);
|
|
853
|
+
const item = {
|
|
854
|
+
parent: `#${parent.number}`,
|
|
855
|
+
removed: `#${child.number}`,
|
|
856
|
+
};
|
|
857
|
+
const blocks = [
|
|
858
|
+
renderDetail("subissue_remove", item, [field("parent"), field("removed")]),
|
|
859
|
+
];
|
|
860
|
+
blocks.push(renderHelp([
|
|
861
|
+
`Run \`gh-axi issue subissue list ${parent.number}\` to see remaining sub-issues`,
|
|
862
|
+
]));
|
|
863
|
+
return renderOutput(blocks);
|
|
864
|
+
}
|
|
865
|
+
async function subissueList(args, ctx) {
|
|
866
|
+
const repo = requireRepo(ctx);
|
|
867
|
+
const parentRaw = args[2];
|
|
868
|
+
const parentNum = requireNumber(parentRaw, "parent");
|
|
869
|
+
const query = `query { repository(owner: "${repo.owner}", name: "${repo.name}") { issue(number: ${parentNum}) { subIssues(first: 100) { totalCount nodes { number title state } } } } }`;
|
|
870
|
+
const data = await gqlRequest(query, repo);
|
|
871
|
+
const issue = data.repository?.issue;
|
|
872
|
+
if (!issue) {
|
|
873
|
+
throw new AxiError(`Issue #${parentNum} not found in ${repo.nwo}`, "NOT_FOUND");
|
|
874
|
+
}
|
|
875
|
+
const nodes = issue.subIssues.nodes ?? [];
|
|
876
|
+
const totalCount = issue.subIssues.totalCount ?? nodes.length;
|
|
877
|
+
const countLine = formatCountLine({
|
|
878
|
+
count: nodes.length,
|
|
879
|
+
limit: 100,
|
|
880
|
+
totalCount,
|
|
881
|
+
});
|
|
882
|
+
const schema = [field("number"), field("title"), lower("state")];
|
|
883
|
+
const blocks = [
|
|
884
|
+
`parent: #${parentNum}`,
|
|
885
|
+
countLine,
|
|
886
|
+
renderList("subissues", nodes, schema),
|
|
887
|
+
];
|
|
888
|
+
return renderOutput(blocks);
|
|
889
|
+
}
|
|
890
|
+
async function fetchSubIssueRelationships(num, ctx) {
|
|
891
|
+
const query = `query { repository(owner: "${ctx.owner}", name: "${ctx.name}") { issue(number: ${num}) { parent { number } subIssues(first: 100) { totalCount nodes { number } } } } }`;
|
|
892
|
+
const data = await gqlRequest(query, ctx);
|
|
893
|
+
const issue = data.repository?.issue;
|
|
894
|
+
if (!issue)
|
|
895
|
+
return { parent: null, subIssues: [] };
|
|
896
|
+
return {
|
|
897
|
+
parent: issue.parent?.number ?? null,
|
|
898
|
+
subIssues: (issue.subIssues?.nodes ?? []).map((n) => n.number),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
async function subissueCommand(args, ctx) {
|
|
902
|
+
const sub = args[1];
|
|
903
|
+
if (!sub || hasFlag(args, "--help")) {
|
|
904
|
+
return renderOutput([SUBISSUE_HELP]);
|
|
905
|
+
}
|
|
906
|
+
switch (sub) {
|
|
907
|
+
case "add":
|
|
908
|
+
return subissueAdd(args, ctx);
|
|
909
|
+
case "remove":
|
|
910
|
+
return subissueRemove(args, ctx);
|
|
911
|
+
case "list":
|
|
912
|
+
return subissueList(args, ctx);
|
|
913
|
+
default:
|
|
914
|
+
return renderError(`Unknown subissue subcommand: ${sub}`, "VALIDATION_ERROR", ["Run `gh-axi issue subissue --help` for usage"]);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
576
917
|
// ---------------------------------------------------------------------------
|
|
577
918
|
// Main dispatcher
|
|
578
919
|
// ---------------------------------------------------------------------------
|
|
579
920
|
export async function issueCommand(args, ctx) {
|
|
580
921
|
const sub = args[0];
|
|
922
|
+
if (sub === "subissue") {
|
|
923
|
+
return subissueCommand(args, ctx);
|
|
924
|
+
}
|
|
581
925
|
if (!sub || hasFlag(args, "--help")) {
|
|
582
926
|
const blocks = [ISSUE_HELP];
|
|
583
927
|
const help = getSuggestions({ domain: "issue", action: "help", repo: ctx });
|