ralph-hero-mcp-server 2.4.39 → 2.4.41

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.
@@ -22,7 +22,7 @@ const REMAINING_PHASES = {
22
22
  // ---------------------------------------------------------------------------
23
23
  // Oversized estimate detection
24
24
  // ---------------------------------------------------------------------------
25
- const OVERSIZED_ESTIMATES = new Set(["M", "L", "XL"]);
25
+ export const OVERSIZED_ESTIMATES = new Set(["M", "L", "XL"]);
26
26
  // ---------------------------------------------------------------------------
27
27
  // Detection logic
28
28
  // ---------------------------------------------------------------------------
@@ -56,8 +56,8 @@ export function detectPipelinePosition(issues, isGroup, groupPrimary) {
56
56
  blocking: [],
57
57
  });
58
58
  }
59
- // Step 1: Check for oversized issues needing split
60
- const oversized = issues.filter((i) => i.estimate !== null && OVERSIZED_ESTIMATES.has(i.estimate));
59
+ // Step 1: Check for oversized issues needing split (skip already-split issues)
60
+ const oversized = issues.filter((i) => i.estimate !== null && OVERSIZED_ESTIMATES.has(i.estimate) && i.subIssueCount === 0);
61
61
  if (oversized.length > 0) {
62
62
  return buildResult("SPLIT", `${oversized.length} issue(s) need splitting (estimate: ${oversized.map((i) => `#${i.number}=${i.estimate}`).join(", ")})`, issues, isGroup, groupPrimary, { required: false, met: true, blocking: [] });
63
63
  }
@@ -7,7 +7,7 @@
7
7
  import { z } from "zod";
8
8
  import { paginateConnection } from "../lib/pagination.js";
9
9
  import { detectGroup } from "../lib/group-detection.js";
10
- import { detectPipelinePosition, } from "../lib/pipeline-detection.js";
10
+ import { detectPipelinePosition, OVERSIZED_ESTIMATES, } from "../lib/pipeline-detection.js";
11
11
  import { isValidState, VALID_STATES, LOCK_STATES, } from "../lib/workflow-states.js";
12
12
  import { resolveState } from "../lib/state-resolution.js";
13
13
  import { parseDateMath } from "../lib/date-math.js";
@@ -880,8 +880,28 @@ export function registerIssueTools(server, client, fieldCache) {
880
880
  title: ticket.title,
881
881
  workflowState: state.workflowState || "unknown",
882
882
  estimate: state.estimate || null,
883
+ subIssueCount: 0,
883
884
  };
884
885
  }));
886
+ // Fetch sub-issue counts for oversized issues (targeted query, not all issues)
887
+ const oversizedNumbers = issueStates
888
+ .filter((i) => i.estimate !== null && OVERSIZED_ESTIMATES.has(i.estimate))
889
+ .map((i) => i.number);
890
+ if (oversizedNumbers.length > 0) {
891
+ await Promise.all(oversizedNumbers.map(async (num) => {
892
+ const subResult = await client.query(`query($owner: String!, $repo: String!, $issueNum: Int!) {
893
+ repository(owner: $owner, name: $repo) {
894
+ issue(number: $issueNum) {
895
+ subIssuesSummary { total }
896
+ }
897
+ }
898
+ }`, { owner, repo, issueNum: num });
899
+ const issueState = issueStates.find((i) => i.number === num);
900
+ if (issueState && subResult.repository?.issue?.subIssuesSummary) {
901
+ issueState.subIssueCount = subResult.repository.issue.subIssuesSummary.total;
902
+ }
903
+ }));
904
+ }
885
905
  // Detect pipeline position
886
906
  const position = detectPipelinePosition(issueStates, group.isGroup, group.groupPrimary.number);
887
907
  return toolSuccess(position);
@@ -13,6 +13,53 @@ import { isValidState, isEarlierState, VALID_STATES, PARENT_GATE_STATES, isParen
13
13
  import { toolSuccess, toolError } from "../types.js";
14
14
  import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, updateProjectItemField, getCurrentFieldValue, resolveConfig, resolveFullConfig, syncStatusField, } from "../lib/helpers.js";
15
15
  // ---------------------------------------------------------------------------
