ralph-hero-mcp-server 2.4.13 → 2.4.15

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/index.js CHANGED
@@ -20,6 +20,7 @@ import { registerBatchTools } from "./tools/batch-tools.js";
20
20
  import { registerProjectManagementTools } from "./tools/project-management-tools.js";
21
21
  import { registerHygieneTools } from "./tools/hygiene-tools.js";
22
22
  import { registerRoutingTools } from "./tools/routing-tools.js";
23
+ import { registerSyncTools } from "./tools/sync-tools.js";
23
24
  /**
24
25
  * Initialize the GitHub client from environment variables.
25
26
  */
@@ -251,6 +252,8 @@ async function main() {
251
252
  registerHygieneTools(server, client, fieldCache);
252
253
  // Routing config management tools
253
254
  registerRoutingTools(server, client, fieldCache);
255
+ // Cross-project sync tools
256
+ registerSyncTools(server, client, fieldCache);
254
257
  // Connect via stdio transport
255
258
  const transport = new StdioServerTransport();
256
259
  await server.connect(transport);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Routing rule matching engine.
3
+ *
4
+ * Evaluates routing rules against an issue context (repo, labels, type)
5
+ * and returns matched rules with their actions. Pure function — no I/O,
6
+ * no API calls, fully deterministic.
7
+ *
8
+ * Used by: configure_routing dry_run (#179), Actions routing script (#171).
9
+ */
10
+ // ---------------------------------------------------------------------------
11
+ // Public API
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Evaluate routing rules against an issue context.
15
+ *
16
+ * Rules are evaluated top-to-bottom. Each rule's match criteria use AND
17
+ * logic (all specified criteria must match). Omitted criteria are vacuously
18
+ * true. The `negate` flag inverts the combined result.
19
+ *
20
+ * When `stopOnFirstMatch` is true (default), evaluation stops after the
21
+ * first matching rule. Set to false for fan-out routing (one issue →
22
+ * multiple projects).
23
+ */
24
+ export function evaluateRules(config, issue) {
25
+ const results = [];
26
+ const stopOnFirst = config.stopOnFirstMatch ?? true;
27
+ for (let i = 0; i < config.rules.length; i++) {
28
+ const rule = config.rules[i];
29
+ if (rule.enabled === false)
30
+ continue;
31
+ let matched = matchesRule(rule.match, issue);
32
+ if (rule.match.negate)
33
+ matched = !matched;
34
+ if (matched) {
35
+ results.push({ rule, ruleIndex: i, matched: true, actions: rule.action });
36
+ if (stopOnFirst) {
37
+ return { matchedRules: results, stoppedEarly: true };
38
+ }
39
+ }
40
+ }
41
+ return { matchedRules: results, stoppedEarly: false };
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Private Helpers
45
+ // ---------------------------------------------------------------------------
46
+ function matchesRule(criteria, issue) {
47
+ if (criteria.repo && !matchesRepo(criteria.repo, issue.repo))
48
+ return false;
49
+ if (criteria.labels && !matchesLabels(criteria.labels, issue.labels))
50
+ return false;
51
+ if (criteria.issueType && !matchesIssueType(criteria.issueType, issue.issueType))
52
+ return false;
53
+ return true;
54
+ }
55
+ function matchesRepo(pattern, repo) {
56
+ return matchesGlob(pattern.toLowerCase(), repo.toLowerCase());
57
+ }
58
+ function matchesLabels(criteria, issueLabels) {
59
+ const normalized = issueLabels.map((l) => l.toLowerCase());
60
+ if (criteria.any?.length) {
61
+ const hasAny = criteria.any.some((l) => normalized.includes(l.toLowerCase()));
62
+ if (!hasAny)
63
+ return false;
64
+ }
65
+ if (criteria.all?.length) {
66
+ const hasAll = criteria.all.every((l) => normalized.includes(l.toLowerCase()));
67
+ if (!hasAll)
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ function matchesIssueType(expected, actual) {
73
+ return expected.toLowerCase() === actual.toLowerCase();
74
+ }
75
+ function matchesGlob(pattern, input) {
76
+ const regexStr = pattern
77
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
78
+ .replace(/\*\*/g, "\x00")
79
+ .replace(/\*/g, "[^/]*")
80
+ .replace(/\?/g, "[^/]")
81
+ .replace(/\x00/g, ".*");
82
+ return new RegExp(`^${regexStr}$`).test(input);
83
+ }
84
+ //# sourceMappingURL=routing-engine.js.map
@@ -0,0 +1,183 @@
1
+ /**
2
+ * MCP tools for cross-project state synchronization.
3
+ *
4
+ * Provides `ralph_hero__sync_across_projects` to propagate Workflow State
5
+ * changes to all GitHub Projects an issue belongs to. Discovers project
6
+ * memberships via the `projectItems` GraphQL field on Issue nodes.
7
+ */
8
+ import { z } from "zod";
9
+ import { toolSuccess, toolError } from "../types.js";
10
+ import { resolveIssueNodeId, resolveConfig } from "../lib/helpers.js";
11
+ // ---------------------------------------------------------------------------
12
+ // GraphQL queries and mutations
13
+ // ---------------------------------------------------------------------------
14
+ const SYNC_PROJECT_ITEMS_QUERY = `query($issueId: ID!) {
15
+ node(id: $issueId) {
16
+ ... on Issue {
17
+ projectItems(first: 20) {
18
+ nodes {
19
+ id
20
+ project { id number }
21
+ fieldValues(first: 20) {
22
+ nodes {
23
+ ... on ProjectV2ItemFieldSingleSelectValue {
24
+ __typename
25
+ name
26
+ field { ... on ProjectV2FieldCommon { name } }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }`;
35
+ const PROJECT_FIELD_META_QUERY = `query($projectId: ID!) {
36
+ node(id: $projectId) {
37
+ ... on ProjectV2 {
38
+ fields(first: 20) {
39
+ nodes {
40
+ ... on ProjectV2SingleSelectField {
41
+ id
42
+ name
43
+ options { id name }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }`;
50
+ const UPDATE_FIELD_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
51
+ updateProjectV2ItemFieldValue(input: {
52
+ projectId: $projectId,
53
+ itemId: $itemId,
54
+ fieldId: $fieldId,
55
+ value: { singleSelectOptionId: $optionId }
56
+ }) {
57
+ projectV2Item { id }
58
+ }
59
+ }`;
60
+ // ---------------------------------------------------------------------------
61
+ // Helpers
62
+ // ---------------------------------------------------------------------------
63
+ /**
64
+ * Fetch field metadata for a specific project. Returns only SingleSelectField
65
+ * entries (which have id, name, and options). Does not populate FieldOptionCache
66
+ * to avoid polluting the default project cache.
67
+ */
68
+ async function fetchProjectFieldMeta(client, projectId) {
69
+ const result = await client.projectQuery(PROJECT_FIELD_META_QUERY, { projectId });
70
+ return (result.node?.fields?.nodes ?? []).filter((f) => !!f.id && !!f.options);
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Register sync tools
74
+ // ---------------------------------------------------------------------------
75
+ export function registerSyncTools(server, client, _fieldCache) {
76
+ server.tool("ralph_hero__sync_across_projects", "Propagate a Workflow State change to all GitHub Projects an issue belongs to. " +
77
+ "Queries projectItems to find all project memberships, applies the target state " +
78
+ "to projects where current state differs. Idempotent: skips projects already at " +
79
+ "target state. Returns: list of projects synced and skipped with reasons.", {
80
+ owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
81
+ repo: z.string().optional().describe("Repository name. Defaults to env var"),
82
+ number: z.number().describe("Issue number to sync"),
83
+ workflowState: z
84
+ .string()
85
+ .describe('Target Workflow State to propagate (e.g., "In Progress")'),
86
+ dryRun: z
87
+ .boolean()
88
+ .optional()
89
+ .default(false)
90
+ .describe("If true, return affected projects without mutating (default: false)"),
91
+ }, async (args) => {
92
+ try {
93
+ const { owner, repo } = resolveConfig(client, args);
94
+ // 1. Resolve issue node ID
95
+ const issueNodeId = await resolveIssueNodeId(client, owner, repo, args.number);
96
+ // 2. Fetch all project memberships + current Workflow State
97
+ const projectItemsResult = await client.projectQuery(SYNC_PROJECT_ITEMS_QUERY, { issueId: issueNodeId });
98
+ const projectItems = projectItemsResult.node?.projectItems?.nodes ?? [];
99
+ if (!projectItems.length) {
100
+ return toolSuccess({
101
+ number: args.number,
102
+ message: "Issue is not a member of any GitHub Project",
103
+ synced: [],
104
+ skipped: [],
105
+ });
106
+ }
107
+ const synced = [];
108
+ const skipped = [];
109
+ for (const item of projectItems) {
110
+ const projectId = item.project.id;
111
+ const projectNumber = item.project.number;
112
+ // Extract current Workflow State from fieldValues
113
+ const currentState = item.fieldValues.nodes.find((fv) => fv.__typename === "ProjectV2ItemFieldSingleSelectValue" &&
114
+ fv.field?.name === "Workflow State")?.name ?? null;
115
+ // Idempotency: skip if already at target state
116
+ if (currentState === args.workflowState) {
117
+ skipped.push({
118
+ projectNumber,
119
+ reason: "already_at_target_state",
120
+ currentState,
121
+ });
122
+ continue;
123
+ }
124
+ if (args.dryRun) {
125
+ synced.push({
126
+ projectNumber,
127
+ currentState,
128
+ targetState: args.workflowState,
129
+ dryRun: true,
130
+ });
131
+ continue;
132
+ }
133
+ // Fetch field IDs for this project
134
+ const fieldMeta = await fetchProjectFieldMeta(client, projectId);
135
+ const wfField = fieldMeta.find((f) => f.name === "Workflow State");
136
+ if (!wfField) {
137
+ skipped.push({
138
+ projectNumber,
139
+ reason: "no_workflow_state_field",
140
+ currentState,
141
+ });
142
+ continue;
143
+ }
144
+ const targetOption = wfField.options.find((o) => o.name === args.workflowState);
145
+ if (!targetOption) {
146
+ skipped.push({
147
+ projectNumber,
148
+ reason: "invalid_option",
149
+ currentState,
150
+ detail: `"${args.workflowState}" not found. Valid: ${wfField.options.map((o) => o.name).join(", ")}`,
151
+ });
152
+ continue;
153
+ }
154
+ // Apply the update
155
+ await client.projectMutate(UPDATE_FIELD_MUTATION, {
156
+ projectId,
157
+ itemId: item.id,
158
+ fieldId: wfField.id,
159
+ optionId: targetOption.id,
160
+ });
161
+ synced.push({
162
+ projectNumber,
163
+ currentState,
164
+ targetState: args.workflowState,
165
+ });
166
+ }
167
+ return toolSuccess({
168
+ number: args.number,
169
+ workflowState: args.workflowState,
170
+ dryRun: args.dryRun,
171
+ syncedCount: synced.length,
172
+ skippedCount: skipped.length,
173
+ synced,
174
+ skipped,
175
+ });
176
+ }
177
+ catch (error) {
178
+ const message = error instanceof Error ? error.message : String(error);
179
+ return toolError(`Failed to sync across projects: ${message}`);
180
+ }
181
+ });
182
+ }
183
+ //# sourceMappingURL=sync-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.4.13",
3
+ "version": "2.4.15",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",