@programinglive/dev-workflow-mcp-server 1.0.4 → 1.0.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [1.0.7](https://github.com/programinglive/dev-workflow-mcp-server/compare/v1.0.6...v1.0.7) (2025-10-20)
6
+
7
+ ### [1.0.6](https://github.com/programinglive/dev-workflow-mcp-server/compare/v1.0.5...v1.0.6) (2025-10-20)
8
+
9
+
10
+ ### ✅ Tests
11
+
12
+ * enforce clean release workflow ([c261a7b](https://github.com/programinglive/dev-workflow-mcp-server/commit/c261a7b3d141ba4472567bd47e4f6b6e4d2ee3fd))
13
+
14
+ ### [1.0.5](https://github.com/programinglive/dev-workflow-mcp-server/compare/v1.0.4...v1.0.5) (2025-10-20)
15
+
16
+
17
+ ### ✅ Tests
18
+
19
+ * update index.js and tests/workflowState.test.js ([b9df3c8](https://github.com/programinglive/dev-workflow-mcp-server/commit/b9df3c8643303769dbe4c5a0992995b6328cdfcf))
20
+
5
21
  ### [1.0.4](https://github.com/programinglive/dev-workflow-mcp-server/compare/v1.0.3...v1.0.4) (2025-10-20)
6
22
 
7
23
 
package/index.js CHANGED
@@ -186,6 +186,279 @@ async function hasWorkingChanges() {
186
186
  }
187
187
  }
188
188
 
189
+ export function workingTreeSummary(statusOutput) {
190
+ if (typeof statusOutput !== "string" || statusOutput.trim() === "") {
191
+ return {
192
+ hasChanges: false,
193
+ lines: [],
194
+ };
195
+ }
196
+
197
+ const lines = statusOutput
198
+ .split("\n")
199
+ .map((line) => line.replace(/\s+/g, " ").trim())
200
+ .filter(Boolean);
201
+
202
+ return {
203
+ hasChanges: lines.length > 0,
204
+ lines,
205
+ };
206
+ }
207
+
208
+ function isTestFilePath(filePath) {
209
+ if (!filePath) {
210
+ return false;
211
+ }
212
+
213
+ if (/\.(test|spec)\.[^.]+$/i.test(filePath)) {
214
+ return true;
215
+ }
216
+
217
+ return /(\/|^)(__tests__|tests?|spec)\//i.test(filePath);
218
+ }
219
+
220
+ function extractPathFromStatusLine(line) {
221
+ if (!line || line.length < 4) {
222
+ return "";
223
+ }
224
+
225
+ const rawPath = line.slice(3).trim();
226
+ if (!rawPath) {
227
+ return "";
228
+ }
229
+
230
+ if (rawPath.includes(" -> ")) {
231
+ const parts = rawPath.split(" -> ");
232
+ return parts[parts.length - 1].trim();
233
+ }
234
+
235
+ return rawPath;
236
+ }
237
+
238
+ export function containsTestFilesInStatus(statusOutput) {
239
+ if (typeof statusOutput !== "string" || statusOutput.trim() === "") {
240
+ return false;
241
+ }
242
+
243
+ return statusOutput
244
+ .split("\n")
245
+ .map((line) => extractPathFromStatusLine(line))
246
+ .some((path) => isTestFilePath(path));
247
+ }
248
+
249
+ function isDocumentationFilePath(filePath) {
250
+ if (!filePath) {
251
+ return false;
252
+ }
253
+
254
+ const normalized = filePath.toLowerCase();
255
+ return (
256
+ normalized.startsWith("docs/") ||
257
+ normalized.startsWith("documentation/") ||
258
+ normalized.endsWith(".md") ||
259
+ normalized.endsWith(".rst") ||
260
+ normalized.endsWith(".adoc") ||
261
+ normalized.includes("readme")
262
+ );
263
+ }
264
+
265
+ function parseNameStatusLine(line) {
266
+ if (!line) {
267
+ return null;
268
+ }
269
+
270
+ const parts = line.split("\t").map((part) => part.trim());
271
+ const status = parts[0];
272
+ if (!status) {
273
+ return null;
274
+ }
275
+
276
+ if (status.startsWith("R") || status.startsWith("C")) {
277
+ const originalPath = parts[1] || "";
278
+ const newPath = parts[2] || originalPath;
279
+ return {
280
+ status,
281
+ path: newPath,
282
+ originalPath,
283
+ };
284
+ }
285
+
286
+ return {
287
+ status,
288
+ path: parts[1] || "",
289
+ originalPath: "",
290
+ };
291
+ }
292
+
293
+ function formatFileList(paths) {
294
+ const uniquePaths = [...new Set(paths.filter(Boolean))];
295
+
296
+ if (uniquePaths.length === 0) {
297
+ return "updates";
298
+ }
299
+
300
+ if (uniquePaths.length === 1) {
301
+ return uniquePaths[0];
302
+ }
303
+
304
+ if (uniquePaths.length === 2) {
305
+ return `${uniquePaths[0]} and ${uniquePaths[1]}`;
306
+ }
307
+
308
+ if (uniquePaths.length === 3) {
309
+ return `${uniquePaths[0]}, ${uniquePaths[1]}, and ${uniquePaths[2]}`;
310
+ }
311
+
312
+ const remaining = uniquePaths.length - 3;
313
+ return `${uniquePaths[0]}, ${uniquePaths[1]}, ${uniquePaths[2]} +${remaining} more`;
314
+ }
315
+
316
+ function determineCommitType(changes) {
317
+ const hasTestChange = changes.some((change) =>
318
+ [change.path, change.originalPath].some((p) => isTestFilePath(p))
319
+ );
320
+ const hasDocsChange = changes.some((change) =>
321
+ [change.path, change.originalPath].some((p) => isDocumentationFilePath(p))
322
+ );
323
+
324
+ if (hasTestChange) {
325
+ return "test";
326
+ }
327
+
328
+ if (hasDocsChange) {
329
+ return "docs";
330
+ }
331
+
332
+ return "chore";
333
+ }
334
+
335
+ function describeChange(change) {
336
+ const statusCode = change.status || "";
337
+ const path = change.path || change.originalPath || "";
338
+
339
+ if (statusCode.startsWith("R")) {
340
+ return `renamed ${change.originalPath} -> ${change.path}`;
341
+ }
342
+
343
+ if (statusCode.startsWith("C")) {
344
+ return `copied ${change.originalPath} -> ${change.path}`;
345
+ }
346
+
347
+ switch (statusCode[0]) {
348
+ case "A":
349
+ return `added ${path}`;
350
+ case "M":
351
+ return `modified ${path}`;
352
+ case "D":
353
+ return `removed ${path}`;
354
+ case "U":
355
+ return `updated ${path}`;
356
+ default:
357
+ return `${statusCode} ${path}`.trim();
358
+ }
359
+ }
360
+
361
+ export function createCommitMessageParts(changes, providedSummary = "") {
362
+ const summaryInput = typeof providedSummary === "string" ? providedSummary.trim() : "";
363
+
364
+ if (!Array.isArray(changes) || changes.length === 0) {
365
+ return {
366
+ summary: summaryInput,
367
+ body: "",
368
+ };
369
+ }
370
+
371
+ const commitType = determineCommitType(changes);
372
+ const summaryPaths = changes
373
+ .map((change) => change.path || change.originalPath)
374
+ .filter(Boolean);
375
+ const generatedSummary = `${commitType}: update ${formatFileList(summaryPaths)}`;
376
+ const summary = summaryInput || generatedSummary;
377
+
378
+ const detailLines = changes
379
+ .map((change) => describeChange(change))
380
+ .filter((line) => line && line.trim().length > 0)
381
+ .map((line) => `- ${line}`);
382
+
383
+ return {
384
+ summary,
385
+ body: detailLines.join("\n"),
386
+ };
387
+ }
388
+
389
+ async function getStagedChanges() {
390
+ try {
391
+ const { stdout } = await exec("git diff --cached --name-status");
392
+ return stdout
393
+ .split("\n")
394
+ .map((line) => parseNameStatusLine(line))
395
+ .filter(Boolean);
396
+ } catch (error) {
397
+ return [];
398
+ }
399
+ }
400
+
401
+ async function hasTestChanges() {
402
+ try {
403
+ const { stdout } = await exec("git status --porcelain");
404
+ return containsTestFilesInStatus(stdout);
405
+ } catch (error) {
406
+ return false;
407
+ }
408
+ }
409
+
410
+ function normalizeCommitHeader(message) {
411
+ if (typeof message !== "string") {
412
+ return "";
413
+ }
414
+
415
+ const header = message.split("\n")[0] || "";
416
+ return header.trim();
417
+ }
418
+
419
+ function containsBreakingChange(message) {
420
+ if (typeof message !== "string") {
421
+ return false;
422
+ }
423
+
424
+ return /breaking change/i.test(message);
425
+ }
426
+
427
+ export function determineReleaseTypeFromCommit(message) {
428
+ const header = normalizeCommitHeader(message).toLowerCase();
429
+ const hasBreaking = containsBreakingChange(message) || /!/.test(header.split(":")[0] || "");
430
+
431
+ if (hasBreaking) {
432
+ return "major";
433
+ }
434
+
435
+ const typeMatch = header.match(/^([a-z]+)(\(|:)/i);
436
+ const commitType = typeMatch ? typeMatch[1] : "";
437
+
438
+ if (commitType === "feat") {
439
+ return "minor";
440
+ }
441
+
442
+ if (commitType === "fix" || commitType === "perf") {
443
+ return "patch";
444
+ }
445
+
446
+ if (!commitType && containsBreakingChange(message)) {
447
+ return "major";
448
+ }
449
+
450
+ return "patch";
451
+ }
452
+
453
+ async function getLastCommitMessage() {
454
+ try {
455
+ const { stdout } = await exec("git log -1 --pretty=%B");
456
+ return stdout.trim();
457
+ } catch (error) {
458
+ return "";
459
+ }
460
+ }
461
+
189
462
  async function getCurrentBranch() {
190
463
  try {
191
464
  const { stdout } = await exec("git rev-parse --abbrev-ref HEAD");
@@ -325,7 +598,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
325
598
  "Optional branch name to push to (defaults to current branch)",
326
599
  },
327
600
  },
328
- required: ["commitMessage"],
601
+ required: [],
329
602
  },
330
603
  },
331
604
  {
@@ -671,26 +944,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
671
944
  };
672
945
  }
673
946
 
674
- const commitMessage =
947
+ const providedCommitMessage =
675
948
  typeof args.commitMessage === "string" ? args.commitMessage.trim() : "";
676
949
 
677
- if (!commitMessage) {
950
+ if (!(await hasWorkingChanges())) {
678
951
  return {
679
952
  content: [
680
953
  {
681
954
  type: "text",
682
- text: "⚠️ Please provide a non-empty 'commitMessage' when using 'commit_and_push'.",
955
+ text: "⚠️ No changes detected. Make sure you have modifications to commit before running 'commit_and_push'.",
683
956
  },
684
957
  ],
685
958
  };
686
959
  }
687
960
 
688
- if (!(await hasWorkingChanges())) {
961
+ if (!(await hasTestChanges())) {
689
962
  return {
690
963
  content: [
691
964
  {
692
965
  type: "text",
693
- text: "⚠️ No changes detected. Make sure you have modifications to commit before running 'commit_and_push'.",
966
+ text: "⚠️ Please include test updates in your changes before running 'commit_and_push'. Ensure at least one test file is modified.",
694
967
  },
695
968
  ],
696
969
  };
@@ -709,8 +982,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
709
982
  };
710
983
  }
711
984
 
985
+ const stagedChanges = await getStagedChanges();
986
+ const { summary: generatedSummary, body: commitBody } =
987
+ createCommitMessageParts(stagedChanges, providedCommitMessage);
988
+
989
+ if (!generatedSummary) {
990
+ return {
991
+ content: [
992
+ {
993
+ type: "text",
994
+ text: "⚠️ Unable to generate commit message. Please provide a 'commitMessage' manually.",
995
+ },
996
+ ],
997
+ };
998
+ }
999
+
712
1000
  try {
713
- await exec(`git commit -m ${shellEscape(commitMessage)}`);
1001
+ if (commitBody) {
1002
+ await exec(
1003
+ `git commit -m ${shellEscape(generatedSummary)} -m ${shellEscape(commitBody)}`
1004
+ );
1005
+ } else {
1006
+ await exec(`git commit -m ${shellEscape(generatedSummary)}`);
1007
+ }
714
1008
  } catch (error) {
715
1009
  const output = error.stderr || error.stdout || error.message || "Unknown error";
716
1010
  if (output.includes("nothing to commit")) {
@@ -737,9 +1031,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
737
1031
  const requestedBranch =
738
1032
  typeof args.branch === "string" ? args.branch.trim() : "";
739
1033
  const currentBranch = await getCurrentBranch();
740
- const pushBranch = requestedBranch || currentBranch;
741
- const pushCommand = pushBranch
742
- ? `git push origin ${shellEscape(pushBranch)}`
1034
+ const pushCommand = requestedBranch
1035
+ ? `git push origin ${shellEscape(requestedBranch)}`
743
1036
  : "git push";
744
1037
 
745
1038
  try {
@@ -756,8 +1049,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
756
1049
  }
757
1050
 
758
1051
  workflowState.state.commitAndPushCompleted = true;
759
- workflowState.state.lastCommitMessage = commitMessage;
760
- workflowState.state.lastPushBranch = pushBranch || currentBranch || "";
1052
+ workflowState.state.lastCommitMessage = generatedSummary;
1053
+ workflowState.state.lastPushBranch =
1054
+ requestedBranch || currentBranch || "";
761
1055
  workflowState.state.currentPhase = workflowState.state.released
762
1056
  ? "ready_to_complete"
763
1057
  : "release";
@@ -767,7 +1061,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
767
1061
  content: [
768
1062
  {
769
1063
  type: "text",
770
- text: `✅ Commit & push completed!\n\nCommit message: ${commitMessage}\nPushed to: ${pushBranch || currentBranch || "(default upstream)"}\n\nNext: Run 'perform_release' to record the release.`,
1064
+ text: `✅ Commit & push completed!\n\nCommit message: ${generatedSummary}\nPushed to: ${
1065
+ requestedBranch || currentBranch || "(default upstream)"
1066
+ }\n\nNext: Run 'perform_release' to record the release.`,
771
1067
  },
772
1068
  ],
773
1069
  };
@@ -807,8 +1103,55 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
807
1103
  };
808
1104
  }
809
1105
 
1106
+ const releaseCommand =
1107
+ typeof args.command === "string" ? args.command.trim() : "";
1108
+
1109
+ if (!releaseCommand) {
1110
+ return {
1111
+ content: [
1112
+ {
1113
+ type: "text",
1114
+ text: "⚠️ Please provide a non-empty 'command' when using 'perform_release'.",
1115
+ },
1116
+ ],
1117
+ };
1118
+ }
1119
+
1120
+ if (await hasWorkingChanges()) {
1121
+ return {
1122
+ content: [
1123
+ {
1124
+ type: "text",
1125
+ text: "⚠️ Working tree not clean! Please commit or stash all changes before running 'perform_release'.",
1126
+ },
1127
+ ],
1128
+ };
1129
+ }
1130
+
1131
+ const lastCommitMessage = await getLastCommitMessage();
1132
+ const releaseType = determineReleaseTypeFromCommit(
1133
+ lastCommitMessage || workflowState.state.lastCommitMessage
1134
+ );
1135
+
1136
+ const finalCommand = releaseCommand.includes("--release-as")
1137
+ ? releaseCommand
1138
+ : `${releaseCommand} -- --release-as ${releaseType}`;
1139
+
1140
+ try {
1141
+ await exec(finalCommand);
1142
+ } catch (error) {
1143
+ return {
1144
+ content: [
1145
+ {
1146
+ type: "text",
1147
+ text: `❌ Release command failed:\n\n${error.stderr || error.stdout || error.message}`,
1148
+ },
1149
+ ],
1150
+ };
1151
+ }
1152
+
810
1153
  workflowState.state.released = true;
811
- workflowState.state.releaseCommand = args.command;
1154
+ workflowState.state.releaseCommand = finalCommand;
812
1155
  workflowState.state.releaseNotes =
813
1156
  typeof args.notes === "string" ? args.notes : "";
814
1157
  workflowState.state.currentPhase = "ready_to_complete";
@@ -818,7 +1161,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
818
1161
  content: [
819
1162
  {
820
1163
  type: "text",
821
- text: `🚀 Release recorded!\n\nCommand: ${args.command}\n${workflowState.state.releaseNotes ? `Notes: ${workflowState.state.releaseNotes}\n` : ""}\n✅ Next: Run 'complete_task' to wrap up.`,
1164
+ text: `🚀 Release recorded!\n\nCommand: ${finalCommand}\nRelease type: ${releaseType}\n${workflowState.state.releaseNotes ? `Notes: ${workflowState.state.releaseNotes}\n` : ""}\n✅ Next: Run 'complete_task' to wrap up.`,
822
1165
  },
823
1166
  ],
824
1167
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "MCP server to enforce development workflow discipline",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -3,7 +3,14 @@ import assert from "node:assert/strict";
3
3
  import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
- import { WorkflowState, getNextStep } from "../index.js";
6
+ import {
7
+ WorkflowState,
8
+ getNextStep,
9
+ containsTestFilesInStatus,
10
+ createCommitMessageParts,
11
+ determineReleaseTypeFromCommit,
12
+ workingTreeSummary,
13
+ } from "../index.js";
7
14
 
8
15
  async function withWorkflowState(callback) {
9
16
  const tempDir = await mkdtemp(path.join(tmpdir(), "workflow-state-"));
@@ -118,3 +125,80 @@ test("getNextStep guides workflow progression", () => {
118
125
  const afterRelease = { ...afterCommit, released: true };
119
126
  assert.equal(getNextStep(afterRelease), "Complete the task");
120
127
  });
128
+
129
+ test("containsTestFilesInStatus detects modified test files", () => {
130
+ const statusOutput = [
131
+ " M src/app.test.js",
132
+ "A tests/helpers/util.js",
133
+ "R src/old.js -> src/new.spec.ts",
134
+ ].join("\n");
135
+
136
+ assert.equal(containsTestFilesInStatus(statusOutput), true);
137
+ });
138
+
139
+ test("containsTestFilesInStatus returns false when no test files present", () => {
140
+ const statusOutput = [
141
+ " M src/app.js",
142
+ "A docs/README.md",
143
+ "?? assets/logo.png",
144
+ ].join("\n");
145
+
146
+ assert.equal(containsTestFilesInStatus(statusOutput), false);
147
+ });
148
+
149
+ test("createCommitMessageParts generates test prefixed summary with details", () => {
150
+ const changes = [
151
+ { status: "M", path: "src/app.test.js" },
152
+ { status: "A", path: "tests/helpers/util.js" },
153
+ ];
154
+
155
+ const { summary, body } = createCommitMessageParts(changes);
156
+
157
+ assert.equal(summary.startsWith("test:"), true);
158
+ assert.equal(body.includes("- modified src/app.test.js"), true);
159
+ assert.equal(body.includes("- added tests/helpers/util.js"), true);
160
+ });
161
+
162
+ test("createCommitMessageParts respects provided summary when present", () => {
163
+ const changes = [{ status: "M", path: "docs/guide.md" }];
164
+ const provided = "docs: update guide";
165
+
166
+ const { summary, body } = createCommitMessageParts(changes, provided);
167
+
168
+ assert.equal(summary, provided);
169
+ assert.equal(body.includes("- modified docs/guide.md"), true);
170
+ });
171
+
172
+ test("determineReleaseTypeFromCommit returns major for breaking change", () => {
173
+ const message = "feat!: overhaul API\n\nBREAKING CHANGE: new behavior";
174
+ assert.equal(determineReleaseTypeFromCommit(message), "major");
175
+ });
176
+
177
+ test("determineReleaseTypeFromCommit returns minor for feat", () => {
178
+ const message = "feat: add dashboard";
179
+ assert.equal(determineReleaseTypeFromCommit(message), "minor");
180
+ });
181
+
182
+ test("determineReleaseTypeFromCommit returns patch for fix", () => {
183
+ const message = "fix: resolve login issue";
184
+ assert.equal(determineReleaseTypeFromCommit(message), "patch");
185
+ });
186
+
187
+ test("determineReleaseTypeFromCommit defaults to patch for other commit types", () => {
188
+ const message = "chore: update dependencies";
189
+ assert.equal(determineReleaseTypeFromCommit(message), "patch");
190
+ });
191
+
192
+ test("workingTreeSummary reports no changes for empty output", () => {
193
+ const summary = workingTreeSummary("\n \n");
194
+ assert.equal(summary.hasChanges, false);
195
+ assert.deepEqual(summary.lines, []);
196
+ });
197
+
198
+ test("workingTreeSummary lists trimmed status lines", () => {
199
+ const statusOutput = " M src/app.js\nA tests/new.test.js\n";
200
+ const summary = workingTreeSummary(statusOutput);
201
+
202
+ assert.equal(summary.hasChanges, true);
203
+ assert.deepEqual(summary.lines, ["M src/app.js", "A tests/new.test.js"]);
204
+ });