16
+ // Sub-issue tree helpers (exported for testing)
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Recursively build the GraphQL selection set for nested sub-issues.
20
+ * At the leaf level (currentDepth >= maxDepth), returns only base fields.
21
+ * At inner levels, includes subIssuesSummary and nested subIssues.
22
+ */
23
+ export function buildSubIssueFragment(currentDepth, maxDepth) {
24
+ const base = "id number title state";
25
+ if (currentDepth >= maxDepth)
26
+ return base;
27
+ return `${base}
28
+ subIssuesSummary { total completed percentCompleted }
29
+ subIssues(first: 50) {
30
+ nodes { ${buildSubIssueFragment(currentDepth + 1, maxDepth)} }
31
+ }`;
32
+ }
33
+ /**
34
+ * Recursively map raw GraphQL sub-issue nodes into typed SubIssueNode[].
35
+ * Adds subIssues/subIssuesSummary fields when currentDepth < maxDepth.
36
+ */
37
+ export function mapSubIssueNodes(
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ nodes, currentDepth, maxDepth) {
40
+ return nodes.map((node) => {
41
+ const mapped = {
42
+ id: node.id,
43
+ number: node.number,
44
+ title: node.title,
45
+ state: node.state,
46
+ };
47
+ if (currentDepth < maxDepth && node.subIssues?.nodes) {
48
+ mapped.subIssues = mapSubIssueNodes(node.subIssues.nodes, currentDepth + 1, maxDepth);
49
+ mapped.subIssuesSummary = node.subIssuesSummary || {
50
+ total: node.subIssues.nodes.length,
51
+ completed: node.subIssues.nodes.filter((si) => si.state === "CLOSED").length,
52
+ percentCompleted: node.subIssues.nodes.length > 0
53
+ ? Math.round((node.subIssues.nodes.filter((si) => si.state === "CLOSED").length /
54
+ node.subIssues.nodes.length) *
55
+ 100)
56
+ : 0,
57
+ };
58
+ }
59
+ return mapped;
60
+ });
61
+ }
62
+ // ---------------------------------------------------------------------------
16
63
  // Register relationship tools
17
64
  // ---------------------------------------------------------------------------
18
65
  export function registerRelationshipTools(server, client, fieldCache) {
@@ -73,7 +120,7 @@ export function registerRelationshipTools(server, client, fieldCache) {
73
120
  // -------------------------------------------------------------------------
74
121
  // ralph_hero__list_sub_issues
75
122
  // -------------------------------------------------------------------------
76
- server.tool("ralph_hero__list_sub_issues", "List all sub-issues (children) of a parent GitHub issue, with completion summary", {
123
+ server.tool("ralph_hero__list_sub_issues", "List all sub-issues (children) of a parent GitHub issue, with completion summary. Use depth parameter (1-3) to fetch nested sub-issue trees in a single call. Default depth=1 returns direct children only.", {
77
124
  owner: z
78
125
  .string()
79
126
  .optional()
@@ -83,10 +130,17 @@ export function registerRelationshipTools(server, client, fieldCache) {
83
130
  .optional()
84
131
  .describe("Repository name. Defaults to GITHUB_REPO env var"),
85
132
  number: z.coerce.number().describe("Parent issue number"),
133
+ depth: z.coerce
134
+ .number()
135
+ .optional()
136
+ .default(1)
137
+ .describe("How many levels of sub-issues to fetch (1=direct children, 2=children+grandchildren, max 3)"),
86
138
  }, async (args) => {
87
139
  try {
88
140
  const { owner, repo } = resolveConfig(client, args);
89
- const result = await client.query(`query($owner: String!, $repo: String!, $number: Int!) {
141
+ const depth = Math.min(Math.max(args.depth, 1), 3);
142
+ const subIssueFields = buildSubIssueFragment(1, depth);
143
+ const queryStr = `query($owner: String!, $repo: String!, $number: Int!) {
90
144
  repository(owner: $owner, name: $repo) {
91
145
  issue(number: $number) {
92
146
  id
@@ -94,34 +148,31 @@ export function registerRelationshipTools(server, client, fieldCache) {
94
148
  title
95
149
  subIssuesSummary { total completed percentCompleted }
96
150
  subIssues(first: 50) {
97
- nodes { id number title state }
151
+ nodes { ${subIssueFields} }
98
152
  pageInfo { hasNextPage endCursor }
99
153
  }
100
154
  }
101
155
  }
102
- }`, { owner, repo, number: args.number });
156
+ }`;
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ const result = await client.query(queryStr, { owner, repo, number: args.number });
103
159
  const issue = result.repository?.issue;
104
160
  if (!issue) {
105
161
  return toolError(`Issue #${args.number} not found in ${owner}/${repo}`);
106
162
  }
163
+ const mappedSubIssues = mapSubIssueNodes(issue.subIssues.nodes, 1, depth);
107
164
  return toolSuccess({
108
165
  parent: {
109
166
  id: issue.id,
110
167
  number: issue.number,
111
168
  title: issue.title,
112
169
  },
113
- subIssues: issue.subIssues.nodes.map((si) => ({
114
- id: si.id,
115
- number: si.number,
116
- title: si.title,
117
- state: si.state,
118
- })),
170
+ subIssues: mappedSubIssues,
119
171
  summary: issue.subIssuesSummary || {
120
172
  total: issue.subIssues.nodes.length,
121
173
  completed: issue.subIssues.nodes.filter((si) => si.state === "CLOSED").length,
122
174
  percentCompleted: issue.subIssues.nodes.length > 0
123
- ? Math.round((issue.subIssues.nodes.filter((si) => si.state === "CLOSED")
124
- .length /
175
+ ? Math.round((issue.subIssues.nodes.filter((si) => si.state === "CLOSED").length /
125
176
  issue.subIssues.nodes.length) *
126
177
  100)
127
178
  : 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.4.39",
3
+ "version": "2.4.41",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",