ralph-hero-mcp-server 2.4.8 → 2.4.10

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
@@ -18,6 +18,7 @@ import { registerRelationshipTools } from "./tools/relationship-tools.js";
18
18
  import { registerDashboardTools } from "./tools/dashboard-tools.js";
19
19
  import { registerBatchTools } from "./tools/batch-tools.js";
20
20
  import { registerProjectManagementTools } from "./tools/project-management-tools.js";
21
+ import { registerHygieneTools } from "./tools/hygiene-tools.js";
21
22
  /**
22
23
  * Initialize the GitHub client from environment variables.
23
24
  */
@@ -245,6 +246,8 @@ async function main() {
245
246
  registerBatchTools(server, client, fieldCache);
246
247
  // Project management tools (archive, remove, add, link repo, clear field)
247
248
  registerProjectManagementTools(server, client, fieldCache);
249
+ // Hygiene reporting tools
250
+ registerHygieneTools(server, client, fieldCache);
248
251
  // Connect via stdio transport
249
252
  const transport = new StdioServerTransport();
250
253
  await server.connect(transport);
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Project hygiene report — pure functions.
3
+ *
4
+ * All functions are side-effect-free: DashboardItems in, report data out.
5
+ * I/O (GraphQL fetching) lives in tools/hygiene-tools.ts.
6
+ */
7
+ import { TERMINAL_STATES } from "./workflow-states.js";
8
+ export const DEFAULT_HYGIENE_CONFIG = {
9
+ archiveDays: 14,
10
+ staleDays: 7,
11
+ orphanDays: 14,
12
+ wipLimits: {},
13
+ };
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+ function ageDays(timestamp, now) {
18
+ return Math.max(0, (now - new Date(timestamp).getTime()) / (1000 * 60 * 60 * 24));
19
+ }
20
+ function toHygieneItem(item, now) {
21
+ return {
22
+ number: item.number,
23
+ title: item.title,
24
+ workflowState: item.workflowState,
25
+ ageDays: Math.round(ageDays(item.updatedAt, now) * 10) / 10,
26
+ };
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // Section functions
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * Items in terminal states (Done/Canceled) older than archiveDays.
33
+ */
34
+ export function findArchiveCandidates(items, now, archiveDays) {
35
+ return items
36
+ .filter((item) => {
37
+ const ws = item.workflowState;
38
+ if (!ws || !TERMINAL_STATES.includes(ws))
39
+ return false;
40
+ const ts = item.closedAt ?? item.updatedAt;
41
+ return ageDays(ts, now) > archiveDays;
42
+ })
43
+ .map((item) => toHygieneItem(item, now));
44
+ }
45
+ /**
46
+ * Non-terminal items not updated for more than staleDays.
47
+ */
48
+ export function findStaleItems(items, now, staleDays) {
49
+ return items
50
+ .filter((item) => {
51
+ const ws = item.workflowState;
52
+ if (ws && TERMINAL_STATES.includes(ws))
53
+ return false;
54
+ return ageDays(item.updatedAt, now) > staleDays;
55
+ })
56
+ .map((item) => toHygieneItem(item, now));
57
+ }
58
+ /**
59
+ * Backlog items with no assignee older than orphanDays.
60
+ */
61
+ export function findOrphanedItems(items, now, orphanDays) {
62
+ return items
63
+ .filter((item) => {
64
+ if (item.workflowState !== "Backlog")
65
+ return false;
66
+ if (item.assignees.length > 0)
67
+ return false;
68
+ return ageDays(item.updatedAt, now) > orphanDays;
69
+ })
70
+ .map((item) => toHygieneItem(item, now));
71
+ }
72
+ /**
73
+ * Non-terminal items missing estimate or priority.
74
+ */
75
+ export function findFieldGaps(items, now) {
76
+ const nonTerminal = items.filter((item) => {
77
+ const ws = item.workflowState;
78
+ return !ws || !TERMINAL_STATES.includes(ws);
79
+ });
80
+ return {
81
+ missingEstimate: nonTerminal
82
+ .filter((item) => item.estimate === null)
83
+ .map((item) => toHygieneItem(item, now)),
84
+ missingPriority: nonTerminal
85
+ .filter((item) => item.priority === null)
86
+ .map((item) => toHygieneItem(item, now)),
87
+ };
88
+ }
89
+ /**
90
+ * States where item count exceeds configured WIP limit.
91
+ */
92
+ export function findWipViolations(items, now, wipLimits) {
93
+ const violations = [];
94
+ for (const [state, limit] of Object.entries(wipLimits)) {
95
+ const stateItems = items.filter((item) => item.workflowState === state);
96
+ if (stateItems.length > limit) {
97
+ violations.push({
98
+ state,
99
+ count: stateItems.length,
100
+ limit,
101
+ items: stateItems.map((item) => toHygieneItem(item, now)),
102
+ });
103
+ }
104
+ }
105
+ return violations;
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Orchestrator
109
+ // ---------------------------------------------------------------------------
110
+ /**
111
+ * Build a complete hygiene report from project items.
112
+ */
113
+ export function buildHygieneReport(items, config = DEFAULT_HYGIENE_CONFIG, now = Date.now()) {
114
+ const archiveCandidates = findArchiveCandidates(items, now, config.archiveDays);
115
+ const staleItems = findStaleItems(items, now, config.staleDays);
116
+ const orphanedItems = findOrphanedItems(items, now, config.orphanDays);
117
+ const fieldGaps = findFieldGaps(items, now);
118
+ const wipViolations = findWipViolations(items, now, config.wipLimits);
119
+ // Field coverage: % of non-terminal items with both estimate AND priority
120
+ const nonTerminal = items.filter((item) => {
121
+ const ws = item.workflowState;
122
+ return !ws || !TERMINAL_STATES.includes(ws);
123
+ });
124
+ const withBothFields = nonTerminal.filter((item) => item.estimate !== null && item.priority !== null);
125
+ const fieldCoveragePercent = nonTerminal.length > 0
126
+ ? Math.round((withBothFields.length / nonTerminal.length) * 100)
127
+ : 100;
128
+ return {
129
+ generatedAt: new Date(now).toISOString(),
130
+ totalItems: items.length,
131
+ archiveCandidates,
132
+ staleItems,
133
+ orphanedItems,
134
+ fieldGaps,
135
+ wipViolations,
136
+ summary: {
137
+ archiveCandidateCount: archiveCandidates.length,
138
+ staleCount: staleItems.length,
139
+ orphanCount: orphanedItems.length,
140
+ fieldCoveragePercent,
141
+ wipViolationCount: wipViolations.length,
142
+ },
143
+ };
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // Formatters
147
+ // ---------------------------------------------------------------------------
148
+ function formatItemRow(item) {
149
+ return `| #${item.number} | ${item.title} | ${item.workflowState ?? "\u2014"} | ${item.ageDays}d |`;
150
+ }
151
+ /**
152
+ * Render hygiene report as markdown.
153
+ */
154
+ export function formatHygieneMarkdown(report) {
155
+ const lines = [];
156
+ lines.push("# Project Hygiene Report");
157
+ lines.push(`_Generated: ${report.generatedAt}_`);
158
+ lines.push("");
159
+ lines.push(`**Total items**: ${report.totalItems}`);
160
+ lines.push("");
161
+ // Summary
162
+ lines.push("## Summary");
163
+ lines.push(`- Archive candidates: ${report.summary.archiveCandidateCount}`);
164
+ lines.push(`- Stale items: ${report.summary.staleCount}`);
165
+ lines.push(`- Orphaned items: ${report.summary.orphanCount}`);
166
+ lines.push(`- Field coverage: ${report.summary.fieldCoveragePercent}%`);
167
+ lines.push(`- WIP violations: ${report.summary.wipViolationCount}`);
168
+ lines.push("");
169
+ // Archive candidates
170
+ if (report.archiveCandidates.length > 0) {
171
+ lines.push("## Archive Candidates");
172
+ lines.push("| Issue | Title | State | Age |");
173
+ lines.push("|-------|-------|-------|-----|");
174
+ for (const item of report.archiveCandidates) {
175
+ lines.push(formatItemRow(item));
176
+ }
177
+ lines.push("");
178
+ }
179
+ // Stale items
180
+ if (report.staleItems.length > 0) {
181
+ lines.push("## Stale Items");
182
+ lines.push("| Issue | Title | State | Age |");
183
+ lines.push("|-------|-------|-------|-----|");
184
+ for (const item of report.staleItems) {
185
+ lines.push(formatItemRow(item));
186
+ }
187
+ lines.push("");
188
+ }
189
+ // Orphaned items
190
+ if (report.orphanedItems.length > 0) {
191
+ lines.push("## Orphaned Items");
192
+ lines.push("| Issue | Title | State | Age |");
193
+ lines.push("|-------|-------|-------|-----|");
194
+ for (const item of report.orphanedItems) {
195
+ lines.push(formatItemRow(item));
196
+ }
197
+ lines.push("");
198
+ }
199
+ // Field gaps
200
+ const totalGaps = report.fieldGaps.missingEstimate.length +
201
+ report.fieldGaps.missingPriority.length;
202
+ if (totalGaps > 0) {
203
+ lines.push("## Field Gaps");
204
+ if (report.fieldGaps.missingEstimate.length > 0) {
205
+ lines.push("### Missing Estimate");
206
+ lines.push("| Issue | Title | State | Age |");
207
+ lines.push("|-------|-------|-------|-----|");
208
+ for (const item of report.fieldGaps.missingEstimate) {
209
+ lines.push(formatItemRow(item));
210
+ }
211
+ lines.push("");
212
+ }
213
+ if (report.fieldGaps.missingPriority.length > 0) {
214
+ lines.push("### Missing Priority");
215
+ lines.push("| Issue | Title | State | Age |");
216
+ lines.push("|-------|-------|-------|-----|");
217
+ for (const item of report.fieldGaps.missingPriority) {
218
+ lines.push(formatItemRow(item));
219
+ }
220
+ lines.push("");
221
+ }
222
+ }
223
+ // WIP violations
224
+ if (report.wipViolations.length > 0) {
225
+ lines.push("## WIP Violations");
226
+ for (const v of report.wipViolations) {
227
+ lines.push(`### ${v.state}: ${v.count} items (limit: ${v.limit})`);
228
+ lines.push("| Issue | Title | State | Age |");
229
+ lines.push("|-------|-------|-------|-----|");
230
+ for (const item of v.items) {
231
+ lines.push(formatItemRow(item));
232
+ }
233
+ lines.push("");
234
+ }
235
+ }
236
+ return lines.join("\n");
237
+ }
238
+ //# sourceMappingURL=hygiene.js.map
@@ -101,6 +101,28 @@ export function buildBatchFieldValueQuery(projectItemIds) {
101
101
  const queryString = `query(${varDecls.join(", ")}) {\n ${aliases.join("\n ")}\n}`;
102
102
  return { queryString, variables };
103
103
  }
104
+ /**
105
+ * Build an aliased mutation to archive multiple project items
106
+ * in a single GraphQL call.
107
+ */
108
+ export function buildBatchArchiveMutation(projectId, itemIds) {
109
+ const variables = { projectId };
110
+ const varDecls = ["$projectId: ID!"];
111
+ const aliases = [];
112
+ for (let i = 0; i < itemIds.length; i++) {
113
+ const itemVar = `item_a${i}`;
114
+ varDecls.push(`$${itemVar}: ID!`);
115
+ variables[itemVar] = itemIds[i];
116
+ aliases.push(`a${i}: archiveProjectV2Item(input: {
117
+ projectId: $projectId,
118
+ itemId: $${itemVar}
119
+ }) {
120
+ item { id }
121
+ }`);
122
+ }
123
+ const mutationString = `mutation(${varDecls.join(", ")}) {\n ${aliases.join("\n ")}\n}`;
124
+ return { mutationString, variables };
125
+ }
104
126
  // ---------------------------------------------------------------------------
105
127
  // Constants
106
128
  // ---------------------------------------------------------------------------
@@ -69,7 +69,7 @@ function getFieldValue(item, fieldName) {
69
69
  /**
70
70
  * Convert raw GraphQL project items to DashboardItem[].
71
71
  */
72
- function toDashboardItems(raw) {
72
+ export function toDashboardItems(raw) {
73
73
  const items = [];
74
74
  for (const r of raw) {
75
75
  // Only include issues (not PRs or drafts)
@@ -94,7 +94,7 @@ function toDashboardItems(raw) {
94
94
  // ---------------------------------------------------------------------------
95
95
  // GraphQL query for dashboard items
96
96
  // ---------------------------------------------------------------------------
97
- const DASHBOARD_ITEMS_QUERY = `query($projectId: ID!, $cursor: String, $first: Int!) {
97
+ export const DASHBOARD_ITEMS_QUERY = `query($projectId: ID!, $cursor: String, $first: Int!) {
98
98
  node(id: $projectId) {
99
99
  ... on ProjectV2 {
100
100
  items(first: $first, after: $cursor) {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * MCP tool for project board hygiene reporting.
3
+ *
4
+ * Provides a single `ralph_hero__project_hygiene` tool that
5
+ * identifies archive candidates, stale items, orphaned entries,
6
+ * field gaps, and WIP violations.
7
+ */
8
+ import { z } from "zod";
9
+ import { paginateConnection } from "../lib/pagination.js";
10
+ import { ensureFieldCache } from "../lib/helpers.js";
11
+ import { buildHygieneReport, formatHygieneMarkdown, DEFAULT_HYGIENE_CONFIG, } from "../lib/hygiene.js";
12
+ import { DASHBOARD_ITEMS_QUERY, toDashboardItems, } from "./dashboard-tools.js";
13
+ import { toolSuccess, toolError, resolveProjectOwner } from "../types.js";
14
+ // ---------------------------------------------------------------------------
15
+ // Register hygiene tools
16
+ // ---------------------------------------------------------------------------
17
+ export function registerHygieneTools(server, client, fieldCache) {
18
+ server.tool("ralph_hero__project_hygiene", "Generate a project board hygiene report. Identifies archive candidates, stale items, orphaned backlog entries, missing fields, and WIP violations. Returns: report with 6 sections + summary stats.", {
19
+ owner: z
20
+ .string()
21
+ .optional()
22
+ .describe("GitHub owner. Defaults to env var"),
23
+ archiveDays: z
24
+ .number()
25
+ .optional()
26
+ .default(14)
27
+ .describe("Days before Done/Canceled items become archive candidates (default: 14)"),
28
+ staleDays: z
29
+ .number()
30
+ .optional()
31
+ .default(7)
32
+ .describe("Days before non-terminal items are flagged as stale (default: 7)"),
33
+ orphanDays: z
34
+ .number()
35
+ .optional()
36
+ .default(14)
37
+ .describe("Days before unassigned Backlog items are flagged as orphaned (default: 14)"),
38
+ wipLimits: z
39
+ .record(z.number())
40
+ .optional()
41
+ .describe('Per-state WIP limits, e.g. { "In Progress": 3 }'),
42
+ format: z
43
+ .enum(["json", "markdown"])
44
+ .optional()
45
+ .default("json")
46
+ .describe("Output format (default: json)"),
47
+ }, async (args) => {
48
+ try {
49
+ const owner = args.owner || resolveProjectOwner(client.config);
50
+ const projectNumber = client.config.projectNumber;
51
+ if (!owner) {
52
+ return toolError("owner is required");
53
+ }
54
+ if (!projectNumber) {
55
+ return toolError("project number is required");
56
+ }
57
+ // Ensure field cache
58
+ await ensureFieldCache(client, fieldCache, owner, projectNumber);
59
+ const projectId = fieldCache.getProjectId();
60
+ if (!projectId) {
61
+ return toolError("Could not resolve project ID");
62
+ }
63
+ // Fetch all project items (reuse dashboard query)
64
+ const result = await paginateConnection((q, v) => client.projectQuery(q, v), DASHBOARD_ITEMS_QUERY, { projectId, first: 100 }, "node.items", { maxItems: 500 });
65
+ // Convert to dashboard items
66
+ const dashboardItems = toDashboardItems(result.nodes);
67
+ // Build hygiene config
68
+ const hygieneConfig = {
69
+ ...DEFAULT_HYGIENE_CONFIG,
70
+ archiveDays: args.archiveDays ?? 14,
71
+ staleDays: args.staleDays ?? 7,
72
+ orphanDays: args.orphanDays ?? 14,
73
+ wipLimits: args.wipLimits ?? {},
74
+ };
75
+ // Build report
76
+ const report = buildHygieneReport(dashboardItems, hygieneConfig);
77
+ // Format output
78
+ if (args.format === "markdown") {
79
+ return toolSuccess({
80
+ ...report,
81
+ formatted: formatHygieneMarkdown(report),
82
+ });
83
+ }
84
+ return toolSuccess(report);
85
+ }
86
+ catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ return toolError(`Failed to generate hygiene report: ${message}`);
89
+ }
90
+ });
91
+ }
92
+ //# sourceMappingURL=hygiene-tools.js.map
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { z } from "zod";
9
9
  import { toolSuccess, toolError } from "../types.js";
10
+ import { paginateConnection } from "../lib/pagination.js";
11
+ import { buildBatchArchiveMutation } from "./batch-tools.js";
10
12
  import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, resolveFullConfig, updateProjectItemField, } from "../lib/helpers.js";
11
13
  // ---------------------------------------------------------------------------
12
14
  // Constants
@@ -752,5 +754,117 @@ export function registerProjectManagementTools(server, client, fieldCache) {
752
754
  return toolError(`Failed to delete status update: ${message}`);
753
755
  }
754
756
  });
757
+ // -------------------------------------------------------------------------
758
+ // ralph_hero__bulk_archive
759
+ // -------------------------------------------------------------------------
760
+ server.tool("ralph_hero__bulk_archive", "Archive multiple project items matching workflow state filter. Uses aliased GraphQL mutations for efficiency (chunked at 50). Archived items are hidden from views but not deleted. Returns: archivedCount, items, errors.", {
761
+ owner: z.string().optional().describe("GitHub owner. Defaults to env var"),
762
+ repo: z.string().optional().describe("Repository name. Defaults to env var"),
763
+ workflowStates: z
764
+ .array(z.string())
765
+ .min(1)
766
+ .describe('Workflow states to archive (e.g., ["Done", "Canceled"])'),
767
+ maxItems: z
768
+ .number()
769
+ .optional()
770
+ .default(50)
771
+ .describe("Max items to archive per invocation (default 50, cap 200)"),
772
+ }, async (args) => {
773
+ try {
774
+ const { projectNumber, projectOwner } = resolveFullConfig(client, args);
775
+ await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
776
+ const projectId = fieldCache.getProjectId();
777
+ if (!projectId) {
778
+ return toolError("Could not resolve project ID");
779
+ }
780
+ const effectiveMax = Math.min(args.maxItems || 50, 200);
781
+ // Query project items with field values
782
+ const itemsResult = await paginateConnection((q, v) => client.projectQuery(q, v), `query($projectId: ID!, $cursor: String, $first: Int!) {
783
+ node(id: $projectId) {
784
+ ... on ProjectV2 {
785
+ items(first: $first, after: $cursor) {
786
+ totalCount
787
+ pageInfo { hasNextPage endCursor }
788
+ nodes {
789
+ id
790
+ type
791
+ content {
792
+ ... on Issue {
793
+ number
794
+ title
795
+ }
796
+ ... on PullRequest {
797
+ number
798
+ title
799
+ }
800
+ }
801
+ fieldValues(first: 20) {
802
+ nodes {
803
+ ... on ProjectV2ItemFieldSingleSelectValue {
804
+ __typename
805
+ name
806
+ field { ... on ProjectV2FieldCommon { name } }
807
+ }
808
+ }
809
+ }
810
+ }
811
+ }
812
+ }
813
+ }
814
+ }`, { projectId, first: 100 }, "node.items", { maxItems: effectiveMax * 3 });
815
+ // Filter by workflow state
816
+ const matched = itemsResult.nodes
817
+ .filter((item) => {
818
+ const ws = getBulkArchiveFieldValue(item, "Workflow State");
819
+ return ws && args.workflowStates.includes(ws);
820
+ })
821
+ .slice(0, effectiveMax);
822
+ if (matched.length === 0) {
823
+ return toolSuccess({
824
+ archivedCount: 0,
825
+ items: [],
826
+ errors: [],
827
+ });
828
+ }
829
+ // Chunk and execute archive mutations
830
+ const ARCHIVE_CHUNK_SIZE = 50;
831
+ const itemIds = matched.map((m) => m.id);
832
+ const archived = [];
833
+ const errors = [];
834
+ for (let i = 0; i < itemIds.length; i += ARCHIVE_CHUNK_SIZE) {
835
+ const chunk = itemIds.slice(i, i + ARCHIVE_CHUNK_SIZE);
836
+ const chunkItems = matched.slice(i, i + ARCHIVE_CHUNK_SIZE);
837
+ try {
838
+ const { mutationString, variables } = buildBatchArchiveMutation(projectId, chunk);
839
+ await client.projectMutate(mutationString, variables);
840
+ for (const item of chunkItems) {
841
+ archived.push({
842
+ number: item.content?.number,
843
+ title: item.content?.title,
844
+ itemId: item.id,
845
+ });
846
+ }
847
+ }
848
+ catch (error) {
849
+ const msg = error instanceof Error ? error.message : String(error);
850
+ errors.push(`Chunk ${Math.floor(i / ARCHIVE_CHUNK_SIZE) + 1} failed: ${msg}`);
851
+ }
852
+ }
853
+ return toolSuccess({
854
+ archivedCount: archived.length,
855
+ items: archived,
856
+ errors,
857
+ });
858
+ }
859
+ catch (error) {
860
+ const message = error instanceof Error ? error.message : String(error);
861
+ return toolError(`Failed to bulk archive: ${message}`);
862
+ }
863
+ });
864
+ }
865
+ function getBulkArchiveFieldValue(item, fieldName) {
866
+ const fieldValue = item.fieldValues.nodes.find((fv) => fv.field?.name === fieldName &&
867
+ fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
868
+ return fieldValue?.name;
755
869
  }
756
870
  //# sourceMappingURL=project-management-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.4.8",
3
+ "version": "2.4.10",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",