ralph-hero-mcp-server 1.3.3 → 2.4.6
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.
|
@@ -38,6 +38,21 @@ export const HUMAN_STATES = [
|
|
|
38
38
|
"Human Needed",
|
|
39
39
|
"Plan in Review",
|
|
40
40
|
];
|
|
41
|
+
/**
|
|
42
|
+
* Gate states that trigger parent advancement when ALL children reach them.
|
|
43
|
+
* Intermediate "in progress" states should NOT advance the parent.
|
|
44
|
+
*/
|
|
45
|
+
export const PARENT_GATE_STATES = [
|
|
46
|
+
"Ready for Plan",
|
|
47
|
+
"In Review",
|
|
48
|
+
"Done",
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* Check if a state is a parent advancement gate.
|
|
52
|
+
*/
|
|
53
|
+
export function isParentGateState(state) {
|
|
54
|
+
return PARENT_GATE_STATES.includes(state);
|
|
55
|
+
}
|
|
41
56
|
/**
|
|
42
57
|
* Valid workflow states for the project (all known states).
|
|
43
58
|
*/
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { toolSuccess, toolError } from "../types.js";
|
|
9
|
-
import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, resolveFullConfig, } from "../lib/helpers.js";
|
|
9
|
+
import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, resolveFullConfig, updateProjectItemField, } from "../lib/helpers.js";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/** Fields that Ralph depends on — delete_field refuses to remove these. */
|
|
14
|
+
export const PROTECTED_FIELDS = ["Workflow State", "Priority", "Estimate", "Status"];
|
|
10
15
|
// ---------------------------------------------------------------------------
|
|
11
16
|
// Register project management tools
|
|
12
17
|
// ---------------------------------------------------------------------------
|
|
@@ -244,5 +249,352 @@ export function registerProjectManagementTools(server, client, fieldCache) {
|
|
|
244
249
|
return toolError(`Failed to clear field: ${message}`);
|
|
245
250
|
}
|
|
246
251
|
});
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
// ralph_hero__create_draft_issue
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
server.tool("ralph_hero__create_draft_issue", "Create a draft issue in the project (no repo required). Optionally set workflow state, priority, and estimate after creation. Returns: projectItemId, title, fieldsSet.", {
|
|
256
|
+
owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
|
|
257
|
+
repo: z.string().optional().describe("Repository name. Defaults to env var"),
|
|
258
|
+
title: z.string().describe("Draft issue title"),
|
|
259
|
+
body: z.string().optional().describe("Draft issue body (markdown)"),
|
|
260
|
+
workflowState: z.string().optional().describe("Workflow state to set after creation"),
|
|
261
|
+
priority: z.string().optional().describe("Priority to set after creation"),
|
|
262
|
+
estimate: z.string().optional().describe("Estimate to set after creation"),
|
|
263
|
+
}, async (args) => {
|
|
264
|
+
try {
|
|
265
|
+
const { projectNumber, projectOwner } = resolveFullConfig(client, args);
|
|
266
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
267
|
+
const projectId = fieldCache.getProjectId();
|
|
268
|
+
if (!projectId) {
|
|
269
|
+
return toolError("Could not resolve project ID");
|
|
270
|
+
}
|
|
271
|
+
const result = await client.projectMutate(`mutation($projectId: ID!, $title: String!, $body: String) {
|
|
272
|
+
addProjectV2DraftIssue(input: {
|
|
273
|
+
projectId: $projectId,
|
|
274
|
+
title: $title,
|
|
275
|
+
body: $body
|
|
276
|
+
}) {
|
|
277
|
+
projectItem { id }
|
|
278
|
+
}
|
|
279
|
+
}`, { projectId, title: args.title, body: args.body });
|
|
280
|
+
const projectItemId = result.addProjectV2DraftIssue.projectItem.id;
|
|
281
|
+
const fieldsSet = [];
|
|
282
|
+
if (args.workflowState) {
|
|
283
|
+
await updateProjectItemField(client, fieldCache, projectItemId, "Workflow State", args.workflowState);
|
|
284
|
+
fieldsSet.push("Workflow State");
|
|
285
|
+
}
|
|
286
|
+
if (args.priority) {
|
|
287
|
+
await updateProjectItemField(client, fieldCache, projectItemId, "Priority", args.priority);
|
|
288
|
+
fieldsSet.push("Priority");
|
|
289
|
+
}
|
|
290
|
+
if (args.estimate) {
|
|
291
|
+
await updateProjectItemField(client, fieldCache, projectItemId, "Estimate", args.estimate);
|
|
292
|
+
fieldsSet.push("Estimate");
|
|
293
|
+
}
|
|
294
|
+
return toolSuccess({
|
|
295
|
+
projectItemId,
|
|
296
|
+
title: args.title,
|
|
297
|
+
fieldsSet,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
302
|
+
return toolError(`Failed to create draft issue: ${message}`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
// -------------------------------------------------------------------------
|
|
306
|
+
// ralph_hero__update_draft_issue
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
server.tool("ralph_hero__update_draft_issue", "Update title and/or body of an existing draft issue. Requires the draft issue content node ID (DI_...), not the project item ID. Returns: draftIssueId, updated.", {
|
|
309
|
+
owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
|
|
310
|
+
repo: z.string().optional().describe("Repository name. Defaults to env var"),
|
|
311
|
+
draftIssueId: z.string().describe("Draft issue content node ID (DI_...)"),
|
|
312
|
+
title: z.string().optional().describe("New title"),
|
|
313
|
+
body: z.string().optional().describe("New body (markdown)"),
|
|
314
|
+
}, async (args) => {
|
|
315
|
+
try {
|
|
316
|
+
if (!args.title && args.body === undefined) {
|
|
317
|
+
return toolError("At least one of title or body must be provided");
|
|
318
|
+
}
|
|
319
|
+
const { projectNumber, projectOwner } = resolveFullConfig(client, args);
|
|
320
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
321
|
+
const vars = { draftIssueId: args.draftIssueId };
|
|
322
|
+
if (args.title !== undefined)
|
|
323
|
+
vars.title = args.title;
|
|
324
|
+
if (args.body !== undefined)
|
|
325
|
+
vars.body = args.body;
|
|
326
|
+
await client.projectMutate(`mutation($draftIssueId: ID!, $title: String, $body: String) {
|
|
327
|
+
updateProjectV2DraftIssue(input: {
|
|
328
|
+
draftIssueId: $draftIssueId,
|
|
329
|
+
title: $title,
|
|
330
|
+
body: $body
|
|
331
|
+
}) {
|
|
332
|
+
projectItem { id }
|
|
333
|
+
}
|
|
334
|
+
}`, vars);
|
|
335
|
+
return toolSuccess({
|
|
336
|
+
draftIssueId: args.draftIssueId,
|
|
337
|
+
updated: true,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
342
|
+
return toolError(`Failed to update draft issue: ${message}`);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
// -------------------------------------------------------------------------
|
|
346
|
+
// ralph_hero__reorder_item
|
|
347
|
+
// -------------------------------------------------------------------------
|
|
348
|
+
server.tool("ralph_hero__reorder_item", "Set item position within project views. Moves an issue before or after another item. Omit afterNumber to move to the top. Returns: number, position.", {
|
|
349
|
+
owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
|
|
350
|
+
repo: z.string().optional().describe("Repository name. Defaults to env var"),
|
|
351
|
+
number: z.number().describe("Issue number to reposition"),
|
|
352
|
+
afterNumber: z.number().optional()
|
|
353
|
+
.describe("Issue number to place after; omit to move to top"),
|
|
354
|
+
}, async (args) => {
|
|
355
|
+
try {
|
|
356
|
+
const { owner, repo, projectNumber, projectOwner } = resolveFullConfig(client, args);
|
|
357
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
358
|
+
const projectId = fieldCache.getProjectId();
|
|
359
|
+
if (!projectId) {
|
|
360
|
+
return toolError("Could not resolve project ID");
|
|
361
|
+
}
|
|
362
|
+
const itemId = await resolveProjectItemId(client, fieldCache, owner, repo, args.number);
|
|
363
|
+
let afterId;
|
|
364
|
+
if (args.afterNumber !== undefined) {
|
|
365
|
+
afterId = await resolveProjectItemId(client, fieldCache, owner, repo, args.afterNumber);
|
|
366
|
+
}
|
|
367
|
+
await client.projectMutate(`mutation($projectId: ID!, $itemId: ID!, $afterId: ID) {
|
|
368
|
+
updateProjectV2ItemPosition(input: {
|
|
369
|
+
projectId: $projectId,
|
|
370
|
+
itemId: $itemId,
|
|
371
|
+
afterId: $afterId
|
|
372
|
+
}) {
|
|
373
|
+
items(first: 1) { nodes { id } }
|
|
374
|
+
}
|
|
375
|
+
}`, { projectId, itemId, afterId: afterId ?? null });
|
|
376
|
+
return toolSuccess({
|
|
377
|
+
number: args.number,
|
|
378
|
+
position: args.afterNumber ? `after #${args.afterNumber}` : "top",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
383
|
+
return toolError(`Failed to reorder item: ${message}`);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// ralph_hero__update_project
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
server.tool("ralph_hero__update_project", "Update project settings — title, description, README, visibility, open/closed state. At least one field must be provided. Returns: projectId, updated, fields.", {
|
|
390
|
+
owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
|
|
391
|
+
repo: z.string().optional().describe("Repository name. Defaults to env var"),
|
|
392
|
+
title: z.string().optional().describe("New project title"),
|
|
393
|
+
shortDescription: z.string().optional().describe("Short summary for listings"),
|
|
394
|
+
readme: z.string().optional().describe("Full README in markdown"),
|
|
395
|
+
public: z.boolean().optional().describe("Visibility (true=public, false=private)"),
|
|
396
|
+
closed: z.boolean().optional().describe("Close (true) or reopen (false) the project"),
|
|
397
|
+
}, async (args) => {
|
|
398
|
+
try {
|
|
399
|
+
const updatedFields = [];
|
|
400
|
+
const vars = {};
|
|
401
|
+
const varDefs = ["$projectId: ID!"];
|
|
402
|
+
const inputFields = ["projectId: $projectId"];
|
|
403
|
+
if (args.title !== undefined) {
|
|
404
|
+
vars.updateTitle = args.title;
|
|
405
|
+
varDefs.push("$updateTitle: String");
|
|
406
|
+
inputFields.push("title: $updateTitle");
|
|
407
|
+
updatedFields.push("title");
|
|
408
|
+
}
|
|
409
|
+
if (args.shortDescription !== undefined) {
|
|
410
|
+
vars.shortDescription = args.shortDescription;
|
|
411
|
+
varDefs.push("$shortDescription: String");
|
|
412
|
+
inputFields.push("shortDescription: $shortDescription");
|
|
413
|
+
updatedFields.push("shortDescription");
|
|
414
|
+
}
|
|
415
|
+
if (args.readme !== undefined) {
|
|
416
|
+
vars.updateReadme = args.readme;
|
|
417
|
+
varDefs.push("$updateReadme: String");
|
|
418
|
+
inputFields.push("readme: $updateReadme");
|
|
419
|
+
updatedFields.push("readme");
|
|
420
|
+
}
|
|
421
|
+
if (args.public !== undefined) {
|
|
422
|
+
vars.publicVisibility = args.public;
|
|
423
|
+
varDefs.push("$publicVisibility: Boolean");
|
|
424
|
+
inputFields.push("public: $publicVisibility");
|
|
425
|
+
updatedFields.push("public");
|
|
426
|
+
}
|
|
427
|
+
if (args.closed !== undefined) {
|
|
428
|
+
vars.closedState = args.closed;
|
|
429
|
+
varDefs.push("$closedState: Boolean");
|
|
430
|
+
inputFields.push("closed: $closedState");
|
|
431
|
+
updatedFields.push("closed");
|
|
432
|
+
}
|
|
433
|
+
if (updatedFields.length === 0) {
|
|
434
|
+
return toolError("At least one field to update must be provided");
|
|
435
|
+
}
|
|
436
|
+
const { projectNumber, projectOwner } = resolveFullConfig(client, args);
|
|
437
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
438
|
+
const projectId = fieldCache.getProjectId();
|
|
439
|
+
if (!projectId) {
|
|
440
|
+
return toolError("Could not resolve project ID");
|
|
441
|
+
}
|
|
442
|
+
vars.projectId = projectId;
|
|
443
|
+
await client.projectMutate(`mutation(${varDefs.join(", ")}) {
|
|
444
|
+
updateProjectV2(input: {
|
|
445
|
+
${inputFields.join(",\n ")}
|
|
446
|
+
}) {
|
|
447
|
+
projectV2 { id title }
|
|
448
|
+
}
|
|
449
|
+
}`, vars);
|
|
450
|
+
return toolSuccess({
|
|
451
|
+
projectId,
|
|
452
|
+
updated: true,
|
|
453
|
+
fields: updatedFields,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
458
|
+
return toolError(`Failed to update project: ${message}`);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
// -------------------------------------------------------------------------
|
|
462
|
+
// ralph_hero__delete_field
|
|
463
|
+
// -------------------------------------------------------------------------
|
|
464
|
+
server.tool("ralph_hero__delete_field", "Delete a custom field from the project. Refuses to delete Ralph's required fields (Workflow State, Priority, Estimate, Status). Defaults to dry-run; set confirm=true to execute. Returns: field, deleted or action.", {
|
|
465
|
+
owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
|
|
466
|
+
repo: z.string().optional().describe("Repository name. Defaults to env var"),
|
|
467
|
+
field: z.string().describe("Name of the field to delete"),
|
|
468
|
+
confirm: z.boolean().optional().default(false)
|
|
469
|
+
.describe("Must be true to execute deletion; false for dry-run"),
|
|
470
|
+
}, async (args) => {
|
|
471
|
+
try {
|
|
472
|
+
if (PROTECTED_FIELDS.includes(args.field)) {
|
|
473
|
+
return toolError(`Cannot delete protected field "${args.field}". ` +
|
|
474
|
+
`Protected fields: ${PROTECTED_FIELDS.join(", ")}`);
|
|
475
|
+
}
|
|
476
|
+
const { projectNumber, projectOwner } = resolveFullConfig(client, args);
|
|
477
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
478
|
+
const projectId = fieldCache.getProjectId();
|
|
479
|
+
if (!projectId) {
|
|
480
|
+
return toolError("Could not resolve project ID");
|
|
481
|
+
}
|
|
482
|
+
const fieldId = fieldCache.getFieldId(args.field);
|
|
483
|
+
if (!fieldId) {
|
|
484
|
+
const validFields = fieldCache.getFieldNames();
|
|
485
|
+
return toolError(`Field "${args.field}" not found in project. ` +
|
|
486
|
+
`Valid fields: ${validFields.join(", ")}`);
|
|
487
|
+
}
|
|
488
|
+
if (!args.confirm) {
|
|
489
|
+
return toolSuccess({
|
|
490
|
+
field: args.field,
|
|
491
|
+
fieldId,
|
|
492
|
+
action: "would_delete",
|
|
493
|
+
confirm: false,
|
|
494
|
+
message: "Dry run. Set confirm=true to delete.",
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
await client.projectMutate(`mutation($projectId: ID!, $fieldId: ID!) {
|
|
498
|
+
deleteProjectV2Field(input: {
|
|
499
|
+
projectId: $projectId,
|
|
500
|
+
fieldId: $fieldId
|
|
501
|
+
}) {
|
|
502
|
+
projectV2Field {
|
|
503
|
+
... on ProjectV2SingleSelectField { id name }
|
|
504
|
+
... on ProjectV2Field { id name }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}`, { projectId, fieldId });
|
|
508
|
+
// Invalidate field cache since a field definition was removed
|
|
509
|
+
fieldCache.clear();
|
|
510
|
+
return toolSuccess({
|
|
511
|
+
field: args.field,
|
|
512
|
+
deleted: true,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
517
|
+
return toolError(`Failed to delete field: ${message}`);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
// -------------------------------------------------------------------------
|
|
521
|
+
// ralph_hero__update_collaborators
|
|
522
|
+
// -------------------------------------------------------------------------
|
|
523
|
+
server.tool("ralph_hero__update_collaborators", "Manage project collaborator access — add, update, or remove users/teams. Each entry needs exactly one of username or teamSlug. Team collaborators require an org-owned project. Returns: updated, collaboratorCount.", {
|
|
524
|
+
owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
|
|
525
|
+
repo: z.string().optional().describe("Repository name. Defaults to env var"),
|
|
526
|
+
collaborators: z.array(z.object({
|
|
527
|
+
username: z.string().optional().describe("GitHub username"),
|
|
528
|
+
teamSlug: z.string().optional().describe("Team slug (org projects only)"),
|
|
529
|
+
role: z.enum(["READER", "WRITER", "ADMIN", "NONE"])
|
|
530
|
+
.describe("Permission level (NONE removes access)"),
|
|
531
|
+
})).describe("List of collaborator changes"),
|
|
532
|
+
}, async (args) => {
|
|
533
|
+
try {
|
|
534
|
+
if (args.collaborators.length === 0) {
|
|
535
|
+
return toolError("At least one collaborator entry must be provided");
|
|
536
|
+
}
|
|
537
|
+
const { projectNumber, projectOwner } = resolveFullConfig(client, args);
|
|
538
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
539
|
+
const projectId = fieldCache.getProjectId();
|
|
540
|
+
if (!projectId) {
|
|
541
|
+
return toolError("Could not resolve project ID");
|
|
542
|
+
}
|
|
543
|
+
const resolvedCollaborators = [];
|
|
544
|
+
for (const entry of args.collaborators) {
|
|
545
|
+
if (entry.username && entry.teamSlug) {
|
|
546
|
+
return toolError(`Collaborator entry has both username ("${entry.username}") and ` +
|
|
547
|
+
`teamSlug ("${entry.teamSlug}"). Provide exactly one.`);
|
|
548
|
+
}
|
|
549
|
+
if (!entry.username && !entry.teamSlug) {
|
|
550
|
+
return toolError("Collaborator entry must have either username or teamSlug");
|
|
551
|
+
}
|
|
552
|
+
if (entry.username) {
|
|
553
|
+
const userResult = await client.query(`query($login: String!) { user(login: $login) { id } }`, { login: entry.username }, { cache: true, cacheTtlMs: 60 * 60 * 1000 });
|
|
554
|
+
if (!userResult.user) {
|
|
555
|
+
return toolError(`User "${entry.username}" not found`);
|
|
556
|
+
}
|
|
557
|
+
resolvedCollaborators.push({
|
|
558
|
+
userId: userResult.user.id,
|
|
559
|
+
role: entry.role,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
else if (entry.teamSlug) {
|
|
563
|
+
const teamResult = await client.query(`query($org: String!, $slug: String!) {
|
|
564
|
+
organization(login: $org) {
|
|
565
|
+
team(slug: $slug) { id }
|
|
566
|
+
}
|
|
567
|
+
}`, { org: projectOwner, slug: entry.teamSlug }, { cache: true, cacheTtlMs: 60 * 60 * 1000 });
|
|
568
|
+
if (!teamResult.organization) {
|
|
569
|
+
return toolError(`Team collaborators require an organization-owned project. ` +
|
|
570
|
+
`"${projectOwner}" is not an organization.`);
|
|
571
|
+
}
|
|
572
|
+
if (!teamResult.organization.team) {
|
|
573
|
+
return toolError(`Team "${entry.teamSlug}" not found in organization "${projectOwner}"`);
|
|
574
|
+
}
|
|
575
|
+
resolvedCollaborators.push({
|
|
576
|
+
teamId: teamResult.organization.team.id,
|
|
577
|
+
role: entry.role,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
await client.projectMutate(`mutation($projectId: ID!, $collaborators: [ProjectV2Collaborator!]!) {
|
|
582
|
+
updateProjectV2Collaborators(input: {
|
|
583
|
+
projectId: $projectId,
|
|
584
|
+
collaborators: $collaborators
|
|
585
|
+
}) {
|
|
586
|
+
collaborators { totalCount }
|
|
587
|
+
}
|
|
588
|
+
}`, { projectId, collaborators: resolvedCollaborators });
|
|
589
|
+
return toolSuccess({
|
|
590
|
+
updated: true,
|
|
591
|
+
collaboratorCount: args.collaborators.length,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
596
|
+
return toolError(`Failed to update collaborators: ${message}`);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
247
599
|
}
|
|
248
600
|
//# sourceMappingURL=project-management-tools.js.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { detectGroup } from "../lib/group-detection.js";
|
|
12
|
-
import { isValidState, isEarlierState, VALID_STATES, } from "../lib/workflow-states.js";
|
|
12
|
+
import { isValidState, isEarlierState, VALID_STATES, PARENT_GATE_STATES, isParentGateState, stateIndex, } from "../lib/workflow-states.js";
|
|
13
13
|
import { toolSuccess, toolError, resolveProjectOwner } from "../types.js";
|
|
14
14
|
import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, updateProjectItemField, getCurrentFieldValue, resolveConfig, syncStatusField, } from "../lib/helpers.js";
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -438,5 +438,159 @@ export function registerRelationshipTools(server, client, fieldCache) {
|
|
|
438
438
|
return toolError(`Failed to advance children: ${message}`);
|
|
439
439
|
}
|
|
440
440
|
});
|
|
441
|
+
// -------------------------------------------------------------------------
|
|
442
|
+
// ralph_hero__advance_parent
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
server.tool("ralph_hero__advance_parent", "Check if all siblings of a child issue have reached a gate state, and if so, advance the parent issue to match. Gate states: Ready for Plan, In Review, Done. Only applies to parent/child (sub-issue) relationships. Returns what changed or why the parent was not advanced.", {
|
|
445
|
+
owner: z
|
|
446
|
+
.string()
|
|
447
|
+
.optional()
|
|
448
|
+
.describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
|
|
449
|
+
repo: z
|
|
450
|
+
.string()
|
|
451
|
+
.optional()
|
|
452
|
+
.describe("Repository name. Defaults to GITHUB_REPO env var"),
|
|
453
|
+
number: z
|
|
454
|
+
.number()
|
|
455
|
+
.describe("Child issue number (any child in the group)"),
|
|
456
|
+
}, async (args) => {
|
|
457
|
+
try {
|
|
458
|
+
const { owner, repo } = resolveConfig(client, args);
|
|
459
|
+
const projectNumber = client.config.projectNumber;
|
|
460
|
+
if (!projectNumber) {
|
|
461
|
+
return toolError("projectNumber is required (set RALPH_GH_PROJECT_NUMBER env var)");
|
|
462
|
+
}
|
|
463
|
+
const projectOwner = resolveProjectOwner(client.config);
|
|
464
|
+
if (!projectOwner) {
|
|
465
|
+
return toolError("projectOwner is required (set RALPH_GH_PROJECT_OWNER or RALPH_GH_OWNER env var)");
|
|
466
|
+
}
|
|
467
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
468
|
+
// Fetch child issue to find its parent
|
|
469
|
+
const childResult = await client.query(`query($owner: String!, $repo: String!, $issueNumber: Int!) {
|
|
470
|
+
repository(owner: $owner, name: $repo) {
|
|
471
|
+
issue(number: $issueNumber) {
|
|
472
|
+
number
|
|
473
|
+
title
|
|
474
|
+
parent { number title state }
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}`, { owner, repo, issueNumber: args.number });
|
|
478
|
+
const childIssue = childResult.repository?.issue;
|
|
479
|
+
if (!childIssue) {
|
|
480
|
+
return toolError(`Issue #${args.number} not found in ${owner}/${repo}`);
|
|
481
|
+
}
|
|
482
|
+
if (!childIssue.parent) {
|
|
483
|
+
return toolSuccess({
|
|
484
|
+
advanced: false,
|
|
485
|
+
reason: "Issue has no parent",
|
|
486
|
+
child: { number: childIssue.number, title: childIssue.title },
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const parentNumber = childIssue.parent.number;
|
|
490
|
+
// Fetch all siblings (sub-issues of the parent)
|
|
491
|
+
const siblingResult = await client.query(`query($owner: String!, $repo: String!, $parentNum: Int!) {
|
|
492
|
+
repository(owner: $owner, name: $repo) {
|
|
493
|
+
issue(number: $parentNum) {
|
|
494
|
+
number
|
|
495
|
+
title
|
|
496
|
+
subIssues(first: 50) {
|
|
497
|
+
nodes { id number title state }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}`, { owner, repo, parentNum: parentNumber });
|
|
502
|
+
const parentIssue = siblingResult.repository?.issue;
|
|
503
|
+
if (!parentIssue) {
|
|
504
|
+
return toolError(`Parent issue #${parentNumber} not found in ${owner}/${repo}`);
|
|
505
|
+
}
|
|
506
|
+
const siblings = parentIssue.subIssues.nodes;
|
|
507
|
+
if (siblings.length === 0) {
|
|
508
|
+
return toolSuccess({
|
|
509
|
+
advanced: false,
|
|
510
|
+
reason: "Parent has no sub-issues",
|
|
511
|
+
parent: { number: parentNumber, title: parentIssue.title },
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
// Get workflow state for each sibling and find the minimum
|
|
515
|
+
const childStates = [];
|
|
516
|
+
let minStateIdx = Infinity;
|
|
517
|
+
for (const sibling of siblings) {
|
|
518
|
+
const currentState = await getCurrentFieldValue(client, fieldCache, owner, repo, sibling.number, "Workflow State");
|
|
519
|
+
const state = currentState || "unknown";
|
|
520
|
+
childStates.push({
|
|
521
|
+
number: sibling.number,
|
|
522
|
+
title: sibling.title,
|
|
523
|
+
workflowState: state,
|
|
524
|
+
});
|
|
525
|
+
const idx = stateIndex(state);
|
|
526
|
+
// States not in STATE_ORDER (Human Needed, Canceled, unknown) block advancement
|
|
527
|
+
if (idx === -1) {
|
|
528
|
+
return toolSuccess({
|
|
529
|
+
advanced: false,
|
|
530
|
+
reason: `Child #${sibling.number} is in state "${state}" which is outside the pipeline -- blocks parent advancement`,
|
|
531
|
+
parent: { number: parentNumber, title: parentIssue.title },
|
|
532
|
+
childStates,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
if (idx < minStateIdx) {
|
|
536
|
+
minStateIdx = idx;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Find the minimum state name
|
|
540
|
+
const minState = siblings.length > 0
|
|
541
|
+
? childStates.reduce((min, cs) => {
|
|
542
|
+
const idx = stateIndex(cs.workflowState);
|
|
543
|
+
const minIdx = stateIndex(min.workflowState);
|
|
544
|
+
return idx < minIdx ? cs : min;
|
|
545
|
+
}).workflowState
|
|
546
|
+
: "unknown";
|
|
547
|
+
// Check if the minimum state is a gate state
|
|
548
|
+
if (!isParentGateState(minState)) {
|
|
549
|
+
return toolSuccess({
|
|
550
|
+
advanced: false,
|
|
551
|
+
reason: "Not all children at a gate state",
|
|
552
|
+
minimumChildState: minState,
|
|
553
|
+
gateStates: [...PARENT_GATE_STATES],
|
|
554
|
+
parent: { number: parentNumber, title: parentIssue.title },
|
|
555
|
+
childStates,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
// Get parent's current workflow state
|
|
559
|
+
const parentState = await getCurrentFieldValue(client, fieldCache, owner, repo, parentNumber, "Workflow State");
|
|
560
|
+
// Check if parent is already at or past the target state
|
|
561
|
+
const parentIdx = stateIndex(parentState || "");
|
|
562
|
+
if (parentIdx >= minStateIdx) {
|
|
563
|
+
return toolSuccess({
|
|
564
|
+
advanced: false,
|
|
565
|
+
reason: "Parent already at or past target state",
|
|
566
|
+
parent: {
|
|
567
|
+
number: parentNumber,
|
|
568
|
+
title: parentIssue.title,
|
|
569
|
+
currentState: parentState,
|
|
570
|
+
},
|
|
571
|
+
targetState: minState,
|
|
572
|
+
childStates,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
// Advance the parent
|
|
576
|
+
const projectItemId = await resolveProjectItemId(client, fieldCache, owner, repo, parentNumber);
|
|
577
|
+
await updateProjectItemField(client, fieldCache, projectItemId, "Workflow State", minState);
|
|
578
|
+
await syncStatusField(client, fieldCache, projectItemId, minState);
|
|
579
|
+
return toolSuccess({
|
|
580
|
+
advanced: true,
|
|
581
|
+
parent: {
|
|
582
|
+
number: parentNumber,
|
|
583
|
+
title: parentIssue.title,
|
|
584
|
+
fromState: parentState || "unknown",
|
|
585
|
+
toState: minState,
|
|
586
|
+
},
|
|
587
|
+
childStates,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
592
|
+
return toolError(`Failed to advance parent: ${message}`);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
441
595
|
}
|
|
442
596
|
//# sourceMappingURL=relationship-tools.js.map
|