ralph-hero-mcp-server 2.4.9 → 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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.4.9",
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",