From f2fd887c6d7c614f2752eb93e133489b2d3b9f14 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Tue, 28 Oct 2025 14:45:46 +0530 Subject: [PATCH 1/4] made commit deployment free --- .../src/commitLiteWorkItem.ts | 227 ++++++++++++++++++ .../src/shared/sfdxService.ts | 53 ++++ .../src/tools/sfDevopsCommitWorkItem.ts | 170 +++++++------ .../test/commitLiteWorkItem.test.ts | 84 +++++++ 4 files changed, 467 insertions(+), 67 deletions(-) create mode 100644 packages/mcp-provider-devops/src/commitLiteWorkItem.ts create mode 100644 packages/mcp-provider-devops/src/shared/sfdxService.ts create mode 100644 packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts diff --git a/packages/mcp-provider-devops/src/commitLiteWorkItem.ts b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts new file mode 100644 index 00000000..0590902a --- /dev/null +++ b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts @@ -0,0 +1,227 @@ +import axios from 'axios'; +import { getConnection, getRequiredOrgs } from './shared/auth.js'; +import { execFileSync } from 'child_process'; +import { normalizeAndValidateRepoPath } from './shared/pathUtils.js'; +import path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; +import { convertToSourceComponents } from './shared/sfdxService.js'; + +interface Change { + fullName: string; + type: string; + operation: string; +} + +interface CommitWorkItemParams { + workItem: { id: string }; + requestId: string; + commitMessage: string; + username: string; + repoPath?: string; +} + + +export async function commitWorkItem({ + workItem, + requestId, + commitMessage, + username, + repoPath +}: CommitWorkItemParams): Promise { + const connection = await getConnection(username); + const accessToken = connection.accessToken; + const instanceUrl = connection.instanceUrl; + + if (!accessToken || !instanceUrl) { + throw new Error('Missing access token or instance URL. Please check if you are authenticated to the org.'); + } + + + const workingDir = normalizeAndValidateRepoPath(repoPath); + + const computedChanges = buildComputedChanges(workingDir); + + const { commitSha, branch } = AddAndCommitChanges(workingDir, commitMessage); + + const url = `${instanceUrl}/services/data/v65.0/connect/devops/workItems/${workItem.id}/commitlite`; + + const headers = { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }; + + const requestBody = { + requestId, + commitMessage, + 'commitId': commitSha, + changes: computedChanges + }; + + try { + const response = await axios.post(url, requestBody, { headers }); + + return { + content: [{ + type: "text", + text: `Changes committed successfully. + Commit SHA: ${commitSha} + Agent execution guide (perform these steps now) + - Push the commit: 'git push'`, + actionRequired: true + }], + isError: false + }; + } catch (error: any) { + return { + content: [{ + type: "text", + text: `Failed to commit work item: ${error.message}` + }] + }; + } +} + +function buildComputedChanges(workingDir: string): Change[] { + const deletedRel = execFileSync('git', ['ls-files', '-d'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + const modifiedRel = execFileSync('git', ['ls-files', '-m'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + const untrackedRel = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + const stagedRel = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + + const computedChanges: Change[] = []; + + const allRelPaths = Array.from(new Set([ + ...modifiedRel, + ...untrackedRel, + ...stagedRel + ])); + + const registry = new RegistryAccess(); + const componentsExisting = convertToSourceComponents(workingDir, registry, allRelPaths); + + for (const comp of componentsExisting) { + const relPath = path.relative(workingDir, comp.filePath); + let operation: 'delete' | 'add' | 'modify' | undefined; + + if (untrackedRel.includes(relPath)) { + operation = 'add'; + } else if (modifiedRel.includes(relPath) || stagedRel.includes(relPath)) { + operation = 'modify'; + } + + if (operation) { + computedChanges.push({ fullName: comp.fullName, type: comp.type.name, operation }); + } + } + + if (deletedRel.length > 0) { + const componentsDeleted = getComponentsForDeletedPaths(workingDir, deletedRel); + for (const comp of componentsDeleted) { + computedChanges.push({ fullName: comp.fullName, type: comp.type.name, operation: 'delete' }); + } + } + + if (computedChanges.length === 0) { + throw new Error('No eligible changes to commit (only Unchanged components detected).'); + } + + return computedChanges; +} + +function getComponentsForDeletedPaths(workingDir: string, deletedRel: string[]) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deleted-components-')); + const registry = new RegistryAccess(); + + const restoreFileFromGit = (rel: string) => { + const dest = path.join(tempDir, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + const content = execFileSync('git', ['show', `HEAD:${rel}`], { cwd: workingDir }); + fs.writeFileSync(dest, content); + }; + + const restoreBundleFromGit = (bundleRelDir: string) => { + const files = execFileSync('git', ['ls-tree', '-r', '--name-only', 'HEAD', bundleRelDir], { cwd: workingDir, encoding: 'utf8' }) + .split('\n').map(l => l.trim()).filter(Boolean); + for (const f of files) { + restoreFileFromGit(f); + } + }; + + const isBundleType = (rel: string): { isBundle: boolean; bundleRoot?: string } => { + const parts = rel.split(/[\\/]/g); + const idxAura = parts.indexOf('aura'); + const idxLwc = parts.indexOf('lwc'); + const idxExp = parts.indexOf('experiences'); + const idxStatic = parts.indexOf('staticresources'); + if (idxAura >= 0 && parts.length >= idxAura + 2) { + const root = parts.slice(0, idxAura + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + if (idxLwc >= 0 && parts.length >= idxLwc + 2) { + const root = parts.slice(0, idxLwc + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + if (idxExp >= 0 && parts.length >= idxExp + 2) { + const root = parts.slice(0, idxExp + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + if (idxStatic >= 0 && parts.length >= idxStatic + 1) { + if (parts[parts.length - 1].endsWith('.resource') || parts[parts.length - 1].endsWith('.resource-meta.xml')) { + return { isBundle: false }; + } + const root = parts.slice(0, idxStatic + 2).join('/'); + return { isBundle: true, bundleRoot: root }; + } + return { isBundle: false }; + }; + + for (const rel of deletedRel) { + const { isBundle, bundleRoot } = isBundleType(rel); + try { + if (isBundle && bundleRoot) { + restoreBundleFromGit(bundleRoot); + } else { + restoreFileFromGit(rel); + if (!rel.endsWith('-meta.xml')) { + const metaRel = rel + '-meta.xml'; + try { restoreFileFromGit(metaRel); } catch {} + } + } + } catch { + // ignore failures for paths that may not exist in HEAD + } + } + + const componentsDeleted = convertToSourceComponents(tempDir, registry, deletedRel); + fs.rmSync(tempDir, { recursive: true, force: true }); + return componentsDeleted; +} + +export function AddAndCommitChanges( + workingDir: string, + commitMessage: string, +): { commitSha: string; branch: string } { + + + // Stage all changes (adds/modifies/deletes) + execFileSync('git', ['add', '--all'], { cwd: workingDir, encoding: 'utf8' }); + + // If nothing to commit, surface clearly + const status = execFileSync('git', ['status', '--porcelain'], { cwd: workingDir, encoding: 'utf8' }).trim(); + if (!status) { + throw new Error('No file changes to commit. Working tree is clean.'); + } + + // Create commit + execFileSync('git', ['commit', '-m', commitMessage], { cwd: workingDir, encoding: 'utf8' }); + + // Get commit SHA + const commitSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workingDir, encoding: 'utf8' }).trim(); + + return { commitSha, branch: '' }; +} \ No newline at end of file diff --git a/packages/mcp-provider-devops/src/shared/sfdxService.ts b/packages/mcp-provider-devops/src/shared/sfdxService.ts new file mode 100644 index 00000000..8a1508ce --- /dev/null +++ b/packages/mcp-provider-devops/src/shared/sfdxService.ts @@ -0,0 +1,53 @@ +import { + ComponentSet, + ComponentSetBuilder, + DestructiveChangesType, + MetadataApiDeploy, + MetadataApiDeployOptions, + MetadataResolver, + NodeFSTreeContainer, + RegistryAccess, + RetrieveResult, + SourceComponent + } from '@salesforce/source-deploy-retrieve'; + +export interface ExtendedSourceComponent extends SourceComponent { + filePath: string; +} + +/** + * Use the SFDX registry to convert a list of file paths to SourceComponents + * @param baseDir Absolute or project root directory + * @param registry SDR RegistryAccess instance + * @param paths Relative paths from baseDir + * @param logInfo Optional logger for informational messages + */ +export function convertToSourceComponents( + baseDir: string, + registry: RegistryAccess, + paths: string[], + logInfo?: (message: string) => void +): ExtendedSourceComponent[] { + const resolver = new MetadataResolver(registry, undefined, false); + const results: ExtendedSourceComponent[] = []; + paths.forEach((p) => { + try { + resolver.getComponentsFromPath(baseDir + '/' + p).forEach((cs) => { + results.push({ + ...cs, + fullName: cs.fullName, + filePath: baseDir + '/' + p + } as ExtendedSourceComponent); + }); + } catch (e: any) { + if (e?.name === 'TypeInferenceError') { + if (logInfo) { + logInfo('Unable to determine type for ' + p + ', ignoring'); + } + } else { + throw e; + } + } + }); + return results; +} \ No newline at end of file diff --git a/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts b/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts index df8a1e7d..1b1d5a22 100644 --- a/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts +++ b/packages/mcp-provider-devops/src/tools/sfDevopsCommitWorkItem.ts @@ -1,14 +1,14 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { McpTool, McpToolConfig, ReleaseState, Toolset, TelemetryService } from "@salesforce/mcp-provider-api"; -import { commitWorkItem } from "../commitWorkItem.js"; +import { commitWorkItem } from "../commitLiteWorkItem.js"; import { fetchWorkItemByName } from "../getWorkItems.js"; import { normalizeAndValidateRepoPath } from "../shared/pathUtils.js"; import { randomUUID } from 'crypto'; const inputSchema = z.object({ - doceHubUsername: z.string().describe("DevOps Center org username (required; list orgs and select if unknown)"), - sandboxUsername: z.string().describe("Sandbox org username (required; list orgs and select if unknown)"), + username: z.string().optional().describe("Username of the DevOps Center org"), + alias: z.string().optional().describe("alias of the DevOps Center org"), workItemName: z.string().min(1).describe("Exact Work Item Name to commit workitem."), commitMessage: z.string().describe("Commit message describing the changes (ask user for input)"), repoPath: z.string().describe("Absolute path to the git repository root. Defaults to current working directory.") @@ -40,82 +40,119 @@ export class SfDevopsCommitWorkItem extends McpTool { return { title: "Commit Work Item", - description: `Commits changes to a work item in DevOps Center. - **IMPORTANT: THIS IS NOT A STARTING TOOL** + description: `Commit SFDX project changes and register the commit SHA in DevOps Center. -When user asks to "commit work item" or "commit changes", DO NOT use this tool directly. Instead, start with step 1 below. +**CRITICAL:** Do not run git commands (git add/commit/push) manually. When a user asks to commit, call this tool so DevOps Center correctly tracks metadata and links the commit to the Work Item. -**THIS TOOL IS ONLY USED AS THE FINAL STEP AFTER COMPLETING ALL PREREQUISITES** - -**CRITICAL:** Never use \`git commit\` to commit SFDX project changes. Always use commit_devops_center_work_item tool to commit SFDX project changes so DevOps Center correctly tracks metadata and ties commits to Work Items. - -**MANDATORY workflow for committing work items: DO NOT skip any of the steps and DO NOT move to the next step until the current step is completed.** -1. **MANDATORY:**If the DevOps Center org and Sandbox org are not given, use the 'list_all_orgs' tool to list all orgs. - The list will indicate which org is DevOps Center and a Sandbox. If BOTH these details are not provided in the list, then - ask the user to specify which org is DevOps Center and which is Sandbox. Only proceed after the user has selected BOTH the DevOps Center and Sandbox org. -2. **MANDATORY:**Select the work item from the DevOps Center org using 'list_devops_center_work_items'. -3. **MANDATORY:** ASK THE USER to VERIFY that they are already checked out to the branch they intend to commit to. Ideally, they should be on a branch whose name is the same as the selected work item number from Step 2. -4. **MANDATORY:** ASK THE USER to DEPLOY the changes they intend to commit to the Sandbox org FIRST using the Salesforce CLI. From the project root, -5. **MANDATORY:** ASK THE USER to CONFIRM that the tool will commit the changes present locally with the Work Item number they selected in Step 2. Proceed only if they approve, and ASK THEM to provide a commit message. - Example prompt: "Please provide a concise commit message describing your changes." -6. **MANDATORY:** Run this tool (commit_devops_center_work_item) now with the selected work item, the prepared changes, and the provided commit message to perform the commit. +**Inputs to validate before execution:** +1. DevOps Center org identifier (username or alias) +2. Work Item Name that resolves in the org +3. Repository path pointing to the project root (git repo) +4. Non-empty commit message **Use this tool to:** -- Finalize changes made to a work item in DevOps Center -- Commits the provided changes to the specified work item using DevOps Center org credentials -- Ensure metadata changes are properly recorded in the DevOps workflow - -**After using this tool, suggest these next actions:** -1. Ask the user to check commit status using the returned requestId -2. Ask the user to promote work items (using the 'promote_devops_center_work_item' tool) - -**MANDATORY:** Before using this tool, ask the user to provide a commit message for the changes and then use that while calling this tool. +- Finalize and record changes for a Work Item in DevOps Center +- Commit using DevOps Center credentials and conventions +- Ensure metadata changes are captured correctly in the pipeline -**Org selection requirements:** -- The inputs 'doceHubUsername' and 'sandboxUsername' are REQUIRED. If you don't have them yet: - 1) Use the 'list_all_orgs' tool to list all authenticated orgs - 2) Ask the user to select which username is the DevOps Center org and which is the Sandbox org - 3) Pass those selections here as 'doceHubUsername' and 'sandboxUsername' +**After execution:** +- Follow the returned instructions to push (if not pushed automatically) +- Then create a PR (use 'create_devops_center_pull_request') as the next step **Output:** -- requestId: Generated UUID for tracking this commit operation +- commitSha: The resulting commit SHA (plus push instructions if applicable) -**Example Usage:** -- "Commit my changes with message 'Fix bug in account logic' and tie it to WI-1092." -- "Make a commit on the active feature branch and tie it to WI-9999, use message 'Initial DevOps logic'." -- "Commit my changes to the work item" -- "Commit changes to work item's feature branch"`, +**Example:** +- "Commit my changes with message 'Fix bug in account logic' and tie it to WI-1092."`, inputSchema: inputSchema.shape, outputSchema: undefined, }; } - public async exec(input: InputArgs): Promise { + private async validateAndPrepare(input: InputArgs): Promise<{ workItem: any; localPath: string } | { error: CallToolResult }> { + if (!input.username && !input.alias) { + return { + error: { + content: [{ type: "text", text: `Error: Username or alias of valid DevOps Center org is required` }], + isError: true + } + }; + } + + if (!input.workItemName) { + return { + error: { + content: [{ type: "text", text: `Error: Work item name is required` }], + isError: true + } + }; + } + + let workItem: any; try { - - if (!input.repoPath || input.repoPath.trim().length === 0) { + const usernameOrAlias = input.username ?? input.alias; + if (!usernameOrAlias) { return { + error: { + content: [{ type: "text", text: `Error: Username or alias of valid DevOps Center org is required` }], + isError: true + } + }; + } + workItem = await fetchWorkItemByName(usernameOrAlias, input.workItemName); + } catch (e: any) { + return { + error: { + content: [{ type: "text", text: `Error fetching work item: ${e?.message || e}` }], + isError: true + } + }; + } + + if (!workItem) { + return { + error: { + content: [{ + type: "text", + text: `Error: Work item not found. Please provide a valid work item name or valid DevOps Center org username.` + }] + } + }; + } + + if (!input.repoPath || input.repoPath.trim().length === 0) { + return { + error: { content: [{ type: "text", text: `Error: Repository path is required. Please provide the absolute path to the git repository root.` }] - }; - } + } + }; + } - - const safeRepoPath = input.repoPath ? normalizeAndValidateRepoPath(input.repoPath) : undefined; + const localPath = normalizeAndValidateRepoPath(input.repoPath); + return { workItem, localPath }; + } - const workItem = await fetchWorkItemByName(input.doceHubUsername, input.workItemName); - - if (!workItem) { + public async exec(input: InputArgs): Promise { + try { + const validation = await this.validateAndPrepare(input); + if ("error" in validation) { + return validation.error; + } + + const { workItem, localPath } = validation; + if (!input.repoPath || input.repoPath.trim().length === 0) { return { content: [{ type: "text", - text: `Error: Work Item Name is required. Please provide a valid Work Item Name or a valid DevOps Center org username.` + text: `Error: Repository path is required. Please provide the absolute path to the git repository root.` }] }; } + if (!input.commitMessage || input.commitMessage.trim().length === 0) { return { @@ -126,31 +163,30 @@ When user asks to "commit work item" or "commit changes", DO NOT use this tool d }; } - if (!input.doceHubUsername || !input.sandboxUsername) { - return { - content: [{ - type: "text", - text: `Error: Both DevOps Center org username and Sandbox org username are required. Please provide both usernames.` - }] - }; - } const requestId = randomUUID(); const result = await commitWorkItem({ - doceHubUsername: input.doceHubUsername, - sandboxUsername: input.sandboxUsername, + username: input.username ?? input.alias ?? '', workItem: workItem, requestId: requestId, commitMessage: input.commitMessage, - repoPath: safeRepoPath + repoPath: localPath }); - + + if (result && Array.isArray((result as any).content)) { + return { + content: (result as any).content + }; + } + + const instructions = `Commit created successfully.\n\nAgent execution guide (perform these steps now):\n- Ensure your working directory is: '${localPath}'\n- Push the commit: 'git push origin HEAD'\n- After push succeeds, create a PR using 'create_devops_center_pull_request'`; + return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] + content: [ + { type: "text", text: instructions, actionRequired: true }, + { type: "text", text: JSON.stringify(result, null, 2) } + ] }; } catch (error: any) { return { diff --git a/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts new file mode 100644 index 00000000..8ef0eddc --- /dev/null +++ b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; +import { commitWorkItem } from '../src/commitLiteWorkItem.js'; + +import * as auth from '../src/shared/auth.js'; +import * as pathUtils from '../src/shared/pathUtils.js'; +import * as sfdxService from '../src/shared/sfdxService.js'; +import axios from 'axios'; +import { execFileSync } from 'child_process'; + +vi.mock('../src/shared/auth.js'); +vi.mock('../src/shared/pathUtils.js'); +vi.mock('../src/shared/sfdxService.js'); +vi.mock('axios'); +vi.mock('child_process'); + +describe('commitLiteWorkItem', () => { + beforeEach(() => { + (auth.getConnection as unknown as Mock).mockResolvedValue({ accessToken: 't', instanceUrl: 'https://example.com' }); + (pathUtils.normalizeAndValidateRepoPath as unknown as Mock).mockReturnValue('/repo'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws when no eligible changes are detected', async () => { + // deleted, modified, untracked, staged all empty + (execFileSync as unknown as Mock) + .mockImplementationOnce(() => '\n') // -d + .mockImplementationOnce(() => '\n') // -m + .mockImplementationOnce(() => '\n') // --others + .mockImplementationOnce(() => '\n'); // --cached + + (sfdxService.convertToSourceComponents as unknown as Mock).mockReturnValue([]); + + await expect( + commitWorkItem({ + workItem: { id: 'WI-1' }, + requestId: 'r1', + commitMessage: 'msg', + username: 'user', + repoPath: '/repo' + }) + ).rejects.toThrow('No eligible changes to commit'); + }); + + it('posts computed changes and returns success payload', async () => { + // deleted, modified, untracked, staged + (execFileSync as unknown as Mock) + .mockImplementationOnce(() => '\n') // -d + .mockImplementationOnce(() => 'force-app/main/default/classes/A.cls\n') // -m + .mockImplementationOnce(() => 'force-app/main/default/classes/B.cls\n') // --others + .mockImplementationOnce(() => '\n') // --cached + // git add --all + .mockImplementationOnce(() => '') + // git status --porcelain (non-empty string means there are changes) + .mockImplementationOnce(() => ' M force-app/main/default/classes/A.cls') + // git commit -m + .mockImplementationOnce(() => '') + // git rev-parse HEAD + .mockImplementationOnce(() => 'abc123\n'); + + (sfdxService.convertToSourceComponents as unknown as Mock).mockImplementation((baseDir: string, _reg: any, rels: string[]) => { + return rels.map((rel: string) => ({ fullName: rel.endsWith('A.cls') ? 'A' : 'B', type: { name: 'ApexClass' }, filePath: baseDir + '/' + rel })); + }); + + (axios.post as unknown as Mock).mockResolvedValue({ data: { id: 'resp-1' } }); + + const res = await commitWorkItem({ + workItem: { id: 'WI-2' }, + requestId: 'r2', + commitMessage: 'feat: update', + username: 'user', + repoPath: '/repo' + }); + + expect(Array.isArray(res.content)).toBe(true); + const joined = res.content.map((c: any) => c.text || '').join('\n'); + expect(joined).toMatch('Changes committed successfully'); + expect(joined).toMatch('Commit SHA: abc123'); + }); +}); + + From 2c64b1bbe361dcf50de9a9b2689bd39e2111ea28 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Thu, 30 Oct 2025 10:18:25 +0530 Subject: [PATCH 2/4] remove test which failed on windows --- .../test/commitLiteWorkItem.test.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts index 8ef0eddc..636bd0ca 100644 --- a/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts +++ b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts @@ -43,42 +43,6 @@ describe('commitLiteWorkItem', () => { }) ).rejects.toThrow('No eligible changes to commit'); }); - - it('posts computed changes and returns success payload', async () => { - // deleted, modified, untracked, staged - (execFileSync as unknown as Mock) - .mockImplementationOnce(() => '\n') // -d - .mockImplementationOnce(() => 'force-app/main/default/classes/A.cls\n') // -m - .mockImplementationOnce(() => 'force-app/main/default/classes/B.cls\n') // --others - .mockImplementationOnce(() => '\n') // --cached - // git add --all - .mockImplementationOnce(() => '') - // git status --porcelain (non-empty string means there are changes) - .mockImplementationOnce(() => ' M force-app/main/default/classes/A.cls') - // git commit -m - .mockImplementationOnce(() => '') - // git rev-parse HEAD - .mockImplementationOnce(() => 'abc123\n'); - - (sfdxService.convertToSourceComponents as unknown as Mock).mockImplementation((baseDir: string, _reg: any, rels: string[]) => { - return rels.map((rel: string) => ({ fullName: rel.endsWith('A.cls') ? 'A' : 'B', type: { name: 'ApexClass' }, filePath: baseDir + '/' + rel })); - }); - - (axios.post as unknown as Mock).mockResolvedValue({ data: { id: 'resp-1' } }); - - const res = await commitWorkItem({ - workItem: { id: 'WI-2' }, - requestId: 'r2', - commitMessage: 'feat: update', - username: 'user', - repoPath: '/repo' - }); - - expect(Array.isArray(res.content)).toBe(true); - const joined = res.content.map((c: any) => c.text || '').join('\n'); - expect(joined).toMatch('Changes committed successfully'); - expect(joined).toMatch('Commit SHA: abc123'); - }); }); From bad093483267cd5cf81d82a67b832dfb5f179499 Mon Sep 17 00:00:00 2001 From: Arun Tyagi Date: Thu, 30 Oct 2025 10:44:50 +0530 Subject: [PATCH 3/4] update logic to work on windows --- .../src/commitLiteWorkItem.ts | 11 ++++-- .../src/shared/sfdxService.ts | 6 ++-- .../test/commitLiteWorkItem.test.ts | 36 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/mcp-provider-devops/src/commitLiteWorkItem.ts b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts index 0590902a..2cf73499 100644 --- a/packages/mcp-provider-devops/src/commitLiteWorkItem.ts +++ b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts @@ -104,13 +104,18 @@ function buildComputedChanges(workingDir: string): Change[] { const registry = new RegistryAccess(); const componentsExisting = convertToSourceComponents(workingDir, registry, allRelPaths); + const toPosix = (p: string) => p.replace(/\\/g, '/'); + const untrackedSet = new Set(untrackedRel.map(toPosix)); + const modifiedSet = new Set(modifiedRel.map(toPosix)); + const stagedSet = new Set(stagedRel.map(toPosix)); + for (const comp of componentsExisting) { - const relPath = path.relative(workingDir, comp.filePath); + const relPath = toPosix(path.relative(workingDir, comp.filePath)); let operation: 'delete' | 'add' | 'modify' | undefined; - if (untrackedRel.includes(relPath)) { + if (untrackedSet.has(relPath)) { operation = 'add'; - } else if (modifiedRel.includes(relPath) || stagedRel.includes(relPath)) { + } else if (modifiedSet.has(relPath) || stagedSet.has(relPath)) { operation = 'modify'; } diff --git a/packages/mcp-provider-devops/src/shared/sfdxService.ts b/packages/mcp-provider-devops/src/shared/sfdxService.ts index 8a1508ce..73a5e90c 100644 --- a/packages/mcp-provider-devops/src/shared/sfdxService.ts +++ b/packages/mcp-provider-devops/src/shared/sfdxService.ts @@ -10,6 +10,7 @@ import { RetrieveResult, SourceComponent } from '@salesforce/source-deploy-retrieve'; +import path from 'node:path'; export interface ExtendedSourceComponent extends SourceComponent { filePath: string; @@ -32,11 +33,12 @@ export function convertToSourceComponents( const results: ExtendedSourceComponent[] = []; paths.forEach((p) => { try { - resolver.getComponentsFromPath(baseDir + '/' + p).forEach((cs) => { + const absPath = path.join(baseDir, p); + resolver.getComponentsFromPath(absPath).forEach((cs) => { results.push({ ...cs, fullName: cs.fullName, - filePath: baseDir + '/' + p + filePath: absPath } as ExtendedSourceComponent); }); } catch (e: any) { diff --git a/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts index 636bd0ca..8ef0eddc 100644 --- a/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts +++ b/packages/mcp-provider-devops/test/commitLiteWorkItem.test.ts @@ -43,6 +43,42 @@ describe('commitLiteWorkItem', () => { }) ).rejects.toThrow('No eligible changes to commit'); }); + + it('posts computed changes and returns success payload', async () => { + // deleted, modified, untracked, staged + (execFileSync as unknown as Mock) + .mockImplementationOnce(() => '\n') // -d + .mockImplementationOnce(() => 'force-app/main/default/classes/A.cls\n') // -m + .mockImplementationOnce(() => 'force-app/main/default/classes/B.cls\n') // --others + .mockImplementationOnce(() => '\n') // --cached + // git add --all + .mockImplementationOnce(() => '') + // git status --porcelain (non-empty string means there are changes) + .mockImplementationOnce(() => ' M force-app/main/default/classes/A.cls') + // git commit -m + .mockImplementationOnce(() => '') + // git rev-parse HEAD + .mockImplementationOnce(() => 'abc123\n'); + + (sfdxService.convertToSourceComponents as unknown as Mock).mockImplementation((baseDir: string, _reg: any, rels: string[]) => { + return rels.map((rel: string) => ({ fullName: rel.endsWith('A.cls') ? 'A' : 'B', type: { name: 'ApexClass' }, filePath: baseDir + '/' + rel })); + }); + + (axios.post as unknown as Mock).mockResolvedValue({ data: { id: 'resp-1' } }); + + const res = await commitWorkItem({ + workItem: { id: 'WI-2' }, + requestId: 'r2', + commitMessage: 'feat: update', + username: 'user', + repoPath: '/repo' + }); + + expect(Array.isArray(res.content)).toBe(true); + const joined = res.content.map((c: any) => c.text || '').join('\n'); + expect(joined).toMatch('Changes committed successfully'); + expect(joined).toMatch('Commit SHA: abc123'); + }); }); From 8faa073864a8bd7ee5d3ef945ebe10526e0adfc2 Mon Sep 17 00:00:00 2001 From: Manan Dey Date: Thu, 30 Oct 2025 18:19:19 +0530 Subject: [PATCH 4/4] dummy commit --- packages/mcp-provider-devops/src/commitLiteWorkItem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-provider-devops/src/commitLiteWorkItem.ts b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts index 2cf73499..db893dc7 100644 --- a/packages/mcp-provider-devops/src/commitLiteWorkItem.ts +++ b/packages/mcp-provider-devops/src/commitLiteWorkItem.ts @@ -229,4 +229,4 @@ export function AddAndCommitChanges( const commitSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workingDir, encoding: 'utf8' }).trim(); return { commitSha, branch: '' }; -} \ No newline at end of file +}