ralph-hero-mcp-server 2.4.30 → 2.4.32
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/lib/transition-comments.js +113 -0
- package/dist/tools/project-tools.js +191 -0
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transition comment format specification, builder, and parsers.
|
|
3
|
+
*
|
|
4
|
+
* Defines the canonical machine-parseable transition comment format
|
|
5
|
+
* (`<!-- ralph-transition: {...} -->`) and provides parsers for both
|
|
6
|
+
* the HTML comment format and #19's handoff_ticket markdown audit
|
|
7
|
+
* comment format.
|
|
8
|
+
*
|
|
9
|
+
* Pure utility module — no API dependencies.
|
|
10
|
+
*/
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/** Regex pattern for HTML transition comments: <!-- ralph-transition: {...} --> */
|
|
15
|
+
export const TRANSITION_COMMENT_PATTERN = /<!-- ralph-transition: (\{.*?\}) -->/g;
|
|
16
|
+
/** Regex pattern for #19 handoff_ticket audit comments */
|
|
17
|
+
export const AUDIT_COMMENT_PATTERN = /\*\*State transition\*\*: (.+?) → (.+?) \(intent: .+?\)\n\*\*Command\*\*: ralph_(\w+)/g;
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Builder
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/**
|
|
22
|
+
* Build a machine-parseable HTML comment encoding a transition record.
|
|
23
|
+
* The HTML comment is invisible in rendered GitHub markdown.
|
|
24
|
+
* JSON is compact (no pretty-printing) for single-line format.
|
|
25
|
+
*/
|
|
26
|
+
export function buildTransitionComment(record) {
|
|
27
|
+
const json = JSON.stringify({
|
|
28
|
+
from: record.from,
|
|
29
|
+
to: record.to,
|
|
30
|
+
command: record.command,
|
|
31
|
+
at: record.at,
|
|
32
|
+
});
|
|
33
|
+
return `<!-- ralph-transition: ${json} -->`;
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Parsers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
/**
|
|
39
|
+
* Parse transition records from HTML comment format.
|
|
40
|
+
* Scans text for all `<!-- ralph-transition: {...} -->` patterns.
|
|
41
|
+
* Gracefully handles malformed JSON (skips unparseable entries).
|
|
42
|
+
*/
|
|
43
|
+
export function parseTransitionComments(text) {
|
|
44
|
+
const records = [];
|
|
45
|
+
const regex = new RegExp(TRANSITION_COMMENT_PATTERN.source, "g");
|
|
46
|
+
let match;
|
|
47
|
+
while ((match = regex.exec(text)) !== null) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(match[1]);
|
|
50
|
+
if (parsed.from && parsed.to && parsed.command && parsed.at) {
|
|
51
|
+
records.push({
|
|
52
|
+
from: parsed.from,
|
|
53
|
+
to: parsed.to,
|
|
54
|
+
command: parsed.command,
|
|
55
|
+
at: parsed.at,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Skip malformed JSON entries
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return records;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parse transition records from #19's handoff_ticket markdown audit format.
|
|
67
|
+
* Pattern: `**State transition**: X → Y (intent: Z)\n**Command**: ralph_cmd`
|
|
68
|
+
* Uses `commentCreatedAt` as the `at` timestamp since audit comments don't
|
|
69
|
+
* embed their own timestamp.
|
|
70
|
+
*/
|
|
71
|
+
export function parseAuditComments(text, commentCreatedAt) {
|
|
72
|
+
const records = [];
|
|
73
|
+
const regex = new RegExp(AUDIT_COMMENT_PATTERN.source, "g");
|
|
74
|
+
let match;
|
|
75
|
+
while ((match = regex.exec(text)) !== null) {
|
|
76
|
+
records.push({
|
|
77
|
+
from: match[1],
|
|
78
|
+
to: match[2],
|
|
79
|
+
command: `ralph_${match[3]}`,
|
|
80
|
+
at: commentCreatedAt,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return records;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse transition records from a comment body, trying both formats.
|
|
87
|
+
* Prefers HTML comment format; falls back to audit format.
|
|
88
|
+
* Deduplicates records that appear in both formats (by from+to+command).
|
|
89
|
+
*
|
|
90
|
+
* This is the primary entry point for future analytics tools.
|
|
91
|
+
*/
|
|
92
|
+
export function parseAllTransitions(commentBody, commentCreatedAt) {
|
|
93
|
+
const htmlRecords = parseTransitionComments(commentBody);
|
|
94
|
+
const auditRecords = parseAuditComments(commentBody, commentCreatedAt);
|
|
95
|
+
if (htmlRecords.length === 0) {
|
|
96
|
+
return auditRecords;
|
|
97
|
+
}
|
|
98
|
+
if (auditRecords.length === 0) {
|
|
99
|
+
return htmlRecords;
|
|
100
|
+
}
|
|
101
|
+
// Both present — deduplicate by from+to+command
|
|
102
|
+
const seen = new Set(htmlRecords.map((r) => `${r.from}|${r.to}|${r.command}`));
|
|
103
|
+
const unique = [...htmlRecords];
|
|
104
|
+
for (const r of auditRecords) {
|
|
105
|
+
const key = `${r.from}|${r.to}|${r.command}`;
|
|
106
|
+
if (!seen.has(key)) {
|
|
107
|
+
seen.add(key);
|
|
108
|
+
unique.push(r);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return unique;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=transition-comments.js.map
|
|
@@ -231,6 +231,197 @@ export function registerProjectTools(server, client, fieldCache) {
|
|
|
231
231
|
}
|
|
232
232
|
});
|
|
233
233
|
// -------------------------------------------------------------------------
|
|
234
|
+
// ralph_hero__list_projects
|
|
235
|
+
// -------------------------------------------------------------------------
|
|
236
|
+
server.tool("ralph_hero__list_projects", "List all GitHub Projects V2 for an owner (user or organization). Returns project summaries with item/field/view counts. Supports open/closed filtering.", {
|
|
237
|
+
owner: z
|
|
238
|
+
.string()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe("GitHub owner (user or org). Defaults to RALPH_GH_PROJECT_OWNER or RALPH_GH_OWNER env var"),
|
|
241
|
+
state: z
|
|
242
|
+
.enum(["open", "closed", "all"])
|
|
243
|
+
.optional()
|
|
244
|
+
.default("open")
|
|
245
|
+
.describe('Filter by project state: "open" (default), "closed", or "all"'),
|
|
246
|
+
limit: z
|
|
247
|
+
.number()
|
|
248
|
+
.optional()
|
|
249
|
+
.default(50)
|
|
250
|
+
.describe("Maximum number of projects to return (default: 50, max: 100)"),
|
|
251
|
+
}, async (args) => {
|
|
252
|
+
try {
|
|
253
|
+
const owner = args.owner || resolveProjectOwner(client.config);
|
|
254
|
+
if (!owner) {
|
|
255
|
+
return toolError("owner is required (set RALPH_GH_PROJECT_OWNER or RALPH_GH_OWNER env var or pass explicitly)");
|
|
256
|
+
}
|
|
257
|
+
const maxItems = Math.min(args.limit ?? 50, 100);
|
|
258
|
+
const LIST_PROJECTS_QUERY = `
|
|
259
|
+
query($owner: String!, $cursor: String, $first: Int!) {
|
|
260
|
+
OWNER_TYPE(login: $owner) {
|
|
261
|
+
projectsV2(first: $first, after: $cursor, orderBy: {field: TITLE, direction: ASC}) {
|
|
262
|
+
totalCount
|
|
263
|
+
pageInfo { hasNextPage endCursor }
|
|
264
|
+
nodes {
|
|
265
|
+
id
|
|
266
|
+
number
|
|
267
|
+
title
|
|
268
|
+
shortDescription
|
|
269
|
+
public
|
|
270
|
+
closed
|
|
271
|
+
url
|
|
272
|
+
items { totalCount }
|
|
273
|
+
fields { totalCount }
|
|
274
|
+
views { totalCount }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
`;
|
|
280
|
+
let allProjects = [];
|
|
281
|
+
let totalCount;
|
|
282
|
+
for (const ownerType of ["user", "organization"]) {
|
|
283
|
+
try {
|
|
284
|
+
const result = await paginateConnection((q, vars) => client.projectQuery(q, vars), LIST_PROJECTS_QUERY.replace("OWNER_TYPE", ownerType), { owner }, `${ownerType}.projectsV2`, { maxItems });
|
|
285
|
+
allProjects = result.nodes;
|
|
286
|
+
totalCount = result.totalCount;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Try next owner type
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Client-side state filtering
|
|
294
|
+
const filtered = args.state === "all"
|
|
295
|
+
? allProjects
|
|
296
|
+
: args.state === "closed"
|
|
297
|
+
? allProjects.filter((p) => p.closed)
|
|
298
|
+
: allProjects.filter((p) => !p.closed);
|
|
299
|
+
const projects = filtered.map((p) => ({
|
|
300
|
+
id: p.id,
|
|
301
|
+
number: p.number,
|
|
302
|
+
title: p.title,
|
|
303
|
+
shortDescription: p.shortDescription,
|
|
304
|
+
public: p.public,
|
|
305
|
+
closed: p.closed,
|
|
306
|
+
url: p.url,
|
|
307
|
+
itemCount: p.items?.totalCount ?? 0,
|
|
308
|
+
fieldCount: p.fields?.totalCount ?? 0,
|
|
309
|
+
viewCount: p.views?.totalCount ?? 0,
|
|
310
|
+
}));
|
|
311
|
+
return toolSuccess({
|
|
312
|
+
owner,
|
|
313
|
+
state: args.state,
|
|
314
|
+
projects,
|
|
315
|
+
totalCount: totalCount ?? projects.length,
|
|
316
|
+
returnedCount: projects.length,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
321
|
+
return toolError(`Failed to list projects: ${message}`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
// -------------------------------------------------------------------------
|
|
325
|
+
// ralph_hero__copy_project
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
server.tool("ralph_hero__copy_project", "Copy (duplicate) a GitHub Project V2, preserving views, custom fields, workflows, and insights. Does NOT copy items, collaborators, team links, or repository links.", {
|
|
328
|
+
sourceProjectNumber: z
|
|
329
|
+
.number()
|
|
330
|
+
.describe("Project number of the source project to copy"),
|
|
331
|
+
sourceOwner: z
|
|
332
|
+
.string()
|
|
333
|
+
.optional()
|
|
334
|
+
.describe("Owner of the source project. Defaults to RALPH_GH_PROJECT_OWNER or RALPH_GH_OWNER env var"),
|
|
335
|
+
title: z.string().describe("Title for the new project"),
|
|
336
|
+
targetOwner: z
|
|
337
|
+
.string()
|
|
338
|
+
.optional()
|
|
339
|
+
.describe("Owner for the new project. Defaults to sourceOwner. Supports cross-owner copy (e.g., personal to org)"),
|
|
340
|
+
includeDraftIssues: z
|
|
341
|
+
.boolean()
|
|
342
|
+
.optional()
|
|
343
|
+
.default(false)
|
|
344
|
+
.describe("Include draft issues from the source project in the copy (default: false)"),
|
|
345
|
+
}, async (args) => {
|
|
346
|
+
try {
|
|
347
|
+
const sourceOwner = args.sourceOwner || resolveProjectOwner(client.config);
|
|
348
|
+
if (!sourceOwner) {
|
|
349
|
+
return toolError("sourceOwner is required (set RALPH_GH_PROJECT_OWNER or RALPH_GH_OWNER env var or pass explicitly)");
|
|
350
|
+
}
|
|
351
|
+
// Step 1: Resolve source project node ID via fetchProject
|
|
352
|
+
const sourceProject = await fetchProject(client, sourceOwner, args.sourceProjectNumber);
|
|
353
|
+
if (!sourceProject) {
|
|
354
|
+
return toolError(`Source project #${args.sourceProjectNumber} not found for owner "${sourceOwner}"`);
|
|
355
|
+
}
|
|
356
|
+
// Step 2: Resolve target owner node ID (try user first, then org)
|
|
357
|
+
const targetOwnerLogin = args.targetOwner || sourceOwner;
|
|
358
|
+
let targetOwnerId;
|
|
359
|
+
try {
|
|
360
|
+
const userResult = await client.query(`query($login: String!) {
|
|
361
|
+
user(login: $login) { id }
|
|
362
|
+
}`, { login: targetOwnerLogin }, { cache: true });
|
|
363
|
+
targetOwnerId = userResult.user?.id;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Not a user, try org below
|
|
367
|
+
}
|
|
368
|
+
if (!targetOwnerId) {
|
|
369
|
+
try {
|
|
370
|
+
const orgResult = await client.query(`query($login: String!) {
|
|
371
|
+
organization(login: $login) { id }
|
|
372
|
+
}`, { login: targetOwnerLogin }, { cache: true });
|
|
373
|
+
targetOwnerId = orgResult.organization?.id;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// Not an org either
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!targetOwnerId) {
|
|
380
|
+
return toolError(`Target owner "${targetOwnerLogin}" not found as user or organization`);
|
|
381
|
+
}
|
|
382
|
+
// Step 3: Execute copyProjectV2 mutation
|
|
383
|
+
const copyResult = await client.projectMutate(`mutation($projectId: ID!, $ownerId: ID!, $title: String!, $includeDraftIssues: Boolean!) {
|
|
384
|
+
copyProjectV2(input: {
|
|
385
|
+
projectId: $projectId
|
|
386
|
+
ownerId: $ownerId
|
|
387
|
+
title: $title
|
|
388
|
+
includeDraftIssues: $includeDraftIssues
|
|
389
|
+
}) {
|
|
390
|
+
projectV2 {
|
|
391
|
+
id
|
|
392
|
+
number
|
|
393
|
+
url
|
|
394
|
+
title
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}`, {
|
|
398
|
+
projectId: sourceProject.id,
|
|
399
|
+
ownerId: targetOwnerId,
|
|
400
|
+
title: args.title,
|
|
401
|
+
includeDraftIssues: args.includeDraftIssues ?? false,
|
|
402
|
+
});
|
|
403
|
+
const newProject = copyResult.copyProjectV2.projectV2;
|
|
404
|
+
return toolSuccess({
|
|
405
|
+
project: {
|
|
406
|
+
id: newProject.id,
|
|
407
|
+
number: newProject.number,
|
|
408
|
+
url: newProject.url,
|
|
409
|
+
title: newProject.title,
|
|
410
|
+
},
|
|
411
|
+
copiedFrom: {
|
|
412
|
+
number: args.sourceProjectNumber,
|
|
413
|
+
owner: sourceOwner,
|
|
414
|
+
title: sourceProject.title,
|
|
415
|
+
},
|
|
416
|
+
note: "Copied views, custom fields, workflows, and insights. Items, collaborators, team links, and repository links were NOT copied.",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
421
|
+
return toolError(`Failed to copy project: ${message}`);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
// -------------------------------------------------------------------------
|
|
234
425
|
// ralph_hero__list_project_items
|
|
235
426
|
// -------------------------------------------------------------------------
|
|
236
427
|
server.tool("ralph_hero__list_project_items", "List items in a GitHub Project V2, optionally filtered by Workflow State, Estimate, or Priority", {
|