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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.24",
3
+ "version": "2.5.50",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",