ralph-hero-mcp-server 2.4.15 → 2.4.16

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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Routing config loader and live validation.
3
+ *
4
+ * Two-phase validation:
5
+ * 1. Structural: YAML parse + Zod schema validation (loadRoutingConfig)
6
+ * 2. Referential: field option checks against FieldOptionCache (validateRulesLive)
7
+ *
8
+ * Consumers:
9
+ * - #178 configure_routing tool: loads config for CRUD operations
10
+ * - #179 validate_rules operation: calls validateRulesLive
11
+ */
12
+ import { readFile } from "fs/promises";
13
+ import { parse as yamlParse } from "yaml";
14
+ import { RoutingConfigSchema } from "./routing-types.js";
15
+ const DEFAULT_CONFIG = {
16
+ version: 1,
17
+ stopOnFirstMatch: true,
18
+ rules: [],
19
+ };
20
+ /**
21
+ * Load and validate a routing config file.
22
+ *
23
+ * Returns a discriminated union:
24
+ * - "loaded": valid config parsed from file
25
+ * - "missing": file not found, returns default empty config
26
+ * - "error": YAML parse or schema validation errors
27
+ */
28
+ export async function loadRoutingConfig(configPath) {
29
+ // Read file — gracefully handle missing files
30
+ let contents;
31
+ try {
32
+ contents = await readFile(configPath, "utf-8");
33
+ }
34
+ catch (err) {
35
+ if (err.code === "ENOENT") {
36
+ return { status: "missing", config: DEFAULT_CONFIG };
37
+ }
38
+ throw err;
39
+ }
40
+ // Parse YAML
41
+ let parsed;
42
+ try {
43
+ parsed = yamlParse(contents);
44
+ }
45
+ catch (err) {
46
+ return {
47
+ status: "error",
48
+ errors: [
49
+ {
50
+ phase: "yaml_parse",
51
+ path: [],
52
+ message: err instanceof Error ? err.message : String(err),
53
+ },
54
+ ],
55
+ };
56
+ }
57
+ // Validate against Zod schema
58
+ const result = RoutingConfigSchema.safeParse(parsed);
59
+ if (!result.success) {
60
+ const errors = result.error.issues.map((issue) => ({
61
+ phase: "schema_validation",
62
+ path: issue.path.map(String),
63
+ message: issue.message,
64
+ }));
65
+ return { status: "error", errors };
66
+ }
67
+ return { status: "loaded", config: result.data, filePath: configPath };
68
+ }
69
+ /**
70
+ * Validate routing rules against live project field data.
71
+ *
72
+ * Checks that referenced workflow states exist in the FieldOptionCache.
73
+ * The caller must ensure the cache is populated (via ensureFieldCache)
74
+ * before calling this function.
75
+ *
76
+ * Skips disabled rules (enabled === false).
77
+ */
78
+ export function validateRulesLive(config, fieldCache) {
79
+ const errors = [];
80
+ for (let i = 0; i < config.rules.length; i++) {
81
+ const rule = config.rules[i];
82
+ // Skip disabled rules
83
+ if (rule.enabled === false)
84
+ continue;
85
+ // Validate workflowState references
86
+ if (rule.action.workflowState) {
87
+ const optionId = fieldCache.resolveOptionId("Workflow State", rule.action.workflowState);
88
+ if (optionId === undefined) {
89
+ const valid = fieldCache.getOptionNames("Workflow State");
90
+ errors.push({
91
+ phase: "live_validation",
92
+ path: ["rules", String(i), "action", "workflowState"],
93
+ message: `Workflow state "${rule.action.workflowState}" not found in project. Valid: ${valid.join(", ")}`,
94
+ });
95
+ }
96
+ }
97
+ }
98
+ return errors;
99
+ }
100
+ //# sourceMappingURL=routing-config.js.map
@@ -1,21 +1,32 @@
1
1
  /**
2
2
  * MCP tool for managing routing rules in .ralph-routing.yml.
3
3
  *
4
- * Provides a single `ralph_hero__configure_routing` tool with four
5
- * CRUD operations: list_rules, add_rule, update_rule, remove_rule.
4
+ * Provides a single `ralph_hero__configure_routing` tool with six
5
+ * operations: list_rules, add_rule, update_rule, remove_rule,
6
+ * validate_rules, dry_run.
6
7
  */
7
8
  import fs from "node:fs/promises";
8
9
  import { parse, stringify } from "yaml";
9
10
  import { z } from "zod";
10
- import { toolSuccess, toolError } from "../types.js";
11
+ import { toolSuccess, toolError, resolveProjectOwner } from "../types.js";
12
+ import { ensureFieldCache } from "../lib/helpers.js";
13
+ import { validateRoutingConfig } from "../lib/routing-types.js";
14
+ import { evaluateRules } from "../lib/routing-engine.js";
11
15
  // ---------------------------------------------------------------------------
12
16
  // Register routing tools
13
17
  // ---------------------------------------------------------------------------
