ralph-hero-mcp-server 2.5.24 → 2.5.50
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 +6 -0
- package/dist/lib/plan-graph.js +152 -0
- package/dist/lib/state-resolution.js +1 -0
- package/dist/tools/plan-graph-tools.js +204 -0
- package/dist/tools/project-tools.js +46 -0
- 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,8 @@ 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";
|
|
26
|
+
import { registerPlanGraphTools } from "./tools/plan-graph-tools.js";
|
|
25
27
|
/**
|
|
26
28
|
* Initialize the GitHub client from environment variables.
|
|
27
29
|
*/
|
|
@@ -301,6 +303,10 @@ async function main() {
|
|
|
301
303
|
registerHygieneTools(server, client, fieldCache);
|
|
302
304
|
// Decompose feature tool (cross-repo decomposition via .ralph-repos.yml)
|
|
303
305
|
registerDecomposeTools(server, client, fieldCache);
|
|
306
|
+
// View management tools (REST API view creation)
|
|
307
|
+
registerViewTools(server, client, fieldCache);
|
|
308
|
+
// Plan graph sync tool (sync plan dependency edges to GitHub)
|
|
309
|
+
registerPlanGraphTools(server, client);
|
|
304
310
|
// Debug tools (only when RALPH_DEBUG=true)
|
|
305
311
|
if (process.env.RALPH_DEBUG === 'true') {
|
|
306
312
|
registerDebugTools(server, client);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan dependency graph parser.
|
|
3
|
+
*
|
|
4
|
+
* Extracts issue-level dependency edges from plan markdown documents.
|
|
5
|
+
* Pure function — no I/O, no GitHub calls.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Parse frontmatter from `---` fenced block at the start of content.
|
|
9
|
+
* Returns key-value pairs as raw strings.
|
|
10
|
+
*/
|
|
11
|
+
function parseFrontmatter(content) {
|
|
12
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
13
|
+
if (!match)
|
|
14
|
+
return {};
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const line of match[1].split("\n")) {
|
|
17
|
+
const colonIdx = line.indexOf(":");
|
|
18
|
+
if (colonIdx === -1)
|
|
19
|
+
continue;
|
|
20
|
+
const key = line.slice(0, colonIdx).trim();
|
|
21
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
22
|
+
result[key] = value;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse a YAML-style inline array of numbers, e.g. `[660, 661, 662]`.
|
|
28
|
+
*/
|
|
29
|
+
function parseNumberArray(raw) {
|
|
30
|
+
const inner = raw.replace(/^\[/, "").replace(/\]$/, "");
|
|
31
|
+
if (!inner.trim())
|
|
32
|
+
return [];
|
|
33
|
+
return inner.split(",").map((s) => Number(s.trim()));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse a depends_on value like `[phase-1, phase-2]` or `[GH-44]` or `null`.
|
|
37
|
+
* Returns raw string references (e.g. "phase-1", "GH-44") or empty array for null/absent.
|
|
38
|
+
*/
|
|
39
|
+
function parseDependsOn(raw) {
|
|
40
|
+
const trimmed = raw.trim();
|
|
41
|
+
if (trimmed === "null" || trimmed === "")
|
|
42
|
+
return [];
|
|
43
|
+
const inner = trimmed.replace(/^\[/, "").replace(/\]$/, "");
|
|
44
|
+
if (!inner.trim())
|
|
45
|
+
return [];
|
|
46
|
+
return inner.split(",").map((s) => s.trim());
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse a plan markdown document and extract the dependency graph.
|
|
50
|
+
*/
|
|
51
|
+
export function parsePlanGraph(content) {
|
|
52
|
+
const frontmatter = parseFrontmatter(content);
|
|
53
|
+
const type = (frontmatter.type ?? "plan");
|
|
54
|
+
const issues = parseNumberArray(frontmatter.github_issues ?? "[]");
|
|
55
|
+
const primaryIssue = Number(frontmatter.primary_issue ?? "0");
|
|
56
|
+
const phaseToIssue = new Map();
|
|
57
|
+
const edges = [];
|
|
58
|
+
const lines = content.split("\n");
|
|
59
|
+
if (type === "plan") {
|
|
60
|
+
// Scan for ## Phase N: ... (GH-NNN) headings
|
|
61
|
+
const phasePattern = /^## Phase (\d+):.*\(GH-(\d+)\)/;
|
|
62
|
+
// First pass: build phaseToIssue map
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const m = line.match(phasePattern);
|
|
65
|
+
if (m) {
|
|
66
|
+
phaseToIssue.set(Number(m[1]), Number(m[2]));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Second pass: find depends_on after each phase heading
|
|
70
|
+
let currentPhaseIssue = null;
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const phaseMatch = line.match(phasePattern);
|
|
73
|
+
if (phaseMatch) {
|
|
74
|
+
currentPhaseIssue = Number(phaseMatch[2]);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Check for heading that would end the current phase section
|
|
78
|
+
if (/^##\s/.test(line) && !phaseMatch) {
|
|
79
|
+
currentPhaseIssue = null;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (currentPhaseIssue !== null) {
|
|
83
|
+
const depMatch = line.match(/^\s*-\s+\*\*depends_on\*\*:\s*(.*)/);
|
|
84
|
+
if (depMatch) {
|
|
85
|
+
const refs = parseDependsOn(depMatch[1]);
|
|
86
|
+
for (const ref of refs) {
|
|
87
|
+
const phaseRef = ref.match(/^phase-(\d+)$/);
|
|
88
|
+
const ghRef = ref.match(/^GH-(\d+)$/);
|
|
89
|
+
if (phaseRef) {
|
|
90
|
+
const blockingIssue = phaseToIssue.get(Number(phaseRef[1]));
|
|
91
|
+
if (blockingIssue !== undefined) {
|
|
92
|
+
edges.push({
|
|
93
|
+
blocked: currentPhaseIssue,
|
|
94
|
+
blocking: blockingIssue,
|
|
95
|
+
source: "phase-level",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (ghRef) {
|
|
100
|
+
edges.push({
|
|
101
|
+
blocked: currentPhaseIssue,
|
|
102
|
+
blocking: Number(ghRef[1]),
|
|
103
|
+
source: "phase-level",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// plan-of-plans: scan for ### Feature ...: ... (GH-NNN) headings
|
|
113
|
+
const featurePattern = /^### Feature [^:]+:.*\(GH-(\d+)\)/;
|
|
114
|
+
let currentFeatureIssue = null;
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
const featureMatch = line.match(featurePattern);
|
|
117
|
+
if (featureMatch) {
|
|
118
|
+
currentFeatureIssue = Number(featureMatch[1]);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Check for any heading (h2 or h3) that would end the current feature section
|
|
122
|
+
if (/^##\s/.test(line) && !featureMatch) {
|
|
123
|
+
currentFeatureIssue = null;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (currentFeatureIssue !== null) {
|
|
127
|
+
const depMatch = line.match(/^\s*-\s+\*\*depends_on\*\*:\s*(.*)/);
|
|
128
|
+
if (depMatch) {
|
|
129
|
+
const refs = parseDependsOn(depMatch[1]);
|
|
130
|
+
for (const ref of refs) {
|
|
131
|
+
const ghRef = ref.match(/^GH-(\d+)$/);
|
|
132
|
+
if (ghRef) {
|
|
133
|
+
edges.push({
|
|
134
|
+
blocked: currentFeatureIssue,
|
|
135
|
+
blocking: Number(ghRef[1]),
|
|
136
|
+
source: "feature-level",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
type,
|
|
146
|
+
issues,
|
|
147
|
+
primaryIssue,
|
|
148
|
+
phaseToIssue,
|
|
149
|
+
edges,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=plan-graph.js.map
|
|
@@ -41,6 +41,7 @@ const COMMAND_ALLOWED_STATES = {
|
|
|
41
41
|
ralph_research: ["Research in Progress", "Ready for Plan", "Human Needed"],
|
|
42
42
|
ralph_plan: ["Plan in Progress", "Plan in Review", "In Progress", "Human Needed"],
|
|
43
43
|
ralph_plan_epic: ["Plan in Progress", "In Progress", "Human Needed"],
|
|
44
|
+
ralph_pr: ["In Review", "Human Needed"],
|
|
44
45
|
ralph_impl: ["In Progress", "In Review", "Human Needed"],
|
|
45
46
|
ralph_review: ["In Progress", "Ready for Plan", "Human Needed"],
|
|
46
47
|
ralph_hero: ["In Review", "Human Needed"],
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool for syncing plan dependency graphs to GitHub.
|
|
3
|
+
*
|
|
4
|
+
* Reads a plan markdown document, extracts dependency edges via parsePlanGraph,
|
|
5
|
+
* diffs against existing GitHub blockedBy edges, and adds/removes edges to
|
|
6
|
+
* converge the graph.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { readFile } from "fs/promises";
|
|
10
|
+
import { parsePlanGraph } from "../lib/plan-graph.js";
|
|
11
|
+
import { toolSuccess, toolError } from "../types.js";
|
|
12
|
+
import { resolveIssueNodeId, resolveConfig } from "../lib/helpers.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Pure function: diffDependencyEdges
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/**
|
|
17
|
+
* Compute the diff between declared edges (from the plan) and existing edges
|
|
18
|
+
* (from GitHub), scoped to plan issues only.
|
|
19
|
+
*
|
|
20
|
+
* - added: edges in declared but not in existing
|
|
21
|
+
* - removed: edges in existing but not in declared, where BOTH endpoints are plan issues
|
|
22
|
+
* - unchanged: edges present in both declared and existing
|
|
23
|
+
*
|
|
24
|
+
* External edges (where one endpoint is outside the plan) are left alone.
|
|
25
|
+
*
|
|
26
|
+
* @param declared - Edges parsed from the plan document
|
|
27
|
+
* @param existing - Edges currently on GitHub (blockedBy relationships)
|
|
28
|
+
* @param planIssues - The set of issue numbers that belong to this plan
|
|
29
|
+
*/
|
|
30
|
+
export function diffDependencyEdges(declared, existing, planIssues) {
|
|
31
|
+
const edgeKey = (e) => `${e.blocked}:${e.blocking}`;
|
|
32
|
+
const declaredSet = new Set(declared.map(edgeKey));
|
|
33
|
+
const existingSet = new Set(existing.map(edgeKey));
|
|
34
|
+
const added = declared.filter((e) => !existingSet.has(edgeKey(e)));
|
|
35
|
+
const unchanged = declared.filter((e) => existingSet.has(edgeKey(e)));
|
|
36
|
+
const removed = existing.filter((e) => !declaredSet.has(edgeKey(e)) &&
|
|
37
|
+
planIssues.has(e.blocked) &&
|
|
38
|
+
planIssues.has(e.blocking));
|
|
39
|
+
return { added, removed, unchanged };
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Register plan graph tools
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
export function registerPlanGraphTools(server, client) {
|
|
45
|
+
// -------------------------------------------------------------------------
|
|
46
|
+
// ralph_hero__sync_plan_graph
|
|
47
|
+
// -------------------------------------------------------------------------
|
|
48
|
+
server.tool("ralph_hero__sync_plan_graph", "Sync a plan document's dependency graph to GitHub blockedBy edges. " +
|
|
49
|
+
"Reads the plan file, extracts dependency edges, diffs against existing GitHub edges " +
|
|
50
|
+
"scoped to plan issues, and adds missing / removes stale edges. " +
|
|
51
|
+
"Use dryRun=true to preview changes without mutating (default: false).", {
|
|
52
|
+
planPath: z
|
|
53
|
+
.string()
|
|
54
|
+
.describe("Absolute path to the plan markdown document"),
|
|
55
|
+
dryRun: z
|
|
56
|
+
.boolean()
|
|
57
|
+
.optional()
|
|
58
|
+
.default(false)
|
|
59
|
+
.describe("When true, report what would change without mutating GitHub. Default: false."),
|
|
60
|
+
}, async (args) => {
|
|
61
|
+
try {
|
|
62
|
+
// Step 1: Read the plan file
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
content = await readFile(args.planPath, "utf-8");
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
69
|
+
return toolError(`Failed to read plan file: ${message}`);
|
|
70
|
+
}
|
|
71
|
+
// Step 2: Parse the dependency graph
|
|
72
|
+
const graph = parsePlanGraph(content);
|
|
73
|
+
if (graph.issues.length === 0) {
|
|
74
|
+
return toolError("Plan has no github_issues in frontmatter. Cannot sync dependencies.");
|
|
75
|
+
}
|
|
76
|
+
const planIssues = new Set(graph.issues);
|
|
77
|
+
const { owner, repo } = resolveConfig(client, {});
|
|
78
|
+
// Step 3: Query existing blockedBy edges for all plan issues
|
|
79
|
+
const existingEdges = [];
|
|
80
|
+
for (const issueNum of graph.issues) {
|
|
81
|
+
try {
|
|
82
|
+
const result = await client.query(`query($owner: String!, $repo: String!, $number: Int!) {
|
|
83
|
+
repository(owner: $owner, name: $repo) {
|
|
84
|
+
issue(number: $number) {
|
|
85
|
+
blockedBy(first: 50) {
|
|
86
|
+
nodes { number }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}`, { owner, repo, number: issueNum });
|
|
91
|
+
const blockers = result.repository?.issue?.blockedBy?.nodes ?? [];
|
|
92
|
+
for (const blocker of blockers) {
|
|
93
|
+
existingEdges.push({
|
|
94
|
+
blocked: issueNum,
|
|
95
|
+
blocking: blocker.number,
|
|
96
|
+
source: "phase-level", // source doesn't matter for existing edges
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// If we can't query an issue, skip it — it may not exist yet
|
|
102
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
103
|
+
console.error(`[ralph-hero] Warning: could not query blockedBy for #${issueNum}: ${message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Step 4: Diff
|
|
107
|
+
const diff = diffDependencyEdges(graph.edges, existingEdges, planIssues);
|
|
108
|
+
// Step 5: If dryRun, return diff without mutating
|
|
109
|
+
if (args.dryRun) {
|
|
110
|
+
return toolSuccess({
|
|
111
|
+
dryRun: true,
|
|
112
|
+
planPath: args.planPath,
|
|
113
|
+
planType: graph.type,
|
|
114
|
+
planIssues: graph.issues,
|
|
115
|
+
added: diff.added.map((e) => ({
|
|
116
|
+
blocked: e.blocked,
|
|
117
|
+
blocking: e.blocking,
|
|
118
|
+
})),
|
|
119
|
+
removed: diff.removed.map((e) => ({
|
|
120
|
+
blocked: e.blocked,
|
|
121
|
+
blocking: e.blocking,
|
|
122
|
+
})),
|
|
123
|
+
unchanged: diff.unchanged.map((e) => ({
|
|
124
|
+
blocked: e.blocked,
|
|
125
|
+
blocking: e.blocking,
|
|
126
|
+
})),
|
|
127
|
+
errors: [],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Step 6: Apply mutations
|
|
131
|
+
const errors = [];
|
|
132
|
+
// Add missing edges
|
|
133
|
+
for (const edge of diff.added) {
|
|
134
|
+
try {
|
|
135
|
+
const blockedId = await resolveIssueNodeId(client, owner, repo, edge.blocked);
|
|
136
|
+
const blockingId = await resolveIssueNodeId(client, owner, repo, edge.blocking);
|
|
137
|
+
await client.mutate(`mutation($blockedId: ID!, $blockingId: ID!) {
|
|
138
|
+
addBlockedBy(input: {
|
|
139
|
+
issueId: $blockedId,
|
|
140
|
+
blockingIssueId: $blockingId
|
|
141
|
+
}) {
|
|
142
|
+
issue { id number }
|
|
143
|
+
blockingIssue { id number }
|
|
144
|
+
}
|
|
145
|
+
}`, { blockedId, blockingId });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
149
|
+
errors.push({
|
|
150
|
+
edge: `#${edge.blocked} blocked by #${edge.blocking}`,
|
|
151
|
+
error: `addBlockedBy failed: ${message}`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Remove stale edges
|
|
156
|
+
for (const edge of diff.removed) {
|
|
157
|
+
try {
|
|
158
|
+
const blockedId = await resolveIssueNodeId(client, owner, repo, edge.blocked);
|
|
159
|
+
const blockingId = await resolveIssueNodeId(client, owner, repo, edge.blocking);
|
|
160
|
+
await client.mutate(`mutation($blockedId: ID!, $blockingId: ID!) {
|
|
161
|
+
removeBlockedBy(input: {
|
|
162
|
+
issueId: $blockedId,
|
|
163
|
+
blockingIssueId: $blockingId
|
|
164
|
+
}) {
|
|
165
|
+
issue { id number }
|
|
166
|
+
blockingIssue { id number }
|
|
167
|
+
}
|
|
168
|
+
}`, { blockedId, blockingId });
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
172
|
+
errors.push({
|
|
173
|
+
edge: `#${edge.blocked} blocked by #${edge.blocking}`,
|
|
174
|
+
error: `removeBlockedBy failed: ${message}`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return toolSuccess({
|
|
179
|
+
dryRun: false,
|
|
180
|
+
planPath: args.planPath,
|
|
181
|
+
planType: graph.type,
|
|
182
|
+
planIssues: graph.issues,
|
|
183
|
+
added: diff.added.map((e) => ({
|
|
184
|
+
blocked: e.blocked,
|
|
185
|
+
blocking: e.blocking,
|
|
186
|
+
})),
|
|
187
|
+
removed: diff.removed.map((e) => ({
|
|
188
|
+
blocked: e.blocked,
|
|
189
|
+
blocking: e.blocking,
|
|
190
|
+
})),
|
|
191
|
+
unchanged: diff.unchanged.map((e) => ({
|
|
192
|
+
blocked: e.blocked,
|
|
193
|
+
blocking: e.blocking,
|
|
194
|
+
})),
|
|
195
|
+
errors,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
200
|
+
return toolError(`Failed to sync plan graph: ${message}`);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
//# sourceMappingURL=plan-graph-tools.js.map
|
|
@@ -386,6 +386,52 @@ async function fetchProject(client, owner, number) {
|
|
|
386
386
|
}
|
|
387
387
|
return null;
|
|
388
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
|
+
}
|
|
389
435
|
async function createSingleSelectField(client, projectId, fieldName, options) {
|
|
390
436
|
const result = await client.projectMutate(`mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) {
|
|
391
437
|
createProjectV2Field(input: {
|
|
@@ -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
|