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
|