14
- export function registerRoutingTools(server, _client, _fieldCache) {
15
- server.tool("ralph_hero__configure_routing", "Manage routing rules in .ralph-routing.yml. CRUD operations: list, add, update, remove rules. Config path: configPath arg > RALPH_ROUTING_CONFIG env var > .ralph-routing.yml. Returns: updated rule list and configPath.", {
18
+ export function registerRoutingTools(server, client, fieldCache) {
19
+ server.tool("ralph_hero__configure_routing", "Manage routing rules in .ralph-routing.yml. Operations: list_rules, add_rule, update_rule, remove_rule (CRUD), validate_rules (check field references), dry_run (simulate matching for an issue). Config path: configPath arg > RALPH_ROUTING_CONFIG env var > .ralph-routing.yml.", {
16
20
  operation: z
17
- .enum(["list_rules", "add_rule", "update_rule", "remove_rule"])
18
- .describe("CRUD operation to perform"),
21
+ .enum([
22
+ "list_rules",
23
+ "add_rule",
24
+ "update_rule",
25
+ "remove_rule",
26
+ "validate_rules",
27
+ "dry_run",
28
+ ])
29
+ .describe("Operation to perform"),
19
30
  configPath: z
20
31
  .string()
21
32
  .optional()
@@ -37,6 +48,10 @@ export function registerRoutingTools(server, _client, _fieldCache) {
37
48
  .number()
38
49
  .optional()
39
50
  .describe("Zero-based rule index (required for update_rule, remove_rule)"),
51
+ issueNumber: z
52
+ .number()
53
+ .optional()
54
+ .describe("Issue number (required for dry_run)"),
40
55
  }, async (args) => {
41
56
  const configPath = args.configPath ??
42
57
  process.env.RALPH_ROUTING_CONFIG ??
@@ -73,6 +88,77 @@ export function registerRoutingTools(server, _client, _fieldCache) {
73
88
  config.rules = config.rules.filter((_, i) => i !== args.ruleIndex);
74
89
  await fs.writeFile(configPath, stringify(config, { lineWidth: 0 }));
75
90
  return toolSuccess({ rules: config.rules, configPath });
91
+ case "validate_rules": {
92
+ const errors = [];
93
+ // Populate field cache for the default project
94
+ const projectOwner = resolveProjectOwner(client.config);
95
+ const projectNumber = client.config.projectNumber;
96
+ if (projectOwner && projectNumber) {
97
+ await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
98
+ }
99
+ for (let i = 0; i < config.rules.length; i++) {
100
+ const rule = config.rules[i];
101
+ if (rule.action.workflowState) {
102
+ const optionId = fieldCache.resolveOptionId("Workflow State", rule.action.workflowState);
103
+ if (optionId === undefined) {
104
+ const valid = fieldCache.getOptionNames("Workflow State");
105
+ errors.push({
106
+ ruleIndex: i,
107
+ field: "action.workflowState",
108
+ message: `"${rule.action.workflowState}" is not a valid Workflow State. Valid: ${valid.join(", ")}`,
109
+ });
110
+ }
111
+ }
112
+ }
113
+ return toolSuccess({
114
+ valid: errors.length === 0,
115
+ ruleCount: config.rules.length,
116
+ errors,
117
+ configPath,
118
+ });
119
+ }
120
+ case "dry_run": {
121
+ if (!args.issueNumber) {
122
+ return toolError("issueNumber is required for dry_run operation");
123
+ }
124
+ const owner = client.config.owner;
125
+ const repo = client.config.repo;
126
+ if (!owner || !repo) {
127
+ return toolError("owner and repo must be configured (set RALPH_GH_OWNER and RALPH_GH_REPO env vars)");
128
+ }
129
+ // Parse config through Zod for proper RoutingConfig type
130
+ const typedConfig = validateRoutingConfig(raw ? parse(raw) : { version: 1, rules: [] });
131
+ // Fetch issue details
132
+ const issueResult = await client.query(`query($owner: String!, $repo: String!, $issueNum: Int!) {
133
+ repository(owner: $owner, name: $repo) {
134
+ issue(number: $issueNum) {
135
+ number
136
+ title
137
+ labels(first: 20) { nodes { name } }
138
+ repository { nameWithOwner }
139
+ }
140
+ }
141
+ }`, { owner, repo, issueNum: args.issueNumber });
142
+ const issue = issueResult.repository?.issue;
143
+ if (!issue) {
144
+ return toolError(`Issue #${args.issueNumber} not found in ${owner}/${repo}`);
145
+ }
146
+ const issueContext = {
147
+ repo: issue.repository.nameWithOwner,
148
+ labels: issue.labels.nodes.map((l) => l.name),
149
+ issueType: "issue",
150
+ };
151
+ const evalResult = evaluateRules(typedConfig, issueContext);
152
+ return toolSuccess({
153
+ issueNumber: args.issueNumber,
154
+ issueTitle: issue.title,
155
+ issueContext,
156
+ matchedRules: evalResult.matchedRules,
157
+ stoppedEarly: evalResult.stoppedEarly,
158
+ note: "No mutations performed -- dry run only",
159
+ configPath,
160
+ });
161
+ }
76
162
  }
77
163
  }
78
164
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.4.15",
3
+ "version": "2.4.16",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",