ralph-hero-mcp-server 2.5.23 → 2.5.42

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.
@@ -166,6 +166,27 @@ export function createGitHubClient(clientConfig, debugLogger) {
166
166
  cache.set(cacheKey, login, 60 * 60 * 1000); // Cache for 1 hour
167
167
  return login;
168
168
  },
169
+ async restPost(path, body, useProjectToken = true) {
170
+ const token = useProjectToken
171
+ ? (clientConfig.projectToken ?? clientConfig.token)
172
+ : clientConfig.token;
173
+ const url = `https://api.github.com${path}`;
174
+ const response = await fetch(url, {
175
+ method: "POST",
176
+ headers: {
177
+ Authorization: `token ${token}`,
178
+ Accept: "application/vnd.github+json",
179
+ "X-GitHub-Api-Version": "2022-11-28",
180
+ "Content-Type": "application/json",
181
+ },
182
+ body: JSON.stringify(body),
183
+ });
184
+ if (!response.ok) {
185
+ const text = await response.text().catch(() => "");
186
+ throw new Error(`GitHub REST API error ${response.status} for ${path}: ${text}`);
187
+ }
188
+ return response.json();
189
+ },
169
190
  };
170
191
  }
171
192
  //# sourceMappingURL=github-client.js.map
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { registerProjectManagementTools } from "./tools/project-management-tools
22
22
  import { registerHygieneTools } from "./tools/hygiene-tools.js";
23
23
  import { registerDebugTools } from "./tools/debug-tools.js";
24
24
  import { registerDecomposeTools } from "./tools/decompose-tools.js";
25
+ import { registerViewTools } from "./tools/view-tools.js";
25
26
  /**
26
27
  * Initialize the GitHub client from environment variables.
27
28
  */
