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 +3 -0
- package/dist/lib/routing-engine.js +84 -0
- package/dist/tools/sync-tools.js +183 -0
- package/package.json +1 -1
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
|