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.
- package/dist/github-client.js +21 -0
- package/dist/index.js +3 -0
- package/dist/lib/cache.js +28 -0
- package/dist/lib/dashboard.js +94 -0
- package/dist/lib/helpers.js +45 -0
- package/dist/tools/batch-tools.js +2 -1
- package/dist/tools/dashboard-tools.js +16 -0
- package/dist/tools/issue-tools.js +101 -8
- package/dist/tools/project-management-tools.js +70 -50
- package/dist/tools/project-tools.js +112 -1
- package/dist/tools/view-tools.js +102 -0
- package/package.json +1 -1
package/dist/github-client.js
CHANGED
|
@@ -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
|
*/
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -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("");
|
package/dist/lib/helpers.js
CHANGED
|
@@ -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: {
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
//
|
|
537
|
-
const
|
|
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
|
|
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
|