@@ -301,6 +302,8 @@ async function main() {
301
302
  registerHygieneTools(server, client, fieldCache);
302
303
  // Decompose feature tool (cross-repo decomposition via .ralph-repos.yml)
303
304
  registerDecomposeTools(server, client, fieldCache);
305
+ // View management tools (REST API view creation)
306
+ registerViewTools(server, client, fieldCache);
304
307
  // Debug tools (only when RALPH_DEBUG=true)
305
308
  if (process.env.RALPH_DEBUG === 'true') {
306
309
  registerDebugTools(server, client);
package/dist/lib/cache.js CHANGED
@@ -87,10 +87,12 @@ export class FieldOptionCache {
87
87
  defaultProjectNumber;
88
88
  /**
89
89
  * Populate the cache from project field data.
90
+ * Handles both single-select fields (options) and iteration fields (configuration.iterations).
90
91
  */
91
92
  populate(projectNumber, projectId, fields) {
92
93
  const fieldMap = new Map();
93
94
  const fieldIdMap = new Map();
95
+ const iterationMap = new Map();
94
96
  for (const field of fields) {
95
97
  fieldIdMap.set(field.name, field.id);
96
98
  if (field.options) {
@@ -100,11 +102,28 @@ export class FieldOptionCache {
100
102
  }
101
103
  fieldMap.set(field.name, optionMap);
102
104
  }
105
+ if (field.configuration?.iterations) {
106
+ // Store iteration title -> ID in the options map for resolveOptionId compatibility
107
+ const iterOptionMap = new Map();
108
+ for (const iter of field.configuration.iterations) {
109
+ iterOptionMap.set(iter.title, iter.id);
110
+ }
111
+ for (const iter of field.configuration.completedIterations ?? []) {
112
+ iterOptionMap.set(iter.title, iter.id);
113
+ }
114
+ fieldMap.set(field.name, iterOptionMap);
115
+ // Store full iteration metadata for @current/@next resolution
116
+ iterationMap.set(field.name, [
117
+ ...field.configuration.iterations,
118
+ ...(field.configuration.completedIterations ?? []),
119
+ ]);
120
+ }
103
121
  }
104
122
  this.projects.set(projectNumber, {
105
123
  projectId,
106
124
  fields: fieldMap,
107
125
  fieldIds: fieldIdMap,
126
+ iterations: iterationMap,
108
127
  });
109
128
  if (this.defaultProjectNumber === undefined) {
110
129
  this.defaultProjectNumber = projectNumber;
@@ -158,6 +177,15 @@ export class FieldOptionCache {
158
177
  const entry = this.resolveEntry(projectNumber);
159
178
  return entry ? Array.from(entry.fieldIds.keys()) : [];
160
179
  }
180
+ /**
181
+ * Get iteration data for a given field name.
182
+ * Returns all iterations (active + completed) with full metadata
183
+ * for @current/@next token resolution.
184
+ */
185
+ getIterations(fieldName, projectNumber) {
186
+ const entry = this.resolveEntry(projectNumber);
187
+ return entry?.iterations.get(fieldName);
188
+ }
161
189
  /**
162
190
  * Clear the cache (all projects).
163
191
  */
@@ -369,6 +369,65 @@ export function computeStreamSection(streams, items) {
369
369
  return { streams: summaries };
370
370
  }
371
371
  // ---------------------------------------------------------------------------
372
+ // Iteration section
373
+ // ---------------------------------------------------------------------------
374
+ /**
375
+ * Build per-iteration breakdown: group items by iteration, count phases
376
+ * per iteration. Only includes items that have an iteration assignment.
377
+ * Returns undefined if no items have iteration data.
378
+ */
379
+ export function buildIterationSection(items) {
380
+ const iterItems = items.filter((i) => i.iterationId && i.iterationTitle);
381
+ if (iterItems.length === 0)
382
+ return undefined;
383
+ // Group by iteration title
384
+ const groups = new Map();
385
+ for (const item of iterItems) {
386
+ const title = item.iterationTitle;
387
+ const existing = groups.get(title);
388
+ if (existing) {
389
+ existing.items.push(item);
390
+ }
391
+ else {
392
+ groups.set(title, {
393
+ items: [item],
394
+ startDate: item.iterationStartDate ?? "",
395
+ duration: item.iterationDuration ?? 0,
396
+ });
397
+ }
398
+ }
399
+ // Build breakdowns sorted by start date
400
+ const breakdowns = [];
401
+ for (const [title, group] of groups) {
402
+ // Count items per workflow state
403
+ const stateCounts = new Map();
404
+ for (const item of group.items) {
405
+ const state = item.workflowState ?? "Unknown";
406
+ stateCounts.set(state, (stateCounts.get(state) ?? 0) + 1);
407
+ }
408
+ const phaseCounts = [];
409
+ for (const [state, count] of stateCounts) {
410
+ phaseCounts.push({ state, count });
411
+ }
412
+ // Sort by STATE_ORDER position
413
+ const stateIdx = (s) => {
414
+ const idx = STATE_ORDER.indexOf(s);
415
+ return idx >= 0 ? idx : STATE_ORDER.length;
416
+ };
417
+ phaseCounts.sort((a, b) => stateIdx(a.state) - stateIdx(b.state));
418
+ breakdowns.push({
419
+ iterationTitle: title,
420
+ startDate: group.startDate,
421
+ duration: group.duration,
422
+ phaseCounts,
423
+ totalIssues: group.items.length,
424
+ });
425
+ }
426
+ // Sort by start date
427
+ breakdowns.sort((a, b) => a.startDate.localeCompare(b.startDate));
428
+ return { iterations: breakdowns };
429
+ }
430
+ // ---------------------------------------------------------------------------
372
431
  // buildDashboard
373
432
  // ---------------------------------------------------------------------------
374
433
  /**
@@ -449,6 +508,8 @@ export function buildDashboard(items, config = DEFAULT_HEALTH_CONFIG, now = Date
449
508
  if (streams && streams.length > 0) {
450
509
  streamSection = computeStreamSection(streams, items);
451
510
  }
511
+ // Iteration section (only when items have iteration assignments)
512
+ const iterationSection = buildIterationSection(items);
452
513
  return {
453
514
  generatedAt: new Date(now).toISOString(),
454
515
  totalIssues: items.length,
@@ -461,6 +522,7 @@ export function buildDashboard(items, config = DEFAULT_HEALTH_CONFIG, now = Date
461
522
  ...(projectBreakdowns ? { projectBreakdowns } : {}),
462
523
  ...(repoBreakdowns ? { repoBreakdowns } : {}),
463
524
  ...(streamSection ? { streams: streamSection } : {}),
525
+ ...(iterationSection ? { iterations: iterationSection } : {}),
464
526
  };
465
527
  }
466
528
  // ---------------------------------------------------------------------------
@@ -605,6 +667,27 @@ export function formatMarkdown(data, issuesPerPhase = 10) {
605
667
  }
606
668
  }
607
669
  }
670
+ // Iteration section
671
+ if (data.iterations && data.iterations.iterations.length > 0) {
672
+ lines.push("");
673
+ lines.push("## Iterations");
674
+ for (const iter of data.iterations.iterations) {
675
+ const endDate = iter.startDate && iter.duration
676
+ ? new Date(new Date(iter.startDate).getTime() + iter.duration * 86400000)
677
+ .toISOString().split("T")[0]
678
+ : "";
679
+ const dateRange = iter.startDate && endDate
680
+ ? ` (${iter.startDate} to ${endDate}, ${iter.duration}d)`
681
+ : "";
682
+ lines.push("");
683
+ lines.push(`### ${iter.iterationTitle}${dateRange}`);
684
+ lines.push("");
685
+ const phaseLine = iter.phaseCounts
686
+ .map((p) => `${p.state}: ${p.count}`)
687
+ .join(" | ");
688
+ lines.push(`${phaseLine} (${iter.totalIssues} total)`);
689
+ }
690
+ }
608
691
  // Stream section
609
692
  if (data.streams && data.streams.streams.length > 0) {
610
693
  lines.push("");
@@ -705,6 +788,17 @@ export function formatAscii(data) {
705
788
  }
706
789
  }
707
790
  }
791
+ // Iteration section
792
+ if (data.iterations && data.iterations.iterations.length > 0) {
793
+ lines.push("");
794
+ lines.push("--- Iterations ---");
795
+ for (const iter of data.iterations.iterations) {
796
+ const phaseLine = iter.phaseCounts
797
+ .map((p) => `${p.state}: ${p.count}`)
798
+ .join(", ");
799
+ lines.push(`${iter.iterationTitle}: ${phaseLine} (${iter.totalIssues} total)`);
800
+ }
801
+ }
708
802
  // Stream section
709
803
  if (data.streams && data.streams.streams.length > 0) {
710
804
  lines.push("");
@@ -29,6 +29,15 @@ async function fetchProjectForCache(client, owner, number) {
29
29
  dataType
30
30
  options { id name }
31
31
  }
32
+ ... on ProjectV2IterationField {
33
+ id
34
+ name
35
+ dataType
36
+ configuration {
37
+ iterations { id title startDate duration }
38
+ completedIterations { id title startDate duration }
39
+ }
40
+ }
32
41
  }
33
42
  }
34
43
  }
@@ -62,9 +71,45 @@ export async function ensureFieldCache(client, fieldCache, owner, projectNumber)
62
71
  id: f.id,
63
72
  name: f.name,
64
73
  options: f.options,
74
+ configuration: f.configuration,
65
75
  })));
66
76
  }
67
77
  // ---------------------------------------------------------------------------
78
+ // Helper: Resolve iteration title or @current/@next token to iteration ID
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * Resolve an iteration title string or special token (@current, @next)
82
+ * to its short iteration ID using cached iteration metadata.
83
+ *
84
+ * @param fieldCache - The populated FieldOptionCache
85
+ * @param projectNumber - The project number to look up
86
+ * @param fieldName - The iteration field name (e.g., "Sprint")
87
+ * @param titleOrToken - Iteration title, "@current", or "@next"
88
+ * @param now - Optional Date override for testing
89
+ * @returns The short iteration ID, or null if not found
90
+ */
91
+ export function resolveIterationId(fieldCache, projectNumber, fieldName, titleOrToken, now) {
92
+ const today = now ?? new Date();
93
+ const iterations = fieldCache.getIterations(fieldName, projectNumber);
94
+ if (!iterations)
95
+ return null;
96
+ if (titleOrToken === "@current") {
97
+ return iterations.find((it) => {
98
+ const start = new Date(it.startDate);
99
+ const end = new Date(start.getTime() + it.duration * 86400000);
100
+ return start <= today && today < end;
101
+ })?.id ?? null;
102
+ }
103
+ if (titleOrToken === "@next") {
104
+ const upcoming = iterations
105
+ .filter((it) => new Date(it.startDate) > today)
106
+ .sort((a, b) => a.startDate.localeCompare(b.startDate));
107
+ return upcoming[0]?.id ?? null;
108
+ }
109
+ // Direct title lookup
110
+ return iterations.find((it) => it.title === titleOrToken)?.id ?? null;
111
+ }
112
+ // ---------------------------------------------------------------------------
68
113
  // Helper: Resolve issue number to node ID (with caching)
69
114
  // ---------------------------------------------------------------------------
70
115
  export async function resolveIssueNodeId(client, owner, repo, number) {
@@ -56,6 +56,7 @@ export function buildBatchMutationQuery(projectId, updates) {
56
56
  const itemVar = `item_${update.alias}`;
57
57
  const fieldVar = `field_${update.alias}`;
58
58
  const optVar = `opt_${update.alias}`;
59
+ const valueKey = update.valueType ?? "singleSelectOptionId";
59
60
  varDecls.push(`$${itemVar}: ID!`, `$${fieldVar}: ID!`, `$${optVar}: String!`);
60
61
  variables[itemVar] = update.itemId;
61
62
  variables[fieldVar] = update.fieldId;
@@ -64,7 +65,7 @@ export function buildBatchMutationQuery(projectId, updates) {
64
65
  projectId: $projectId,
65
66
  itemId: $${itemVar},
66
67
  fieldId: $${fieldVar},
67
- value: { singleSelectOptionId: $${optVar} }
68
+ value: { ${valueKey}: $${optVar} }
68
69
  }) {
69
70
  projectV2Item { id }
70
71
  }`);
@@ -82,6 +82,8 @@ export function toDashboardItems(raw, projectNumber, projectTitle) {
82
82
  continue;
83
83
  if (r.content.number === undefined)
84
84
  continue;
85
+ // Extract iteration value (if any)
86
+ const iterFv = r.fieldValues.nodes.find((n) => n.__typename === "ProjectV2ItemFieldIterationValue");
85
87
  items.push({
86
88
  number: r.content.number,
87
89
  title: r.content.title ?? "(untitled)",
@@ -96,6 +98,12 @@ export function toDashboardItems(raw, projectNumber, projectTitle) {
96
98
  ...(projectNumber !== undefined ? { projectNumber } : {}),
97
99
  ...(projectTitle !== undefined ? { projectTitle } : {}),
98
100
  ...(r.content.repository ? { repository: r.content.repository.nameWithOwner } : {}),
101
+ ...(iterFv?.iterationId ? {
102
+ iterationId: iterFv.iterationId,
103
+ iterationTitle: iterFv.title ?? undefined,
104
+ iterationStartDate: iterFv.startDate ?? undefined,
105
+ iterationDuration: iterFv.duration ?? undefined,
106
+ } : {}),
99
107
  });
100
108
  }
101
109
  return items;
@@ -142,6 +150,14 @@ export const DASHBOARD_ITEMS_QUERY = `query($projectId: ID!, $cursor: String, $f
142
150
  name
143
151
  field { ... on ProjectV2FieldCommon { name } }
144
152
  }
153
+ ... on ProjectV2ItemFieldIterationValue {
154
+ __typename
155
+ iterationId
156
+ title
157
+ startDate
158
+ duration
159
+ field { ... on ProjectV2FieldCommon { name } }
160
+ }
145
161
  }
146
162
  }
147
163
  }
@@ -14,7 +14,7 @@ import { resolveState } from "../lib/state-resolution.js";
14
14
  import { parseDateMath } from "../lib/date-math.js";
15
15
  import { expandProfile } from "../lib/filter-profiles.js";
16
16
  import { toolSuccess, toolError } from "../types.js";
17
- import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, updateProjectItemField, resolveConfig, resolveFullConfig, resolveFullConfigOptionalRepo, syncStatusField, autoAdvanceParent, } from "../lib/helpers.js";
17
+ import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, updateProjectItemField, resolveConfig, resolveFullConfig, resolveFullConfigOptionalRepo, syncStatusField, autoAdvanceParent, resolveIterationId, } from "../lib/helpers.js";
18
18
  import { lookupRepo, mergeDefaults } from "../lib/repo-registry.js";
19
19
  // ---------------------------------------------------------------------------
20
20
  // Register issue tools
@@ -23,7 +23,7 @@ export function registerIssueTools(server, client, fieldCache) {
23
23
  // -------------------------------------------------------------------------
24
24
  // ralph_hero__list_issues
25
25
  // -------------------------------------------------------------------------
26
- server.tool("ralph_hero__list_issues", "List issues from a GitHub repository with optional filters. Returns: number, title, state, workflowState, estimate, priority, labels, assignees. Use workflowState filter to find issues in a specific phase. Recovery: if no results, broaden filters or check that issues exist in the project.", {
26
+ server.tool("ralph_hero__list_issues", "List issues from a GitHub repository with optional filters. Returns: number, title, state, workflowState, estimate, priority, iteration, labels, assignees. Use workflowState filter to find issues in a specific phase. Use iteration filter with @current/@next or sprint title. Recovery: if no results, broaden filters or check that issues exist in the project.", {
27
27
  owner: z
28
28
  .string()
29
29
  .optional()
@@ -51,6 +51,11 @@ export function registerIssueTools(server, client, fieldCache) {
51
51
  .string()
52
52
  .optional()
53
53
  .describe("Filter by Priority (P0, P1, P2, P3)"),
54
+ iteration: z
55
+ .string()
56
+ .optional()
57
+ .describe("Filter by iteration/sprint. Accepts iteration title (e.g., 'Sprint 1'), " +
58
+ "@current (active sprint), or @next (upcoming sprint)."),
54
59
  label: z.string().optional().describe("Filter by label name"),
55
60
  repoFilter: z
56
61
  .string()
@@ -166,6 +171,14 @@ export function registerIssueTools(server, client, fieldCache) {
166
171
  optionId
167
172
  field { ... on ProjectV2FieldCommon { name } }
168
173
  }
174
+ ... on ProjectV2ItemFieldIterationValue {
175
+ __typename
176
+ iterationId
177
+ title
178
+ startDate
179
+ duration
180
+ field { ... on ProjectV2FieldCommon { name } }
181
+ }
169
182
  }
170
183
  }
171
184
  }
@@ -202,6 +215,33 @@ export function registerIssueTools(server, client, fieldCache) {
202
215
  if (args.priority) {
203
216
  items = items.filter((item) => getFieldValue(item, "Priority") === args.priority);
204
217
  }
218
+ // Filter by iteration
219
+ if (args.iteration) {
220
+ // Discover the iteration field name from cache
221
+ const fieldNames = fieldCache.getFieldNames(projectNumber);
222
+ const iterFieldName = fieldNames.find((name) => {
223
+ const iters = fieldCache.getIterations(name, projectNumber);
224
+ return iters !== undefined && iters.length > 0;
225
+ });
226
+ if (iterFieldName) {
227
+ // Resolve the target iteration ID from title or token
228
+ const targetIterationId = resolveIterationId(fieldCache, projectNumber, iterFieldName, args.iteration);
229
+ if (targetIterationId) {
230
+ items = items.filter((item) => {
231
+ const iterVal = getIterationValue(item);
232
+ return iterVal?.iterationId === targetIterationId;
233
+ });
234
+ }
235
+ else {
236
+ // Token/title did not resolve - no items can match
237
+ items = [];
238
+ }
239
+ }
240
+ else {
241
+ // No iteration field configured - no items can match
242
+ items = [];
243
+ }
244
+ }
205
245
  // Filter by label
206
246
  if (args.label) {
207
247
  items = items.filter((item) => {
@@ -294,6 +334,7 @@ export function registerIssueTools(server, client, fieldCache) {
294
334
  // Format response
295
335
  const formattedItems = items.map((item) => {
296
336
  const content = item.content;
337
+ const iterVal = getIterationValue(item);
297
338
  return {
298
339
  number: content?.number,
299
340
  title: content?.title,
@@ -304,6 +345,9 @@ export function registerIssueTools(server, client, fieldCache) {
304
345
  workflowState: getFieldValue(item, "Workflow State"),
305
346
  estimate: getFieldValue(item, "Estimate"),
306
347
  priority: getFieldValue(item, "Priority"),
348
+ iteration: iterVal
349
+ ? { title: iterVal.title, startDate: iterVal.startDate, duration: iterVal.duration }
350
+ : null,
307
351
  labels: content?.labels?.nodes?.map((l) => l.name),
308
352
  assignees: content?.assignees?.nodes?.map((a) => a.login),
309
353
  };
@@ -762,10 +806,10 @@ export function registerIssueTools(server, client, fieldCache) {
762
806
  // ralph_hero__save_issue
763
807
  // -------------------------------------------------------------------------
764
808
  server.tool("ralph_hero__save_issue", "Unified issue mutation: update any combination of issue properties (title, body, labels, assignees, open/close) " +
765
- "and project field values (workflow state, estimate, priority) in a single call. " +
809
+ "and project field values (workflow state, estimate, priority, iteration) in a single call. " +
766
810
  "Supports semantic intents (__LOCK__, __COMPLETE__, etc.) for workflowState. " +
767
811
  "Auto-closes the GitHub issue when workflowState resolves to a terminal state (Done, Canceled) unless issueState is explicitly set. " +
768
- "Set estimate or priority to null to clear the field. " +
812
+ "Set estimate, priority, or iteration to null to clear the field. Use @current/@next tokens for iteration. " +
769
813
  "Returns: number, url, changes.", {
770
814
  owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
771
815
  repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
@@ -785,6 +829,8 @@ export function registerIssueTools(server, client, fieldCache) {
785
829
  .describe("Estimate. Set to null to clear."),
786
830
  priority: z.enum(["P0", "P1", "P2", "P3"]).nullable().optional()
787
831
  .describe("Priority. Set to null to clear."),
832
+ iteration: z.string().nullable().optional()
833
+ .describe("Iteration/sprint title (e.g., 'Sprint 1'), @current, @next, or null to clear."),
788
834
  command: z.string().optional()
789
835
  .describe("Ralph command for semantic intent resolution (e.g., 'ralph_impl'). Required when workflowState is a semantic intent."),
790
836
  }, async (args) => {
@@ -793,7 +839,8 @@ export function registerIssueTools(server, client, fieldCache) {
793
839
  const hasIssueFields = args.title !== undefined || args.body !== undefined ||
794
840
  args.labels !== undefined || args.assignees !== undefined || args.issueState !== undefined;
795
841
  const hasProjectFields = args.workflowState !== undefined ||
796
- args.estimate !== undefined || args.priority !== undefined;
842
+ args.estimate !== undefined || args.priority !== undefined ||
843
+ args.iteration !== undefined;
797
844
  if (!hasIssueFields && !hasProjectFields) {
798
845
  return toolError("No fields to update. Provide at least one field.");
799
846
  }
@@ -1007,12 +1054,45 @@ export function registerIssueTools(server, client, fieldCache) {
1007
1054
  changes.priority = args.priority;
1008
1055
  }
1009
1056
  }
1010
- // 4d. Execute aliased batch mutation for non-null field updates
1057
+ // 4d. Iteration (set, resolve token, or clear)
1058
+ if (args.iteration !== undefined) {
1059
+ const fieldNames = fieldCache.getFieldNames(projectNumber);
1060
+ const iterFieldName = fieldNames.find((name) => {
1061
+ const iters = fieldCache.getIterations(name, projectNumber);
1062
+ return iters !== undefined && iters.length > 0;
1063
+ });
1064
+ if (iterFieldName) {
1065
+ const fieldId = fieldCache.getFieldId(iterFieldName, projectNumber);
1066
+ if (args.iteration === null) {
1067
+ // Clear the iteration field
1068
+ if (fieldId) {
1069
+ fieldsToClear.push({ fieldName: iterFieldName, fieldId });
1070
+ }
1071
+ changes.iteration = null;
1072
+ }
1073
+ else {
1074
+ // Set iteration by title or token (@current, @next)
1075
+ const iterationId = resolveIterationId(fieldCache, projectNumber, iterFieldName, args.iteration);
1076
+ if (fieldId && iterationId) {
1077
+ updates.push({
1078
+ alias: `iter_${opIdx}`,
1079
+ itemId: projectItemId,
1080
+ fieldId,
1081
+ optionId: iterationId,
1082
+ valueType: "iterationId",
1083
+ });
1084
+ opIdx++;
1085
+ }
1086
+ changes.iteration = args.iteration;
1087
+ }
1088
+ }
1089
+ }
1090
+ // 4e. Execute aliased batch mutation for non-null field updates
1011
1091
  if (updates.length > 0) {
1012
1092
  const { mutationString, variables } = buildBatchMutationQuery(projectId, updates);
1013
1093
  await client.projectMutate(mutationString, variables);
1014
1094
  }
1015
- // 4e. Execute clear mutations for null fields (separate calls)
1095
+ // 4f. Execute clear mutations for null fields (separate calls)
1016
1096
  for (const { fieldId } of fieldsToClear) {
1017
1097
  await client.projectMutate(`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) {
1018
1098
  clearProjectV2ItemFieldValue(input: {
@@ -1024,7 +1104,7 @@ export function registerIssueTools(server, client, fieldCache) {
1024
1104
  }
1025
1105
  }`, { projectId, itemId: projectItemId, fieldId });
1026
1106
  }
1027
- // 4f. Auto-advance parent if we just moved to a gate state
1107
+ // 4g. Auto-advance parent if we just moved to a gate state
1028
1108
  if (resolvedWorkflowState && isParentGateState(resolvedWorkflowState)) {
1029
1109
  try {
1030
1110
  const advanceResult = await autoAdvanceParent(client, fieldCache, owner, repo, args.number, resolvedWorkflowState, projectNumber);
@@ -1283,6 +1363,19 @@ function getFieldValue(item, fieldName) {
1283
1363
  fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
1284
1364
  return fieldValue?.name;
1285
1365
  }
1366
+ function getIterationValue(item) {
1367
+ const fv = item.fieldValues.nodes.find((fv) => fv.__typename === "ProjectV2ItemFieldIterationValue");
1368
+ if (fv?.iterationId && fv.title && fv.startDate != null && fv.duration != null && fv.field?.name) {
1369
+ return {
1370
+ iterationId: fv.iterationId,
1371
+ title: fv.title,
1372
+ startDate: fv.startDate,
1373
+ duration: fv.duration,
1374
+ fieldName: fv.field.name,
1375
+ };
1376
+ }
1377
+ return undefined;
1378
+ }
1286
1379
  function hasField(item, field) {
1287
1380
  switch (field) {
1288
1381
  case "workflowState":
@@ -8,7 +8,6 @@
8
8
  */
9
9
  import { z } from "zod";
10
10
  import { toolSuccess, toolError } from "../types.js";
11
- import { paginateConnection } from "../lib/pagination.js";
12
11
  import { buildBatchArchiveMutation } from "./batch-tools.js";
13
12
  import { ensureFieldCache, resolveProjectItemId, resolveFullConfig, updateProjectItemField, } from "../lib/helpers.js";
14
13
  // ---------------------------------------------------------------------------
@@ -489,64 +488,79 @@ export function registerProjectManagementTools(server, client, fieldCache) {
489
488
  return toolError("Could not resolve project ID");
490
489
  }
491
490
  const effectiveMax = Math.min(args.maxItems || 50, 200);
492
- // Query project items with field values
493
- const itemsResult = await paginateConnection((q, v) => client.projectQuery(q, v), `query($projectId: ID!, $cursor: String, $first: Int!) {
494
- node(id: $projectId) {
495
- ... on ProjectV2 {
496
- items(first: $first, after: $cursor) {
497
- totalCount
498
- pageInfo { hasNextPage endCursor }
499
- nodes {
500
- id
501
- type
502
- content {
503
- ... on Issue {
504
- number
505
- title
506
- updatedAt
507
- }
508
- ... on PullRequest {
509
- number
510
- title
511
- updatedAt
491
+ const SCAN_CAP = 2000; // Hard limit to prevent runaway pagination
492
+ // Validate updatedBefore early (before scan loop)
493
+ let updatedBeforeCutoff;
494
+ if (args.updatedBefore) {
495
+ updatedBeforeCutoff = new Date(args.updatedBefore).getTime();
496
+ if (isNaN(updatedBeforeCutoff)) {
497
+ return toolError("Invalid updatedBefore date. Use ISO 8601 format (e.g., 2026-02-01T00:00:00Z)");
498
+ }
499
+ }
500
+ // Scan-until-full: fetch pages and filter until we have enough matches or exhaust items
501
+ const matched = [];
502
+ let cursor = null;
503
+ let totalScanned = 0;
504
+ let hasMorePages = true;
505
+ while (matched.length < effectiveMax && hasMorePages && totalScanned < SCAN_CAP) {
506
+ const pageSize = Math.min(100, SCAN_CAP - totalScanned);
507
+ const page = await client.projectQuery(`query($projectId: ID!, $cursor: String, $first: Int!) {
508
+ node(id: $projectId) {
509
+ ... on ProjectV2 {
510
+ items(first: $first, after: $cursor) {
511
+ totalCount
512
+ pageInfo { hasNextPage endCursor }
513
+ nodes {
514
+ id
515
+ type
516
+ content {
517
+ ... on Issue {
518
+ number
519
+ title
520
+ updatedAt
521
+ }
522
+ ... on PullRequest {
523
+ number
524
+ title
525
+ updatedAt
526
+ }
512
527
  }
513
- }
514
- fieldValues(first: 20) {
515
- nodes {
516
- ... on ProjectV2ItemFieldSingleSelectValue {
517
- __typename
518
- name
519
- field { ... on ProjectV2FieldCommon { name } }
528
+ fieldValues(first: 20) {
529
+ nodes {
530
+ ... on ProjectV2ItemFieldSingleSelectValue {
531
+ __typename
532
+ name
533
+ field { ... on ProjectV2FieldCommon { name } }
534
+ }
520
535
  }
521
536
  }
522
537
  }
523
538
  }
524
539
  }
525
540
  }
526
- }
527
- }`, { projectId, first: 100 }, "node.items", { maxItems: effectiveMax * 3 });
528
- // Validate updatedBefore if provided
529
- let updatedBeforeCutoff;
530
- if (args.updatedBefore) {
531
- updatedBeforeCutoff = new Date(args.updatedBefore).getTime();
532
- if (isNaN(updatedBeforeCutoff)) {
533
- return toolError("Invalid updatedBefore date. Use ISO 8601 format (e.g., 2026-02-01T00:00:00Z)");
541
+ }`, { projectId, first: pageSize, cursor });
542
+ const connection = page.node;
543
+ const items = connection.items;
544
+ totalScanned += items.nodes.length;
545
+ for (const item of items.nodes) {
546
+ if (matched.length >= effectiveMax)
547
+ break;
548
+ const ws = getBulkArchiveFieldValue(item, "Workflow State");
549
+ if (!ws || !args.workflowStates.includes(ws))
550
+ continue;
551
+ if (updatedBeforeCutoff) {
552
+ if (!item.content?.updatedAt)
553
+ continue;
554
+ if (new Date(item.content.updatedAt).getTime() >= updatedBeforeCutoff)
555
+ continue;
556
+ }
557
+ matched.push(item);
534
558
  }
559
+ hasMorePages = items.pageInfo.hasNextPage && !!items.pageInfo.endCursor;
560
+ cursor = items.pageInfo.endCursor;
535
561
  }
536
- // Filter by workflow state and optional date
537
- const matched = itemsResult.nodes
538
- .filter((item) => {
539
- const ws = getBulkArchiveFieldValue(item, "Workflow State");
540
- return ws && args.workflowStates.includes(ws);
541
- })
542
- .filter((item) => {
543
- if (!updatedBeforeCutoff)
544
- return true;
545
- if (!item.content?.updatedAt)
546
- return false;
547
- return new Date(item.content.updatedAt).getTime() < updatedBeforeCutoff;
548
- })
549
- .slice(0, effectiveMax);
562
+ // Determine if more eligible items may exist beyond what we collected
563
+ const hasMore = matched.length >= effectiveMax && hasMorePages;
550
564
  if (matched.length === 0) {
551
565
  return toolSuccess({
552
566
  dryRun: args.dryRun,
@@ -554,6 +568,8 @@ export function registerProjectManagementTools(server, client, fieldCache) {
554
568
  wouldArchive: 0,
555
569
  items: [],
556
570
  errors: [],
571
+ hasMore: false,
572
+ totalScanned,
557
573
  });
558
574
  }
559
575
  // Dry run: return matched items without archiving
@@ -567,6 +583,8 @@ export function registerProjectManagementTools(server, client, fieldCache) {
567
583
  itemId: m.id,
568
584
  })),
569
585
  errors: [],
586
+ hasMore,
587
+ totalScanned,
570
588
  });
571
589
  }
572
590
  // Chunk and execute archive mutations
@@ -598,6 +616,8 @@ export function registerProjectManagementTools(server, client, fieldCache) {
598
616
  archivedCount: archived.length,
599
617
  items: archived,
600
618
  errors,
619
+ hasMore,
620
+ totalScanned,
601
621
  });
602
622
  }
603
623
  catch (error) {
@@ -99,7 +99,7 @@ export function registerProjectTools(server, client, fieldCache) {
99
99
  // -------------------------------------------------------------------------
100
100
  // ralph_hero__setup_project
101
101
  // -------------------------------------------------------------------------
102
- server.tool("ralph_hero__setup_project", "Create a new GitHub Project V2 with Workflow State, Priority, and Estimate custom fields", {
102
+ server.tool("ralph_hero__setup_project", "Create a new GitHub Project V2 with Workflow State, Priority, Estimate, and optional Sprint iteration fields", {
103
103
  owner: z.string().describe("GitHub owner (user or org)"),
104
104
  title: z.string().describe("Project title").default("Ralph Workflow"),
105
105
  templateProjectNumber: z
@@ -107,6 +107,12 @@ export function registerProjectTools(server, client, fieldCache) {
107
107
  .optional()
108
108
  .describe("Template project number to copy from. Overrides RALPH_GH_TEMPLATE_PROJECT env var. " +
109
109
  "When set, copies the template project (views, fields, automations) instead of creating blank."),
110
+ createIterationField: z
111
+ .boolean()
112
+ .optional()
113
+ .default(false)
114
+ .describe('When true, creates a "Sprint" iteration field with 2-week duration starting next Monday. ' +
115
+ "Only applies to blank project creation (ignored when using template)."),
110
116
  }, async (args) => {
111
117
  try {
112
118
  const owner = args.owner || resolveProjectOwner(client.config);
@@ -203,6 +209,14 @@ export function registerProjectTools(server, client, fieldCache) {
203
209
  // Estimate field
204
210
  const estField = await createSingleSelectField(client, project.id, "Estimate", ESTIMATE_OPTIONS);
205
211
  fieldResults["Estimate"] = estField;
212
+ // Optional: Sprint iteration field
213
+ if (args.createIterationField) {
214
+ const iterField = await createIterationField(client, project.id, "Sprint", 14);
215
+ fieldResults["Sprint"] = {
216
+ id: iterField.id,
217
+ options: [`Sprint 1 (${iterField.startDate}, ${iterField.durationDays}d)`],
218
+ };
219
+ }
206
220
  }
207
221
  // Shared: cache hydration (both paths)
208
222
  await ensureFieldCacheForNewProject(client, fieldCache, owner, project.number);
@@ -372,6 +386,52 @@ async function fetchProject(client, owner, number) {
372
386
  }
373
387
  return null;
374
388
  }
389
+ const VIEWS_QUERY_USER = `
390
+ query($login: String!, $number: Int!) {
391
+ user(login: $login) {
392
+ projectV2(number: $number) {
393
+ views(first: 50) {
394
+ nodes { id name number layout filter }
395
+ }
396
+ }
397
+ }
398
+ }
399
+ `;
400
+ const VIEWS_QUERY_ORG = `
401
+ query($login: String!, $number: Int!) {
402
+ organization(login: $login) {
403
+ projectV2(number: $number) {
404
+ views(first: 50) {
405
+ nodes { id name number layout filter }
406
+ }
407
+ }
408
+ }
409
+ }
410
+ `;
411
+ /**
412
+ * Fetch project views via GraphQL with user→org fallback.
413
+ * Returns views AND the resolved ownerType so callers can construct
414
+ * the correct REST API path without a separate round-trip.
415
+ */
416
+ export async function fetchProjectViews(client, owner, projectNumber) {
417
+ // Try user first
418
+ try {
419
+ const result = await client.projectQuery(VIEWS_QUERY_USER, { login: owner, number: projectNumber });
420
+ const nodes = result.user?.projectV2?.views?.nodes;
421
+ if (nodes)
422
+ return { views: nodes, ownerType: "users" };
423
+ }
424
+ catch {
425
+ // fall through to org
426
+ }
427
+ // Try org
428
+ const result = await client.projectQuery(VIEWS_QUERY_ORG, { login: owner, number: projectNumber });
429
+ const nodes = result.organization?.projectV2?.views?.nodes;
430
+ if (!nodes) {
431
+ throw new Error(`Project #${projectNumber} not found for owner "${owner}"`);
432
+ }
433
+ return { views: nodes, ownerType: "orgs" };
434
+ }
375
435
  async function createSingleSelectField(client, projectId, fieldName, options) {
376
436
  const result = await client.projectMutate(`mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) {
377
437
  createProjectV2Field(input: {
@@ -410,6 +470,57 @@ async function ensureFieldCacheForNewProject(client, fieldCache, owner, number)
410
470
  client.getCache().invalidatePrefix("query:");
411
471
  await ensureFieldCache(client, fieldCache, owner, number);
412
472
  }
473
+ /**
474
+ * Compute the next Monday on or after a given date.
475
+ * Used to set a sensible default start date for new iteration fields.
476
+ */
477
+ function getNextMonday(from = new Date()) {
478
+ const d = new Date(from);
479
+ const day = d.getDay(); // 0=Sun, 1=Mon, ...
480
+ const daysUntilMonday = day === 0 ? 1 : day === 1 ? 0 : 8 - day;
481
+ d.setDate(d.getDate() + daysUntilMonday);
482
+ return d.toISOString().slice(0, 10); // YYYY-MM-DD
483
+ }
484
+ async function createIterationField(client, projectId, name = "Sprint", durationDays = 14, startDate) {
485
+ const start = startDate || getNextMonday();
486
+ const result = await client.projectMutate(`mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $config: ProjectV2IterationFieldConfigurationInput!) {
487
+ createProjectV2Field(input: {
488
+ projectId: $projectId,
489
+ dataType: $dataType,
490
+ name: $name,
491
+ iterationConfiguration: $config
492
+ }) {
493
+ projectV2Field {
494
+ ... on ProjectV2IterationField {
495
+ id
496
+ name
497
+ configuration {
498
+ iterations { startDate duration }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }`, {
504
+ projectId,
505
+ name,
506
+ dataType: "ITERATION",
507
+ config: {
508
+ duration: durationDays,
509
+ startDate: start,
510
+ iterations: [
511
+ { startDate: start, duration: durationDays },
512
+ ],
513
+ },
514
+ });
515
+ const field = result.createProjectV2Field.projectV2Field;
516
+ const firstIter = field.configuration?.iterations?.[0];
517
+ return {
518
+ id: field.id,
519
+ name: field.name || name,
520
+ startDate: firstIter?.startDate ?? start,
521
+ durationDays: firstIter?.duration ?? durationDays,
522
+ };
523
+ }
413
524
  async function linkRepoAfterSetup(client, projectId, repoOwner, repoName) {
414
525
  const repoResult = await client.query(`query($repoOwner: String!, $repoName: String!) {
415
526
  repository(owner: $repoOwner, name: $repoName) { id }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * MCP tools for GitHub Projects V2 view management.
3
+ *
4
+ * Copies views from a source project (read via GraphQL) to a target
5
+ * project using the REST API POST endpoint.
6
+ */
7
+ import { z } from "zod";
8
+ import { toolError, toolSuccess } from "../types.js";
9
+ import { fetchProjectViews } from "./project-tools.js";
10
+ /**
11
+ * Convert GraphQL layout enum to REST API layout value.
12
+ * All three variants are handled exhaustively — TypeScript will error
13
+ * at build time if a new layout variant is added and not handled here.
14
+ */
15
+ export function toRestLayout(layout) {
16
+ switch (layout) {
17
+ case "TABLE_LAYOUT":
18
+ return "table";
19
+ case "BOARD_LAYOUT":
20
+ return "board";
21
+ case "ROADMAP_LAYOUT":
22
+ return "roadmap";
23
+ }
24
+ }
25
+ export function registerViewTools(server, client, _fieldCache) {
26
+ server.tool("ralph_hero__create_views", "Copy views from a source GitHub Project V2 to a target project using the REST API. Reads view names, layouts, and filter strings from the source project via GraphQL, then creates matching views in the target. Note: sort/group configuration is not available via API and must be set manually after creation.", {
27
+ owner: z
28
+ .string()
29
+ .optional()
30
+ .describe("GitHub owner (user or org). Defaults to RALPH_GH_OWNER env var"),
31
+ sourceProjectNumber: z.coerce
32
+ .number()
33
+ .describe("Project number to copy views FROM"),
34
+ targetProjectNumber: z.coerce
35
+ .number()
36
+ .describe("Project number to copy views INTO"),
37
+ }, async (args) => {
38
+ const owner = args.owner ?? client.config.projectOwner ?? client.config.owner;
39
+ if (!owner) {
40
+ return toolError("owner is required — set RALPH_GH_OWNER or pass owner param");
41
+ }
42
+ // Read views from source project; ownerType drives REST path selection
43
+ let sourceViews;
44
+ let ownerType;
45
+ try {
46
+ const result = await fetchProjectViews(client, owner, args.sourceProjectNumber);
47
+ sourceViews = result.views;
48
+ ownerType = result.ownerType;
49
+ }
50
+ catch (err) {
51
+ return toolError(`Failed to read views from project #${args.sourceProjectNumber}: ${err instanceof Error ? err.message : String(err)}`);
52
+ }
53
+ if (sourceViews.length === 0) {
54
+ return toolSuccess({
55
+ created: [],
56
+ failed: [],
57
+ count: 0,
58
+ sourceProject: args.sourceProjectNumber,
59
+ targetProject: args.targetProjectNumber,
60
+ message: "Source project has no views",
61
+ });
62
+ }
63
+ // REST path uses owner login (not numeric ID).
64
+ // filter is a plain top-level string matching the GraphQL field value.
65
+ const basePath = ownerType === "users"
66
+ ? `/users/${owner}/projectsV2/${args.targetProjectNumber}/views`
67
+ : `/orgs/${owner}/projectsV2/${args.targetProjectNumber}/views`;
68
+ const created = [];
69
+ const failed = [];
70
+ for (const view of sourceViews) {
71
+ const body = {
72
+ name: view.name,
73
+ layout: toRestLayout(view.layout),
74
+ };
75
+ if (view.filter) {
76
+ body.filter = view.filter;
77
+ }
78
+ try {
79
+ const createdView = await client.restPost(basePath, body);
80
+ created.push({
81
+ name: createdView.name,
82
+ layout: createdView.layout,
83
+ id: createdView.id,
84
+ });
85
+ }
86
+ catch (err) {
87
+ failed.push({
88
+ name: view.name,
89
+ error: err instanceof Error ? err.message : String(err),
90
+ });
91
+ }
92
+ }
93
+ return toolSuccess({
94
+ created,
95
+ failed,
96
+ count: created.length,
97
+ sourceProject: args.sourceProjectNumber,
98
+ targetProject: args.targetProjectNumber,
99
+ });
100
+ });
101
+ }
102
+ //# sourceMappingURL=view-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.23",
3
+ "version": "2.5.42",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",