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 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[13]:\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>\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>\nflags{edit}:\n --title, --body, --add-label, --remove-label, --add-assignee, --remove-assignee, --milestone\nflags{close}:\n --reason <completed|not_planned>, --comment <text>\nflags{comment}:\n --body <text> (required)\nflags{transfer}:\n --to-repo <owner/name> (required)\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";
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[13]:
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 fields = "number,title,state,author,createdAt,body" +
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
- const item = await ghJson(ghArgs, ctx);
186
- const blocks = [
187
- renderDetail("issue", item, full ? viewSchemaFull : viewSchema),
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
- const blocks = [renderDetail("issue", item, createResultSchema)];
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
- await ghExec(ghArgs, ctx);
258
- // Fetch updated issue
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
- const blocks = [renderDetail("issue", item, editResultSchema)];
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 });